initial commit

This commit is contained in:
Basavarajappa N 2026-07-03 14:52:45 +05:30
commit 89d64e6196
89 changed files with 12058 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
node_modules
dist
dist-ssr
*.local
.DS_Store
.vite
# Editor
.vscode/*
!.vscode/extensions.json
.idea
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local env (base URL etc.)
.env
*.tsbuildinfo
# Local MCP config (contains credentials)
.mcp.json

93
README.md Normal file
View File

@ -0,0 +1,93 @@
# Aria Console
AI-employee-powered life-insurance **lead-to-policy** console for an Indian insurer.
Vite + React + TypeScript + Tailwind v4, built on the **Aria design system**.
## Run
```bash
npm install
npm run dev # http://localhost:5173
npm run build # type-check + production build
npm run typecheck # type-check only
```
Create `.env` (gitignored) pointing at the gateway:
```
VITE_ZINO_API_URL=https://sandbox.getzino.in
```
Sign in with a platform user (e.g. `admin@getzino.com` / `Zino`); the JWT is
persisted in localStorage under `lead_to_policy_token`.
## Backend wiring (live)
This console is wired to the **sm2 Zino gateway** — sandbox **app 385 "Lead to
Policy"** (org 53), workflow `e29c3c33-…` (v1). No mock data on the four screens.
`src/api/` is a thin SDK over the gateway:
| File | Purpose |
| --- | --- |
| `config.ts` | App/workflow/RV/DV/activity UIDs, field ids, select options, state↔stage maps |
| `client.ts` | `ZinoClient` — login, app-scoped view/workflow calls, JWT persistence |
| `provider.tsx` | `ZinoProvider` + `useZino` + a dependency-free `useQuery` hook |
| `adapters.ts` | Map live records → the Aria domain types (`Lead`, `Decision`, timeline) |
Endpoints used (all on the gateway):
- `POST /usr/login` — auth
- `POST /app/385/view/recordview` — pipeline + lead lookup (`rv_template_uid`)
- `GET /app/385/view/audit?instance_id=` — activity log
- `GET /monitor/decisions?instance_id=` — AI-employee decision stream
- `POST /app/385/activity` — perform Recommend Product / Collect Documents
Writes are real: submitting Recommend advances a lead Meeting Scheduled →
Product Recommended; submitting Documents advances Product Recommended →
Documents Collected. Document upload + OCR auto-extraction is intentionally
out of scope here (field-based KYC capture only).
## Screens
| Route | Screen | What it shows |
| --- | --- | --- |
| `/pipeline` | **Lead Pipeline** | KPI row, pipeline funnel, segment donut, leads table / kanban toggle. Click a lead → Lead 360. |
| `/leads/:id` | **Lead 360** | Profile, workflow stepper, current activity, AI decision stream + escalation callout. |
| `/recommend` | **Recommend Product** | Deep-linked (or picker-selected) lead; live indicative-premium helper, the lead's real AI suggestion, submits the Recommend Product activity. |
| `/documents` | **Documents** | Field-based KYC capture (PAN/Aadhaar/income/status), checklist + completion meter, submits the Collect Documents activity. |
## Architecture
```
src/
├── components/
│ ├── core/ Button, Card, Badge, Avatar, Input, Select, MultiSelect
│ ├── insurance/ ConfidenceRing, RupeeAmount, SegmentBadge, ChannelBadge,
│ │ KpiCard, WorkflowStepper, TimelineEntry, AIDecisionCard,
│ │ LeadRow, DocumentUploadCard
│ └── index.ts single import surface — `import { Button } from '@/components'`
├── layout/Shell.tsx navy sidebar (nav + AI roster) + topbar, routed via <Outlet/>
├── screens/ the four screens
├── data/mockData.ts typed mock data (Indian context)
├── types.ts shared domain types (Lead, Decision, Segment, Channel…)
├── lib/cn.ts className join helper
└── styles/ styles.css + tokens/ (colors, typography, spacing, fonts)
```
Every screen composes design-system components — they never re-implement primitives.
Components are typed, take a `className` for layout overrides, and are reusable across the app.
## Design tokens → Tailwind
`src/styles/styles.css` imports the Aria tokens (CSS custom properties) and wires them into
Tailwind's theme via `@theme inline`, so utilities resolve straight to the design system:
- `bg-navy-900`, `text-sunrise-600`, `bg-emerald-100` … full brand + semantic palette
- `bg-card`, `text-strong`, `text-muted`, `border-border-subtle` … semantic aliases
- `rounded-lg` (16px card), `rounded-md` (12px control), `rounded-pill`
- `shadow-sm/md/lg`, `shadow-sunrise`; `font-sans` (Inter), `font-numeric` (Inter Tight)
- brand gradients via `.bg-sunrise` / `.bg-navy-grad`; tabular figures via `.nums`
Brand: deep navy `#0B1B3B`, sunrise accent `#F26B3A → #FBA94C`, emerald success /
amber escalation; ₹ lakh/crore formatting; sentence case; no emoji.

44
cascade_city_by_state.sql Normal file
View File

@ -0,0 +1,44 @@
-- Cascade: City filtered by State on Add Lead activity (app 385)
-- Activity 26398, workflow_version 29852 (config row id 405)
-- State field = state_region_input (-> States dataset, value_key=state)
-- City field = city_input (-> Cities dataset, display=city, value=city_id)
--
-- Appends a filter_options rule to City: show only Cities rows where
-- Cities.state == current value of the State field.
-- Idempotent: skips if a rule with id 'rule_city_by_state' already exists.
BEGIN;
UPDATE workflow.tbl_wf_activity_ui_config
SET field_rules = COALESCE(field_rules, '[]'::jsonb) || '[
{
"id": "rule_city_by_state",
"action": "filter_options",
"active": true,
"conditions": null,
"action_value": null,
"target_field": "city_input",
"filter_config": {
"logic": "AND",
"conditions": [
{
"operator": "equals",
"form_field": "state_region_input",
"target_column": "state",
"source_value_key": ""
}
]
}
}
]'::jsonb,
updated_at = now()
WHERE activity_id = 26398
AND workflow_version_id = 29852
AND NOT COALESCE(field_rules, '[]'::jsonb) @> '[{"id": "rule_city_by_state"}]'::jsonb;
-- Verify (expect the new rule present)
SELECT id, activity_id, jsonb_pretty(field_rules) AS field_rules
FROM workflow.tbl_wf_activity_ui_config
WHERE activity_id = 26398 AND workflow_version_id = 29852;
COMMIT;

View File

@ -0,0 +1,42 @@
-- Cleanup phantom Schedule-page agents (app 385, sandbox)
-- Junk test rows where owner_agent is a bare placeholder string instead of {id,name}.
-- Effect: nulls owner_agent -> these leads collapse into "Unassigned" on the Schedule page.
-- Affected instances: 4489 (Sandy), 4490 (Alex), 4502 (Owner Agent), 4511/4512 (Owner Agent*)
--
-- NOTE: recordview reads from the denormalized companion table
-- tbl_wf_385_lead_to_policy, which is kept in sync only via the app write path.
-- A raw UPDATE to tbl_appdata_workflow_instances does NOT propagate, so we must
-- clean BOTH tables.
BEGIN;
-- Preview before committing
SELECT 'main' AS tbl, instance_id, data->'owner_agent' AS owner_agent
FROM public.tbl_appdata_workflow_instances
WHERE instance_id IN (4489, 4490, 4502, 4511, 4512)
UNION ALL
SELECT 'companion', instance_id, owner_agent
FROM public.tbl_wf_385_lead_to_policy
WHERE instance_id IN (4489, 4490, 4502, 4511, 4512);
-- 1. Main instance table: drop the bad owner_agent key (only bare-string placeholders)
UPDATE public.tbl_appdata_workflow_instances
SET data = data - 'owner_agent',
updated_at = now()
WHERE instance_id IN (4489, 4490, 4502, 4511, 4512)
AND jsonb_typeof(data->'owner_agent') = 'string';
-- 2. Companion recordview table: null out the bad owner_agent (only bare-string placeholders)
UPDATE public.tbl_wf_385_lead_to_policy
SET owner_agent = NULL
WHERE instance_id IN (4489, 4490, 4502, 4511, 4512)
AND jsonb_typeof(owner_agent) = 'string';
-- Verify (should return 0 rows)
SELECT 'main' AS tbl, instance_id FROM public.tbl_appdata_workflow_instances
WHERE instance_id IN (4489, 4490, 4502, 4511, 4512) AND data ? 'owner_agent'
UNION ALL
SELECT 'companion', instance_id FROM public.tbl_wf_385_lead_to_policy
WHERE instance_id IN (4489, 4490, 4502, 4511, 4512) AND owner_agent IS NOT NULL;
COMMIT;

85
flow-findings.md Normal file
View File

@ -0,0 +1,85 @@
# Lead-to-Policy (Aria) — Live Flow Findings
App **385**, workflow `e29c3c33` (db `workflow_id=29851`), org 53. Sandbox DB verified (`postgres-sandbox`, read-only) + AI decisions via `https://sandbox.getzino.in/monitor/decisions`.
Loop: `/loop 4m` (cron `d62147e6`). Each tick re-checks the latest instance, observes what the AI did, recommends the next human payload.
---
## State graph (state → activity → next state)
| # | State | Human/forward activity | → next state | Disqualify? |
|---|-------|------------------------|--------------|-------------|
| 1 | New Lead | Capture Lead `cc1a73d5` (INIT) | Qualified (via Qualify Lead) | yes |
| 2 | Qualified | **Assign Lead `da61aaa1`** | Assigned | yes |
| 3 | Assigned | Log First Contact `fea6b3a8` | Contacted | yes |
| 4 | Contacted | Schedule Meeting `c444d32f` | Meeting Scheduled | yes |
| 5 | Meeting Scheduled | Recommend Product `c0e2004e` (→ stage-gate `…5a01`) | Product Recommended | yes |
| 6 | Product Recommended | Collect Documents `a8eb0faa` | Documents Collected | yes |
| 7 | Documents Collected | Assess Eligibility `ba3d3e7f` (→ gate `…0a01`) | Eligibility Assessed | yes |
| 8 | Eligibility Assessed | Submit Underwriting `ed8ec0dc` (→ gate `…0b02`) | Underwriting | yes |
| 9 | Underwriting | Issue Policy `7b8fb734` (→ gate `…0c03`) | Policy Issued | yes |
| 10 | Policy Issued | Onboard Customer `ae415f4e` | Customer 360 (END) | — |
`Disqualify ea99fc60` is allowed in every non-terminal state → **Disqualified** (END).
`AI Product Recommendation 14d734da` is a self-loop on Meeting Scheduled (AI-only, no state change).
---
## AI employees & triggers (verified in `activity_triggers`)
Trigger = bound to a **source** activity; fires *after* a human performs it, then the bound AI employee autonomously performs the **next** workflow activity.
| Trigger on activity | AI employee | user_id | Effect |
|---------------------|-------------|---------|--------|
| Capture Lead `cc1a73d5` | Insurance AI (Aria) #6 | **29180** | auto-performs **Qualify Lead** → Qualified |
| Assign Lead `da61aaa1` | Aria (Engagement) #7 | **29181** | auto-performs next (Log First Contact) |
| Log First Contact `fea6b3a8` | Aria (Engagement) #7 | **29181** | auto-advances toward Schedule Meeting |
| Schedule Meeting `c444d32f` | Aria Advisor #9 | **29188** | logs **AI Product Recommendation** decision (self-loop) before human Recommend Product |
| Collect Documents `a8eb0faa` | Aria (Underwriting) #8 | **29182** | auto-advances underwriting steps |
| Onboard Customer `ae415f4e` | *(no AI)* automation pipeline | — | customJS Compute Maturity → generateDoc Policy Schedule → sendEmail welcome |
Note: Qualify Lead, Recommend Product, Assess Eligibility, Submit Underwriting, Issue Policy have **no** trigger bound → they are human/manual gates (no auto-advance).
---
## Observation log
### Tick 1 — 2026-06-26 ~07:25 (instance 5017, lead "Parikshith Takkar")
Flow observed (verified DB + monitor):
- `07:20:46`**Capture Lead** by human **29183** (Sales Agent). Lead: Bengaluru / Karnataka, Lawyer, ₹23L income, DOB 1976-05-25 (age 50), web, term interest, English.
- Capture trigger fired → **Insurance AI (Aria) 29180** auto-performed **Qualify Lead** at `07:21:31`:
- confidence **0.95**, status **submitted**, model `claude-sonnet-4-5`, 5 LLM calls, cost ~$0.124.
- set lead_score **95**, segment **hot**, annual_income 23,00,000, DOB confirmed.
- knowledge sources: Lead Qualification Guide, ABSLI Product Catalog, ABSLI Underwriting Rules.
- reasoning: age 50 within 18-65 entry; income supports ₹2.763.45 cr coverage; Lawyer = stable; no disqualifiers.
- **Current state: Qualified** (`41c7a19f`). No trigger on Qualify Lead → flow waits for human.
**→ Next human activity = Assign Lead.** Recommended payload below.
---
## ⭐ Recommended payload — next activity: **Assign Lead** (`da61aaa1`)
Fields (from `activity_data_fields`; field_rules cascade city → region → agent):
```json
{
"assign_city": "Bengaluru",
"assigned_region_input": "South",
"owner_agent_input": { "id": 1, "name": "Asha Rao" }
}
```
- `assign_city` — select, mapped `city`. Lead already in Bengaluru. Drives region filter.
- `assigned_region_input`**mandatory** lookup `tbl_branches.region`. Bengaluru branch (id 7, "Bengaluru MG Road") → region **South**. Filtered by city via rule `rule_region_by_city`.
- `owner_agent_input` — optional lookup `tbl_sales_agents` (stores id+name), filtered to region=South via `rule_agent_by_region`. South agents available: **Asha Rao (1)**, Karthik Iyer (7), Divya Menon (8). Pick any; Asha Rao recommended.
**After you submit Assign Lead:** trigger fires → **Aria (Engagement) 29181** auto-performs the next step (Log First Contact → Contacted). Watch `/monitor/decisions?instance_id=5017` for user `29181`.
---
### Tick 2 — 2026-06-26 ~07:29 — no change
Instance 5017 still in **Qualified** (`updated_at` 07:21:31, unchanged). Assign Lead not yet submitted. Recommendation above unchanged.

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/zino.svg" />
<title>Zino | Lead Management</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2391
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "aria-console",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "Aria — AI-employee-powered life-insurance lead-to-policy console",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"lucide-react": "^1.21.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"tailwindcss": "^4.0.0",
"typescript": "^5.5.4",
"vite": "^5.4.6"
}
}

8
public/zino.svg Normal file
View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="486.596" height="229" viewBox="0 0 486.596 229">
<g id="Zino_logo" data-name="Zino logo" transform="translate(-61 -34)">
<path id="Path_613" data-name="Path 613" d="M464.867,87.973a54.254,54.254,0,0,0-15.651-38.165,53.085,53.085,0,0,0-75.566,0A54.248,54.248,0,0,0,358,87.973V241.024s.193,21.3.763,21.589,7.674.391,12.6.386h.386c4.738,0,11.084-.01,11.827-.385.76-.384,1.142-173.286,1.145-174.632v-.008a27.124,27.124,0,0,1,7.825-19.082,26.542,26.542,0,0,1,37.783,0,27.129,27.129,0,0,1,7.826,19.082h0s-.534,53.337-.38,87.513c.152,33.726.76,85.2,1.14,86.356.362,1.1,6.936,1.13,11.81,1.153l.734,0c5.214.027,12.261-.452,13.3-1.157s0-173.869,0-173.869Z" transform="translate(-70.29)" fill="#ed563b" fill-rule="evenodd"/>
<path id="Path_614" data-name="Path 614" d="M661.263,64.763a46.933,46.933,0,0,1,4.067,19.071V209.842h0a53.434,53.434,0,0,1-106.844,1.424h-.022V83.834a46.934,46.934,0,0,1,4.068-19.071A49.748,49.748,0,0,1,574.114,48.6a53.837,53.837,0,0,1,17.335-10.8,57.009,57.009,0,0,1,40.9,0A53.84,53.84,0,0,1,649.68,48.6,49.76,49.76,0,0,1,661.263,64.763ZM585.18,209.744a26.717,26.717,0,0,0,53.433,0V83.834a23.467,23.467,0,0,0-2.034-9.535,24.88,24.88,0,0,0-5.791-8.084,26.926,26.926,0,0,0-8.668-5.4,28.505,28.505,0,0,0-20.448,0,26.927,26.927,0,0,0-8.668,5.4,24.88,24.88,0,0,0-5.791,8.084,23.466,23.466,0,0,0-2.034,9.535Z" transform="translate(-117.733)" fill="#ed563b" fill-rule="evenodd"/>
<path id="Path_615" data-name="Path 615" d="M69.512,35.045c-1.221.732-1.527,3.345-1.527,12.022s.305,11.29,1.527,12.022c.916.627,15.674,1.045,34.91,1.045,24.325,0,33.281.314,33.281,1.15,0,1.15-1.934,5.541-12.519,29.167-1.934,4.286-8.651,19.34-14.961,33.452S97.3,152.757,95.465,156.834c-8.142,17.876-17,37.53-21.882,48.611-2.952,6.586-6.717,15.054-8.346,18.817-2.239,4.914-3.257,9.3-3.867,15.89-1.221,12.754.509,18.712,6.31,21.849,2.748,1.463,97.5,1.254,99.233-.209,1.628-1.464,1.832-18.818.305-21.953-1.018-1.882-2.544-1.986-39.184-1.986-27.277,0-38.167-.314-38.167-1.15,0-.627,3.054-7.84,6.718-15.994,3.664-8.05,7.836-17.354,9.262-20.49s4.58-10.036,6.921-15.158c2.34-5.227,6.92-15.263,10.178-22.476s8.956-19.863,12.722-28.226,9.771-21.535,13.231-29.272,8.956-19.758,12.112-26.657A89.4,89.4,0,0,0,164.432,66.6c.8-4.207,2.278-11.688,2.278-11.688,0-13.172-1.424-16.622-7.633-19.235C155.514,34.209,149.408,34,112.971,34,88.341,34,70.428,34.418,69.512,35.045Z" fill="#ed563b"/>
<path id="Path_616" data-name="Path 616" d="M263.343,35.149c-1.73.94-1.832,7.522-1.832,113.768,0,79.4.305,112.933,1.12,113.351.611.418,6.412.731,12.824.731,8.752,0,12.009-.314,12.62-1.358.509-.836.916-51.609.916-112.933,0-99.874-.2-111.575-1.628-113.037C286.141,34.313,283.495,34,275.455,34,269.755,34,264.259,34.522,263.343,35.149Z" transform="translate(-47.454)" fill="#ed563b"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -0,0 +1,65 @@
-- Rebuild the State + City datasets from tbl_branches so every captured city is
-- always assignable (Create Lead → Assign cascade dependency).
--
-- App 385. Cities dataset=264, States dataset=265. City field=46815, State field=46814.
--
-- WHY: Assign Lead's region cascade matches assign_city == tbl_branches.city (city
-- NAMES), then owner agent by region. So the city captured at Create Lead must be a
-- branch city name. We therefore (a) source the City/State option lists straight from
-- tbl_branches, and (b) store the city NAME as the value (value_key=city), not city_id.
--
-- Datasets are read live from tbl_datasets (no redeploy needed for the data). The field
-- property changes live in the deployed workflow config, so REDEPLOY the version after
-- running this. Re-runnable: deterministic full rebuild from the current branches.
BEGIN;
-- Cities dataset ← distinct (state_name, city) from branches. keys: state, city.
UPDATE tbl_datasets
SET keys = '["state","city"]'::jsonb,
items = (
SELECT COALESCE(
jsonb_agg(jsonb_build_object('state', state_name, 'city', city) ORDER BY state_name, city),
'[]'::jsonb)
FROM (SELECT DISTINCT state_name, city FROM lead_to_policy.tbl_branches) t
),
updated_at = now()
WHERE id = 264;
-- States dataset ← distinct state_name from branches. key: state.
UPDATE tbl_datasets
SET keys = '["state"]'::jsonb,
items = (
SELECT COALESCE(
jsonb_agg(jsonb_build_object('state', state_name) ORDER BY state_name),
'[]'::jsonb)
FROM (SELECT DISTINCT state_name FROM lead_to_policy.tbl_branches) t
),
updated_at = now()
WHERE id = 265;
-- City field (46815): store the city NAME so it matches tbl_branches.city in the
-- Assign region cascade (and displays correctly).
UPDATE workflow.tbl_wf_fields
SET properties = jsonb_set(
jsonb_set(properties, '{value_key}', '"city"'::jsonb),
'{dataset_keys}', '["state","city"]'::jsonb)
WHERE id = 46815;
-- State field (46814): value + label + keys all on `state`.
UPDATE workflow.tbl_wf_fields
SET properties = jsonb_set(
jsonb_set(
jsonb_set(properties, '{value_key}', '"state"'::jsonb),
'{display_keys}', '["state"]'::jsonb),
'{dataset_keys}', '["state"]'::jsonb)
WHERE id = 46814;
-- Verify
SELECT id, name, jsonb_array_length(items) AS rows, keys FROM tbl_datasets WHERE id IN (264,265);
SELECT id, field_s_id,
properties->>'value_key' AS value_key,
properties->'display_keys' AS display_keys
FROM workflow.tbl_wf_fields WHERE id IN (46814,46815);
COMMIT;

View File

@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""Build the ABSLI policy-schedule / welcome-kit .docx template for app 385.
Dependency-free: writes a minimal-but-valid WordprocessingML package by hand.
Each {{placeholder}} is emitted as ONE contiguous run so docx-service's
placeholder substitution resolves it cleanly (it also handles fragmented runs,
but single runs are the safe path).
Output: ai-employee-leadtopolicy/policy_schedule.docx
Upload it via Studio > Templates (app 385); docx-service substitutes the
{{vars}} mapped in the 44d generateDoc node.
Placeholders (all sourced from companion tbl_wf_385_lead_to_policy at 44d):
policy_number lead_name plan_name sum_assured premium_amount
premium_frequency policy_term commencement_date maturity_date
advisor_name issue_date
"""
import os
import zipfile
from xml.sax.saxutils import escape
OUT = os.path.join(os.path.dirname(__file__), "..", "..",
".supacode", "repos", "sm2", "lead-to-policy",
"ai-employee-leadtopolicy", "policy_schedule.docx")
# When run from the aria-console scripts/ dir the relative hop above is brittle;
# allow an explicit override.
OUT = os.environ.get("POLICY_DOCX_OUT", OUT)
NSW = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
# ---- run / paragraph helpers -------------------------------------------------
def run(text, *, bold=False, size=None, color=None):
rpr = ""
props = []
if bold:
props.append("<w:b/>")
if size: # half-points
props.append(f'<w:sz w:val="{size}"/><w:szCs w:val="{size}"/>')
if color:
props.append(f'<w:color w:val="{color}"/>')
if props:
rpr = "<w:rPr>" + "".join(props) + "</w:rPr>"
return (f'<w:r>{rpr}<w:t xml:space="preserve">{escape(text)}</w:t></w:r>')
def para(runs, *, align=None, space_after=120, shading=None):
ppr_bits = []
if align:
ppr_bits.append(f'<w:jc w:val="{align}"/>')
if shading:
ppr_bits.append(f'<w:shd w:val="clear" w:color="auto" w:fill="{shading}"/>')
ppr_bits.append(f'<w:spacing w:after="{space_after}"/>')
ppr = "<w:pPr>" + "".join(ppr_bits) + "</w:pPr>"
body = runs if isinstance(runs, str) else "".join(runs)
return f"<w:p>{ppr}{body}</w:p>"
def cell(text_runs, *, w=4500, fill=None):
shd = f'<w:shd w:val="clear" w:color="auto" w:fill="{fill}"/>' if fill else ""
tcpr = f'<w:tcPr><w:tcW w:w="{w}" w:type="dxa"/>{shd}</w:tcPr>'
return f"<w:tc>{tcpr}{para(text_runs, space_after=40)}</w:tc>"
def kv_row(label, placeholder):
left = cell([run(label, bold=True, size=20, color="475569")], w=3600, fill="F1F5F9")
right = cell([run("{{" + placeholder + "}}", size=20, color="0F172A")], w=5400)
return f"<w:tr>{left}{right}</w:tr>"
def table(rows):
borders = (
"<w:tblBorders>"
'<w:top w:val="single" w:sz="4" w:color="E2E8F0"/>'
'<w:left w:val="single" w:sz="4" w:color="E2E8F0"/>'
'<w:bottom w:val="single" w:sz="4" w:color="E2E8F0"/>'
'<w:right w:val="single" w:sz="4" w:color="E2E8F0"/>'
'<w:insideH w:val="single" w:sz="4" w:color="E2E8F0"/>'
'<w:insideV w:val="single" w:sz="4" w:color="E2E8F0"/>'
"</w:tblBorders>"
)
tblpr = f'<w:tblPr><w:tblW w:w="9000" w:type="dxa"/>{borders}</w:tblPr>'
grid = '<w:tblGrid><w:gridCol w:w="3600"/><w:gridCol w:w="5400"/></w:tblGrid>'
return f"<w:tbl>{tblpr}{grid}{''.join(rows)}</w:tbl>"
# ---- document body -----------------------------------------------------------
body_parts = [
para([run("Aditya Birla Sun Life Insurance", bold=True, size=32, color="9A1F40")],
align="center", space_after=40),
para([run("Policy Schedule & Welcome Kit", bold=True, size=24, color="475569")],
align="center", space_after=240),
para([run("Dear ", size=22),
run("{{lead_name}}", bold=True, size=22),
run(",", size=22)]),
para([run(
"Welcome to the Aditya Birla Sun Life Insurance family. We are delighted to "
"confirm that your policy has been issued. Please find your policy schedule "
"below. Kindly retain this document for your records.", size=22)],
space_after=240),
para([run("Policy Schedule", bold=True, size=24, color="9A1F40")], space_after=120),
table([
kv_row("Policy Number", "policy_number"),
kv_row("Plan", "plan_name"),
kv_row("Life Assured", "lead_name"),
kv_row("Sum Assured (INR)", "sum_assured"),
kv_row("Premium (INR)", "premium_amount"),
kv_row("Premium Frequency", "premium_frequency"),
kv_row("Policy Term (years)", "policy_term"),
kv_row("Risk Commencement Date", "commencement_date"),
kv_row("Maturity Date", "maturity_date"),
kv_row("Date of Issue", "issue_date"),
kv_row("Your Advisor", "advisor_name"),
]),
para([run("", size=22)], space_after=200),
para([run(
"This is a computer-generated welcome document. The benefits, terms and "
"conditions are governed by the policy contract. For any assistance, please "
"contact your advisor named above or reach us at care.abslifeinsurance@adityabirlacapital.com.",
size=18, color="64748B")], space_after=240),
para([run("Warm regards,", size=22)], space_after=40),
para([run("Team Aditya Birla Sun Life Insurance", bold=True, size=22)]),
]
document_xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
f'<w:document xmlns:w="{NSW}">'
"<w:body>"
+ "".join(body_parts)
+ '<w:sectPr><w:pgSz w:w="11906" w:h="16838"/>'
'<w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1134"'
' w:header="709" w:footer="709" w:gutter="0"/></w:sectPr>'
"</w:body></w:document>"
)
CONTENT_TYPES = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Override PartName="/word/document.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
"</Types>"
)
RELS = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" '
'Target="word/document.xml"/>'
"</Relationships>"
)
DOC_RELS = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
"</Relationships>"
)
os.makedirs(os.path.dirname(OUT), exist_ok=True)
with zipfile.ZipFile(OUT, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("[Content_Types].xml", CONTENT_TYPES)
z.writestr("_rels/.rels", RELS)
z.writestr("word/document.xml", document_xml)
z.writestr("word/_rels/document.xml.rels", DOC_RELS)
print("wrote", os.path.abspath(OUT))

View File

@ -0,0 +1,64 @@
// Runnable assertions for src/lib/kyc-match.ts (no test runner is configured).
// node scripts/kyc-match.check.ts
// Lives outside src/ so it isn't part of the `tsc -b` app build.
import assert from 'node:assert/strict';
import { crossCheck, dobVerdict, nameVerdict, normName } from '../src/lib/kyc-match.ts';
let n = 0;
const ok = (label: string, cond: boolean) => {
n++;
if (!cond) {
console.error('FAIL:', label);
process.exitCode = 1;
}
};
// normName: order-insensitive, honorific/punct strip
assert.equal(normName('Mr. Ravi Kumar'), 'kumar ravi');
assert.equal(normName('KUMAR, RAVI'), 'kumar ravi');
// name: exact / case / order → match
ok('exact name', nameVerdict('Ravi Kumar', 'ravi kumar').status === 'match');
ok('reordered name', nameVerdict('Ravi Kumar', 'Kumar Ravi').status === 'match');
// initials → match
ok('initial expands', nameVerdict('R Kumar', 'Ravi Kumar').status === 'match');
ok('middle initial', nameVerdict('Ravi K Sharma', 'Ravi Kumar Sharma').status === 'match');
// typo / wrong person → mismatch
ok('typo name', nameVerdict('Rabi Kumar', 'Ravi Kumar').status === 'mismatch');
ok('wrong person', nameVerdict('Ravi Kumar', 'Suresh Patel').status === 'mismatch');
ok('extra surname missing', nameVerdict('Ravi', 'Ravi Kumar').status === 'mismatch');
// empty side → unknown
ok('empty ocr', nameVerdict('Ravi Kumar', '').status === 'unknown');
ok('empty captured', nameVerdict('', 'Ravi Kumar').status === 'unknown');
// DOB: captured IST-midnight instant ("…T18:30Z" → 1990-05-15 IST) vs OCR YYYY-MM-DD
// (literal compare, no tz shift). Normalization is pinned to Asia/Kolkata.
ok('dob match', dobVerdict('1990-05-14T18:30:00.000Z', '1990-05-15') === 'match');
ok('dob mismatch day', dobVerdict('1990-05-14T18:30:00.000Z', '1990-05-16') === 'mismatch');
ok('dob mismatch year', dobVerdict('1990-05-14T18:30:00.000Z', '1991-05-15') === 'mismatch');
// DD/MM/YYYY as a card may print
ok('dob dd/mm/yyyy', dobVerdict('1990-05-14T18:30:00.000Z', '15/05/1990') === 'match');
// null / unparseable → unknown
ok('dob null', dobVerdict('1990-05-14T18:30:00.000Z', null) === 'unknown');
ok('dob garbage', dobVerdict('1990-05-14T18:30:00.000Z', 'unknown') === 'unknown');
// aggregate
const clean = crossCheck(
{ name: 'Ravi Kumar', dob: '1990-05-14T18:30:00.000Z' },
{ pan: { name: 'RAVI KUMAR', dob: '1990-05-15' }, aadhaar: { name: 'Ravi Kumar', dob: '1990-05-15' } },
);
ok('clean: 4 checked', clean.fields.length === 4);
ok('clean: no mismatch', !clean.hasMismatch && clean.anyChecked);
const bad = crossCheck(
{ name: 'Ravi Kumar', dob: '1990-05-14T18:30:00.000Z' },
{ pan: { name: 'Ravi Kumar', dob: '1990-05-15' }, aadhaar: { name: 'Suresh Patel', dob: '1985-01-01' } },
);
ok('bad: hasMismatch', bad.hasMismatch);
ok('bad: 2 aadhaar mismatches', bad.fields.filter((f) => f.status === 'mismatch').length === 2);
// nothing OCR'd yet
const empty = crossCheck({ name: 'Ravi Kumar', dob: '1990-05-14T18:30:00.000Z' }, {});
ok('empty: nothing checked', !empty.anyChecked && !empty.hasMismatch);
if (!process.exitCode) console.log(`kyc-match: all checks passed (${n} cases)`);

59
src/App.tsx Normal file
View File

@ -0,0 +1,59 @@
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import type { ReactNode } from 'react';
import { Shell } from './layout/Shell';
import { PipelineScreen } from './screens/PipelineScreen';
import { ScheduleScreen } from './screens/ScheduleScreen';
import { Lead360Screen } from './screens/Lead360Screen';
import {
QualificationScreen,
InteractionScreen,
DocAssessmentScreen,
UnderwritingPolicyScreen,
} from './screens/worklists';
import { Login } from './pages/Login';
import { useZino } from './api/provider';
import { LeadsProvider } from './api/leads';
import { canSeeScreen } from './api/config';
function RequireAuth({ children }: { children: ReactNode }) {
const { user } = useZino();
const location = useLocation();
if (!user) return <Navigate to="/login" replace state={{ from: location.pathname }} />;
return <>{children}</>;
}
// Deep-link guard — a screen the user's roles can't see redirects to the
// dashboard (which is open to every operational role).
function RequireScreen({ path, children }: { path: string; children: ReactNode }) {
const { user } = useZino();
if (!canSeeScreen(user?.roles, path)) return <Navigate to="/pipeline" replace />;
return <>{children}</>;
}
export function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route
element={
<RequireAuth>
<LeadsProvider>
<Shell />
</LeadsProvider>
</RequireAuth>
}
>
<Route index element={<Navigate to="/pipeline" replace />} />
<Route path="pipeline" element={<PipelineScreen />} />
<Route path="schedule" element={<RequireScreen path="/schedule"><ScheduleScreen /></RequireScreen>} />
<Route path="leads" element={<Lead360Screen />} />
<Route path="leads/:id" element={<Lead360Screen />} />
<Route path="qualify" element={<RequireScreen path="/qualify"><QualificationScreen /></RequireScreen>} />
<Route path="engage" element={<RequireScreen path="/engage"><InteractionScreen /></RequireScreen>} />
<Route path="documents" element={<RequireScreen path="/documents"><DocAssessmentScreen /></RequireScreen>} />
<Route path="underwriting" element={<RequireScreen path="/underwriting"><UnderwritingPolicyScreen /></RequireScreen>} />
<Route path="*" element={<Navigate to="/pipeline" replace />} />
</Route>
</Routes>
);
}

286
src/api/adapters.ts Normal file
View File

@ -0,0 +1,286 @@
// Map live API shapes onto the Aria design-system domain types.
import type { Channel, Decision, Lead, Segment, Tone } from '../types';
import {
ACTIVITY_NAME_BY_UID,
AI_EMPLOYEES,
AI_RECOMMENDATION,
STATE_NAME_BY_UID,
SUPER_ROLES,
aiEmployeeForState,
stageOfState,
} from './config';
import type { AiDecision, AuditEntry, LeadRecord } from './types';
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
export function formatDate(iso?: string | null): string {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '—';
return `${d.getDate().toString().padStart(2, '0')} ${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
}
export function formatTime(iso?: string | null): string {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleTimeString('en-IN', { hour: 'numeric', minute: '2-digit', hour12: true });
}
function ageFrom(iso?: string | null): number {
if (!iso) return 0;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return 0;
const now = new Date();
let age = now.getFullYear() - d.getFullYear();
const m = now.getMonth() - d.getMonth();
if (m < 0 || (m === 0 && now.getDate() < d.getDate())) age--;
return age;
}
function toSegment(raw?: string | null): Segment {
switch ((raw ?? '').toLowerCase()) {
case 'hot':
return 'Hot';
case 'cold':
return 'Cold';
default:
return 'Warm';
}
}
const CHANNELS: Record<string, Channel> = {
whatsapp: 'WhatsApp',
web: 'Web',
referral: 'Referral',
agency: 'Agency',
bancassurance: 'Bancassurance',
direct: 'Direct',
event: 'Event',
};
function toChannel(raw?: string | null): Channel {
return CHANNELS[(raw ?? '').toLowerCase()] ?? 'Web';
}
// Several recordview fields arrive as structured objects, not scalars. These
// flatten each to a display string; screens must route reads through them
// rather than rendering the raw value (a raw object crashes React).
// Phone from the phone-input widget: {dial_code, phone, phone_with_dial_code}.
function toPhone(raw: unknown): string {
if (typeof raw === 'string') return raw;
if (raw && typeof raw === 'object') {
const o = raw as { phone_with_dial_code?: string; phone?: string };
return o.phone_with_dial_code ?? o.phone ?? '—';
}
return '—';
}
// assigned_region is a tbl_branches lookup: {region}. Returns undefined when
// absent so it composes with `??` fallback chains.
export function regionOf(raw: unknown): string | undefined {
if (typeof raw === 'string') return raw || undefined;
if (raw && typeof raw === 'object') {
return (raw as { region?: string }).region || undefined;
}
return undefined;
}
// recommended_product is a product-catalog lookup; plan_name is its display
// column (older rows may carry a {name} or a plain string).
export function productOf(raw: unknown): string | undefined {
if (typeof raw === 'string') return raw || undefined;
if (raw && typeof raw === 'object') {
const o = raw as { plan_name?: string; name?: string };
return o.plan_name ?? o.name ?? undefined;
}
return undefined;
}
// state -> a friendly "last action" line + tone for the pipeline list.
const STATE_ACTION: Record<string, { action: string; tone: Tone }> = {
'New Lead': { action: 'Awaiting qualification', tone: 'neutral' },
Qualified: { action: 'Qualified — ready to assign', tone: 'success' },
Assigned: { action: 'Assigned to agent', tone: 'success' },
Contacted: { action: 'First contact logged', tone: 'info' },
'Meeting Scheduled': { action: 'Meeting scheduled', tone: 'accent' },
'Product Recommended': { action: 'Product recommended', tone: 'accent' },
'Documents Collected': { action: 'KYC documents collected', tone: 'info' },
'Eligibility Assessed': { action: 'Eligibility assessed', tone: 'accent' },
Underwriting: { action: 'Underwriting in progress', tone: 'accent' },
'Policy Issued': { action: 'Policy issued', tone: 'success' },
'Customer 360': { action: 'Onboarded — Customer 360', tone: 'success' },
Disqualified: { action: 'Disqualified', tone: 'warning' },
};
export function recordToLead(r: LeadRecord): Lead {
const state = r.current_state_name ?? 'New Lead';
const ai = aiEmployeeForState(state);
const human = r.owner_agent?.name;
const owner = human ?? ai?.name ?? 'Unassigned';
const meta = STATE_ACTION[state] ?? { action: state, tone: 'neutral' as Tone };
return {
id: String(r.instance_id),
name: r.lead_name ?? `Lead ${r.instance_id}`,
city: r.city ?? r.state_region ?? regionOf(r.assigned_region) ?? '—',
phone: toPhone(r.phone),
email: r.email ?? '—',
dob: formatDate(r.date_of_birth),
age: ageFrom(r.date_of_birth),
income: r.annual_income ?? 0,
occupation: r.occupation ?? '—',
language: r.preferred_language ?? '—',
interest: r.product_interest ?? '—',
score: r.lead_score ?? 0,
segment: toSegment(r.lead_segment),
channel: toChannel(r.channel_source),
owner,
ownerAi: !human && !!ai,
stage: Math.max(0, stageOfState(state)),
lastAction: meta.action,
lastTone: meta.tone,
};
}
// --- AI decision stream ---
function asStringArray(v: unknown): string[] {
if (!v) return [];
if (Array.isArray(v)) {
return v
.map((x) => (typeof x === 'string' ? x : (x as { name?: string; title?: string })?.name ?? (x as { title?: string })?.title ?? ''))
.filter(Boolean);
}
return [];
}
function firstSentence(text: string): string {
const t = text.trim();
const m = t.match(/^.*?[.!?](\s|$)/);
return (m ? m[0] : t).trim();
}
export function decisionToCard(d: AiDecision): Decision {
const known = AI_EMPLOYEES[d.ai_user_id];
const emp = known ?? { name: d.employee_name?.trim() || `AI ${d.ai_user_id}`, role: 'AI Employee' };
const reasoning = (d.reasoning ?? '').trim();
const activityName =
ACTIVITY_NAME_BY_UID[d.activity_id] ??
(d.activity_name && !d.activity_name.includes('-') ? d.activity_name : null) ??
d.instance_state ??
'Decision';
return {
employee: emp.name,
role: emp.role,
activity: activityName,
time: formatTime(d.created_at),
confidence: typeof d.confidence === 'number' ? d.confidence : 0,
summary: reasoning ? firstSentence(reasoning) : 'Decision recorded.',
reasoning,
citations: asStringArray(d.knowledge_sources),
};
}
// --- Aria Advisor product recommendation ---
export interface AiRecommendation {
/** Product name as the AI named it, e.g. "ABSLI DigiShield Term Plan". */
product: string;
sumAssured: number | null;
rationale: string;
/** 01 (the decision confidence, or the ai_* field / 100). */
confidence: number;
employee: string;
role: string;
time: string | null;
}
function num(v: unknown): number | null {
if (v == null || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
/** Pull Aria Advisor's pre-activity product recommendation out of the decision
* stream (it fires after Schedule Meeting). Returns null if she hasn't run. */
export function recommendationFromDecisions(decisions: AiDecision[]): AiRecommendation | null {
const F = AI_RECOMMENDATION.fields;
// A recommendation decision = the right activity/employee, carrying a product
// name, and actually *submitted* (skip failed retries, which other employees
// log at low confidence). Prefer the real Aria Advisor over any stand-in.
const candidates = decisions.filter(
(x) =>
x.status === 'submitted' &&
typeof x.data?.[F.product] === 'string' &&
(x.data[F.product] as string).trim() !== '' &&
(x.activity_id === AI_RECOMMENDATION.activityUid || x.ai_user_id === AI_RECOMMENDATION.aiUserId),
);
const d =
candidates.find((x) => x.ai_user_id === AI_RECOMMENDATION.aiUserId) ?? candidates[0];
const product = d?.data?.[F.product];
if (!d || typeof product !== 'string' || !product.trim()) return null;
const emp = AI_EMPLOYEES[d.ai_user_id] ?? {
name: d.employee_name?.trim() || 'Aria Advisor',
role: 'Product Recommendation',
};
const pct = num(d.data?.[F.confidence]);
return {
product: product.trim(),
sumAssured: num(d.data?.[F.sumAssured]),
rationale: (typeof d.data?.[F.rationale] === 'string' ? (d.data[F.rationale] as string) : d.reasoning ?? '').trim(),
confidence: typeof d.confidence === 'number' && d.confidence > 0 ? d.confidence : pct != null ? pct / 100 : 0,
employee: emp.name,
role: emp.role,
time: d.created_at ? formatTime(d.created_at) : null,
};
}
// --- audit timeline ---
export interface TimelineItem {
actor: string;
ai: boolean;
action: string;
time: string;
tone: Tone;
}
// Audit entries carry no username — only user_id + human-readable roles. Show
// the person's role ("Sales Agent", "Underwriter") instead of a raw "User 5";
// admins surface as "Admin".
function humanActor(userId: string, roles: string[] | undefined): string {
const r = roles ?? [];
const sup = r.find((role) => SUPER_ROLES.includes(role));
if (sup) return 'Admin';
if (r[0]) return r[0];
return userId === '1' ? 'Admin' : 'Team member';
}
export function auditToTimeline(entries: AuditEntry[]): TimelineItem[] {
const items = [...entries]
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.map((e) => {
const ai = AI_EMPLOYEES[e.user_id];
const activity = ACTIVITY_NAME_BY_UID[e.activity_id];
const stateName = STATE_NAME_BY_UID[e.execution_state ?? ''];
const action = activity
? `performed ${activity}`
: stateName
? `advanced to ${stateName}`
: 'performed an activity';
return {
actor: ai?.name ?? humanActor(e.user_id, e.user_roles),
ai: !!ai,
action,
time: formatTime(e.created_at),
tone: (ai ? 'accent' : 'neutral') as Tone,
};
});
// Collapse consecutive identical (actor + action) entries from trigger fan-out.
return items.filter(
(it, i) => i === 0 || it.actor !== items[i - 1].actor || it.action !== items[i - 1].action,
);
}

290
src/api/client.ts Normal file
View File

@ -0,0 +1,290 @@
import type {
ActivityResult,
AiDecision,
ApiError,
AuditEntry,
FormScreenResponse,
LoginResponse,
RecordViewParams,
RecordViewResponse,
User,
} from './types';
import { APP_ID, DETAIL_VIEW_IDS, WORKFLOW_UUID } from './config';
const TOKEN_KEY = 'lead_to_policy_token';
/**
* HTTP client for the Zino gateway (sandbox).
*
* Routes are app-scoped (`/app/385/...`); login + ai-employee monitor are not.
* JWT persists in localStorage so the session survives refresh.
*/
export class ZinoClient {
readonly baseUrl: string;
private token: string | null = null;
private onAuthError?: () => void;
constructor(baseUrl: string, onAuthError?: () => void) {
this.baseUrl = baseUrl.replace(/\/+$/, '');
this.onAuthError = onAuthError;
if (typeof window !== 'undefined') this.token = localStorage.getItem(TOKEN_KEY);
}
setAuthErrorHandler(fn: () => void): void {
this.onAuthError = fn;
}
setToken(token: string | null): void {
this.token = token;
if (typeof window === 'undefined') return;
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
}
getToken(): string | null {
return this.token;
}
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
return this.send<T>(method, path, {
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
// Multipart variant — no Content-Type header (the browser sets the boundary).
private async requestForm<T>(path: string, form: FormData): Promise<T> {
const headers: Record<string, string> = {};
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
return this.send<T>('POST', path, { headers, body: form });
}
private async send<T>(method: string, path: string, init: RequestInit): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, { method, ...init });
if (res.status === 401) {
this.setToken(null);
this.onAuthError?.();
throw { status: 401, message: 'Unauthorized' } as ApiError;
}
if (!res.ok) {
let message = res.statusText;
try {
const j = (await res.json()) as { error?: string };
if (j.error) message = j.error;
} catch {
/* non-JSON body */
}
throw { status: res.status, message } as ApiError;
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
// --- Auth ---
async login(email: string, password: string, orgId?: string): Promise<LoginResponse> {
const res = await this.request<LoginResponse>('POST', '/usr/login', {
email,
password,
...(orgId ? { org_id: Number(orgId) } : {}),
});
this.setToken(res.token);
return res;
}
logout(): void {
this.setToken(null);
}
/** Decode the persisted JWT into a User (no network). */
currentUser(): User | null {
if (!this.token) return null;
try {
const p = JSON.parse(atob(this.token.split('.')[1]));
return {
id: String(p.user_id ?? p.sub ?? ''),
org_id: String(p.org_id ?? ''),
name: p.name ?? '',
email: p.email ?? '',
roles: p.roles ?? [],
groups: p.groups ?? [],
};
} catch {
return null;
}
}
// --- Views ---
recordView(rvUid: string, params: RecordViewParams = {}): Promise<RecordViewResponse> {
return this.request<RecordViewResponse>('POST', `/app/${APP_ID}/view/recordview`, {
rv_template_uid: rvUid,
search_query: {
page: params.page ?? 1,
limit: params.limit ?? 50,
sort_by: params.sortBy ?? '',
sort_dir: params.sortDir ?? 'desc',
search: params.search ?? '',
filters: (params.filters ?? []).map((f) => ({
field_key: f.field_key,
value: f.value,
value2: '',
data_type: f.data_type ?? 'string',
})),
},
});
}
detailView(dvUid: string, instanceId: number | string): Promise<{
config: { fields: Array<{ field_key: string; output_label: string; data_type: string }> };
data: Record<string, unknown>;
}> {
return this.request(
'GET',
`/app/${APP_ID}/view/detailview/${dvUid}?instance_id=${encodeURIComponent(String(instanceId))}`,
);
}
audit(instanceId: number | string): Promise<AuditEntry[]> {
return this.request<AuditEntry[]>(
'GET',
`/app/${APP_ID}/view/audit?instance_id=${encodeURIComponent(String(instanceId))}`,
);
}
/** AI-employee decision stream for an instance (public monitor endpoint). */
aiDecisions(instanceId: number | string): Promise<AiDecision[]> {
return this.request<{ decisions: AiDecision[] }>(
'GET',
`/monitor/decisions?instance_id=${encodeURIComponent(String(instanceId))}`,
).then((r) => r.decisions ?? []);
}
// --- Form schema (view-service) ---
/** Live activity form config fields, types, options, lookup/ocr config and
* the designer layout. The single source of truth for rendering a form. */
formSchema(activityId: string, instanceId?: number | string): Promise<FormScreenResponse> {
return this.request<FormScreenResponse>('POST', `/app/${APP_ID}/view/form-screens`, {
activity_id: activityId,
device_type: 'desktop',
...(instanceId != null ? { instance_id: instanceId } : {}),
});
}
// --- Workflow execution (core) ---
startInstance(activityUid: string, data: Record<string, unknown>): Promise<ActivityResult> {
return this.request<ActivityResult>('POST', `/app/${APP_ID}/start`, {
workflow_uuid: WORKFLOW_UUID,
activity_id: activityUid,
data,
});
}
performActivity(
instanceId: number | string,
activityUid: string,
data: Record<string, unknown>,
): Promise<ActivityResult> {
return this.request<ActivityResult>('POST', `/app/${APP_ID}/activity`, {
workflow_uuid: WORKFLOW_UUID,
instance_id: typeof instanceId === 'string' ? Number(instanceId) || instanceId : instanceId,
activity_id: activityUid,
data,
});
}
// Convenience — the single lead detail view.
leadDetail(instanceId: number | string) {
return this.detailView(DETAIL_VIEW_IDS.LEAD, instanceId);
}
// --- RDBMS lookup records (owner-agent picker etc.) ---
/** Search an RDBMS lookup template. Returns the matching rows. `formData`
* carries the current form values so the server can apply the activity's
* `field_rules` filter_options (dependent/cascading lookups, e.g. owner
* agents scoped to the chosen region). */
lookupRecords(
templateUid: string,
opts: {
activityId: string;
fieldId: string;
search?: string;
instanceId?: number | string;
limit?: number;
formData?: Record<string, unknown>;
},
): Promise<{ records: Array<Record<string, unknown>> }> {
return this.request('POST', `/app/${APP_ID}/rdbms-templates/${templateUid}/records`, {
workflow_uuid: WORKFLOW_UUID,
activity_id: opts.activityId,
field_id: opts.fieldId,
instance_id: opts.instanceId || undefined,
search: opts.search ?? '',
limit: opts.limit ?? 50,
offset: 0,
form_data: opts.formData ?? {},
});
}
// --- Dataset-backed select options (state/city cascade etc.) ---
/** Fetch options for a dataset-backed select. The server resolves the field's
* dataset + filter_options from `activityId`/`fieldId`, applying the activity's
* rules against `formData` so e.g. City options come back already scoped to
* the chosen State. Returns `{label, value}` rows (plus the raw dataset row). */
datasetOptions(opts: {
activityId: string;
fieldId: string;
search?: string;
instanceId?: number | string;
limit?: number;
formData?: Record<string, unknown>;
}): Promise<{ options: Array<{ label: string; value: string; _raw?: Record<string, string> }> }> {
return this.request('POST', `/app/${APP_ID}/dataset-options`, {
workflow_uuid: WORKFLOW_UUID,
activity_id: opts.activityId,
field_id: opts.fieldId,
instance_id: opts.instanceId || undefined,
search: opts.search ?? '',
limit: opts.limit ?? 200,
offset: 0,
form_data: opts.formData ?? {},
});
}
// --- File upload + OCR (multipart, field-scoped) ---
private fieldForm(file: File, ctx: FieldContext): FormData {
const form = new FormData();
form.append('file', file);
form.append('workflow_uuid', WORKFLOW_UUID);
form.append('activity_id', ctx.activityId);
form.append('field_id', ctx.fieldId);
if (ctx.instanceId != null) form.append('instance_id', String(ctx.instanceId));
return form;
}
/** Upload a form-field file; returns the FileMeta to store as the field value. */
uploadFile(file: File, ctx: FieldContext): Promise<Record<string, unknown>> {
return this.requestForm(`/app/${APP_ID}/upload`, this.fieldForm(file, ctx));
}
/** Run OCR on a document; `extracted` is keyed by the field's extraction keys. */
extractOcr(file: File, ctx: FieldContext): Promise<{ extracted: Record<string, unknown>; raw?: string; parse_error?: string }> {
return this.requestForm(`/app/${APP_ID}/ocr-extract`, this.fieldForm(file, ctx));
}
}
/** Identifiers the field-scoped CORE endpoints resolve config + RBAC from. */
export interface FieldContext {
activityId: string;
fieldId: string;
instanceId?: number | string;
}

194
src/api/config.ts Normal file
View File

@ -0,0 +1,194 @@
// Aria Lead-to-Policy — dev app 386, org 53 (cloned from sandbox app 385).
// Workflow `e29c3c33` (v1) — identical source_uuid on dev. ids verified against postgres-dev.
export const ORG_ID = '54';
export const APP_ID = '387';
export const WORKFLOW_UUID = '6594f9db-6da9-45a9-b66e-53f78bea2069';
// Record views (POST /app/387/view/recordview, body.rv_template_uid).
export const RECORD_VIEW_IDS = {
ALL_LEADS: '49a5d651-8055-451a-8348-b1ea65cc6357', // comprehensive — drives the pipeline
} as const;
// Detail views (GET /app/387/view/detailview/{uid}).
export const DETAIL_VIEW_IDS = {
LEAD: 'aa96f572-b480-4623-ae9c-9ce0c44b8626',
} as const;
// Onboard activity uid. The trigger writes welcome_email_status + policy_doc
// onto the lead record (read from the recordview). onboarding_notes stays a
// `local` field — it persists only in the activity submission, surfaced via the
// audit feed, where Customer 360 reads it. (welcome_pack_sent is dead.)
export const ONBOARD_ACTIVITY_UID = 'ae415f4e-a33e-432e-882f-733238de8bcc';
// Activity uids + their field ids (data{} keys). From introspect.
export const ACTIVITIES = {
RECOMMEND_PRODUCT: {
uid: 'c0e2004e-2d70-45f2-9cd5-734331010906',
fields: {
product: 'recommended_product_input', // lookup → tbl_products (see PRODUCT_LOOKUP)
sumAssured: 'sum_assured_input', // number
premiumAmount: 'premium_amount_input', // number
premiumFrequency: 'premium_frequency_input', // select
riders: 'riders_input', // multiselect
notes: 'recommendation_notes', // longtext
saValid: 'sa_valid_input', // boolean — computed (see lib/recommend.ts); gates Product Recommended
},
},
COLLECT_DOCUMENTS: {
uid: 'a8eb0faa-17f9-4491-a161-bd6b533adfd1',
fields: {
panOcr: 'pan_ocr_2', // ocr
aadhaarOcr: 'aadhar_ocr_2', // ocr
salarySlip: 'salary_slip_doc_input', // file
aadhaarNumber: 'aadhaar_number_input', // text
panNumber: 'pan_number_input', // text
verifiedIncome: 'verified_income_input', // number
docStatus: 'doc_status_input', // select (req)
},
},
SUBMIT_UNDERWRITING: {
uid: 'ed8ec0dc-4c2c-4d9e-b798-757f6a9d4730',
fields: {
status: 'underwriting_status_input', // select (req) — the verdict
ref: 'underwriting_ref_input', // id_gen (backend-generated UW-YYYY-####; do NOT send)
},
},
ISSUE_POLICY: {
uid: '7b8fb734-a780-4c6a-837b-c5e7ca28e489',
fields: {
issueDate: 'policy_issue_date_input', // date (req)
term: 'policy_term_input', // number (years)
maturity: 'maturity_date_input', // date (computed = issue + term)
premium: 'premium_amount_confirm', // number
number: 'policy_number_input', // id_gen (backend-generated POL-YYYY-####; do NOT send)
},
},
} as const;
// Select options + lookup templates are no longer hardcoded here — screens
// read them live from the form schema (api/schema.ts → fieldFromSchema).
// Canonical ordered lifecycle — matches live `current_state_name` 1:1 so a
// state name maps straight to a stepper/kanban index. Terminal "Disqualified"
// is handled out-of-band (STAGE_OF_STATE returns -1).
export const STAGES = [
'New Lead',
'Qualified',
'Assigned',
'Contacted',
'Meeting Scheduled',
'Product Recommended',
'Documents Collected',
'Eligibility Assessed',
'Underwriting',
'Policy Issued',
'Customer 360',
] as const;
export function stageOfState(stateName?: string): number {
if (!stateName) return 0;
const i = STAGES.indexOf(stateName as (typeof STAGES)[number]);
return i; // -1 for Disqualified / unknown
}
// UID -> human name maps, for rendering the audit trail (which stores raw
// activity + state UIDs).
export const STATE_NAME_BY_UID: Record<string, string> = {
'e469b82f-924e-47d9-9a9f-65b23d270047': 'New Lead',
'41c7a19f-f2fa-4b54-9dd5-310ae982d6c6': 'Qualified',
'8f038dc4-81b7-48c6-ac5b-b28c42b83214': 'Assigned',
'f2aa6dbd-2149-455a-9466-58bd5beb6997': 'Contacted',
'd5a2e33a-d2fe-4bfb-816f-61e064b77e97': 'Meeting Scheduled',
'2f33444a-1af0-4fa0-9461-eb8fc79ec175': 'Product Recommended',
'a0d60916-c332-4d0a-8d02-501e22616b8f': 'Documents Collected',
'0a2a0a66-129a-4b2a-8e4e-1defc5ef85f6': 'Eligibility Assessed',
'f8158695-5ac7-4e3a-9eda-bbab3fd8bbb3': 'Underwriting',
'c964fe5e-c2a8-4d30-825c-fefa97a74368': 'Policy Issued',
'3d783ea2-b3d0-4eca-9424-9f8451c5d986': 'Customer 360',
'0ce7794e-30ba-4f09-b81e-26d5c9fec5b7': 'Disqualified',
};
export const ACTIVITY_NAME_BY_UID: Record<string, string> = {
'cc1a73d5-61e1-4416-af30-2b1eeb623a58': 'Capture Lead',
'6b741e44-37f2-4bb0-90ab-ed139f03b7b3': 'Qualify Lead',
'da61aaa1-003b-4601-9f42-f93ff230497f': 'Assign Lead',
'fea6b3a8-846d-4f6f-8e92-033c4cf4b6e2': 'Log First Contact',
'c444d32f-ed6a-4e36-b8b7-b82e73a141d4': 'Schedule Meeting',
'c0e2004e-2d70-45f2-9cd5-734331010906': 'Recommend Product',
'a8eb0faa-17f9-4491-a161-bd6b533adfd1': 'Collect Documents',
'ba3d3e7f-0d3f-49f2-a156-6cace33c228a': 'Assess Eligibility',
'ed8ec0dc-4c2c-4d9e-b798-757f6a9d4730': 'Submit Underwriting',
'7b8fb734-a780-4c6a-837b-c5e7ca28e489': 'Issue Policy',
'ae415f4e-a33e-432e-882f-733238de8bcc': 'Onboard Customer',
'ea99fc60-caba-45c1-afc1-7386a2fa9220': 'Disqualify',
};
// AI employees (user_id -> identity). Used to label the decision stream and
// to attribute the "owner" of an in-flight lead.
export const AI_EMPLOYEES: Record<string, { name: string; role: string }> = {
'29180': { name: 'Aria', role: 'Lead Qualifier' },
'29181': { name: 'Aria Engage', role: 'Calls & Scheduling' },
'29182': { name: 'Aria Underwrite', role: 'Underwriting' },
'29188': { name: 'Aria Advisor', role: 'Product Recommendation' },
};
// Aria Advisor's internal recommendation activity. It fires from the Schedule
// Meeting trigger (after the meeting is booked) and records the product
// recommendation as a decision — surfaced in the Recommend Product form. Its
// data{} carries the ai_* fields below.
export const AI_RECOMMENDATION = {
activityUid: '14d734da-f1e2-49af-9715-2146d26c0e1a',
aiUserId: '29188',
fields: {
product: 'ai_recommended_product_input', // product name (string)
sumAssured: 'ai_suggested_sum_assured_input', // number
rationale: 'ai_recommendation_rationale_input', // text
confidence: 'ai_recommendation_confidence_input', // 0100
},
} as const;
// Which AI employee is driving a given state (for owner attribution when no
// human sales agent is set).
export function aiEmployeeForState(stateName?: string): { name: string; role: string } | null {
switch (stateName) {
case 'New Lead':
return AI_EMPLOYEES['29180'];
case 'Assigned':
case 'Contacted':
case 'Meeting Scheduled':
case 'Product Recommended':
return AI_EMPLOYEES['29181'];
case 'Documents Collected':
case 'Eligibility Assessed':
return AI_EMPLOYEES['29182'];
default:
return null;
}
}
// --- Screen RBAC (mirrors tbl_appconfig_screens.permissions for app 385) ---
// Roles that bypass screen gating entirely (platform admins see every screen).
export const SUPER_ROLES = ['Super Admin', 'Admin'];
// Route path → roles allowed to see the screen. A path absent here is open to
// all authenticated users (e.g. the contextual Lead 360 detail). `/schedule`
// is an Aria-only screen — managers oversee agent meetings, agents see theirs.
export const SCREEN_ACCESS: Record<string, string[]> = {
'/pipeline': ['Sales Agent', 'Underwriter', 'Branch Manager'],
'/qualify': ['Branch Manager'],
'/engage': ['Sales Agent'],
'/documents': ['Underwriter'],
'/underwriting': ['Underwriter', 'Branch Manager'],
'/schedule': ['Sales Agent', 'Branch Manager'],
};
/** Whether `roles` may see the screen at `path`. Super-admins always can;
* unlisted paths are open. */
export function canSeeScreen(roles: string[] | undefined, path: string): boolean {
const allowed = SCREEN_ACCESS[path];
if (!allowed) return true;
const r = roles ?? [];
if (r.some((role) => SUPER_ROLES.includes(role))) return true;
return r.some((role) => allowed.includes(role));
}

118
src/api/filters.ts Normal file
View File

@ -0,0 +1,118 @@
// Client-side recordview filtering. The app loads the full lead recordview once
// (api/leads.tsx), so search + filters run over the in-memory rows rather than
// re-querying. Which fields are filterable/searchable comes from the live
// recordview config (`is_filter` / `is_search`); option lists are derived from
// the values actually present in the data (config carries no static options).
import type { LeadRecord, RecordViewField } from './types';
/** One field's active filter. `values` = set membership; min/max = number range;
* from/to = date range. Empty/absent on every key means "no filter". */
export interface FieldFilter {
values?: string[];
min?: number;
max?: number;
from?: string;
to?: string;
}
export interface FilterState {
search: string;
byField: Record<string, FieldFilter>;
}
export const EMPTY_FILTER: FilterState = { search: '', byField: {} };
export type FilterKind = 'set' | 'number' | 'date';
export function filterKind(dataType: string): FilterKind {
if (dataType === 'number') return 'number';
if (dataType === 'date' || dataType === 'datetime') return 'date';
return 'set';
}
export const filterableFields = (fields: RecordViewField[]) => fields.filter((f) => f.is_filter);
export const searchableFields = (fields: RecordViewField[]) => fields.filter((f) => f.is_search);
// Flatten a record field to zero+ comparable strings. Several columns arrive as
// structured objects (owner_agent {name}, recommended_product {plan_name},
// assigned_region {region}, phone {…}) or arrays (riders) — normalize them all.
export function fieldValues(record: LeadRecord, key: string): string[] {
return flatten(record[key]);
}
function flatten(raw: unknown): string[] {
if (raw == null || raw === '') return [];
if (Array.isArray(raw)) return raw.flatMap(flatten);
if (typeof raw === 'object') {
const o = raw as Record<string, unknown>;
const pick =
o.plan_name ?? o.name ?? o.region ?? o.label ?? o.value ?? o.phone_with_dial_code ?? o.phone;
return pick == null ? [] : [String(pick)];
}
return [String(raw)];
}
/** Distinct present values for a field, sorted — the option list for a set filter. */
export function distinctValues(records: LeadRecord[], key: string): string[] {
const set = new Set<string>();
for (const r of records) for (const v of fieldValues(r, key)) set.add(v);
return [...set].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
}
/** Title-case code-ish values (whatsapp / pending_docs); leave human text alone. */
export function prettify(v: string): string {
if (/^[a-z0-9_-]+$/.test(v)) {
return v.replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
return v;
}
export function isActive(f: FieldFilter | undefined): boolean {
if (!f) return false;
return !!f.values?.length || f.min != null || f.max != null || !!f.from || !!f.to;
}
export function activeFilterCount(state: FilterState): number {
return Object.values(state.byField).filter(isActive).length;
}
/** Apply search + per-field filters to the loaded rows. */
export function applyFilters(
records: LeadRecord[],
state: FilterState,
fields: RecordViewField[],
): LeadRecord[] {
const q = state.search.trim().toLowerCase();
const sFields = searchableFields(fields);
const dtByKey = new Map(fields.map((f) => [f.field_key, f.data_type]));
return records.filter((r) => {
if (q) {
const hit = sFields.some((f) => fieldValues(r, f.field_key).some((v) => v.toLowerCase().includes(q)));
if (!hit) return false;
}
for (const [key, f] of Object.entries(state.byField)) {
if (!isActive(f)) continue;
const vals = fieldValues(r, key);
switch (filterKind(dtByKey.get(key) ?? 'text')) {
case 'set':
if (f.values?.length && !vals.some((v) => f.values!.includes(v))) return false;
break;
case 'number': {
const n = vals.length ? Number(vals[0]) : NaN;
if (f.min != null && (Number.isNaN(n) || n < f.min)) return false;
if (f.max != null && (Number.isNaN(n) || n > f.max)) return false;
break;
}
case 'date': {
const t = vals.length ? new Date(vals[0]).getTime() : NaN;
if (f.from && (Number.isNaN(t) || t < new Date(f.from).getTime())) return false;
// +1 day so an end date is inclusive of the whole day.
if (f.to && (Number.isNaN(t) || t > new Date(f.to).getTime() + 86_400_000)) return false;
break;
}
}
}
return true;
});
}

189
src/api/forms.ts Normal file
View File

@ -0,0 +1,189 @@
// Activity metadata + the per-state "next action" map. Field schemas are no
// longer mirrored here — <ActivityForm> fetches them live from the form-screens
// endpoint (see api/schema.ts), so the form layout never drifts from config.
export type FieldType =
| 'text'
| 'email'
| 'phone'
| 'number'
| 'date'
| 'datetime'
| 'longtext'
| 'select'
| 'multiselect'
| 'boolean'
| 'lookup'
| 'ocr'
| 'file';
export interface FieldOption {
value: string;
label: string;
}
export interface OcrMapping {
extraction_key: string;
target_field: string;
}
/** A renderable form field. Built at runtime by api/schema.ts from the live
* form-screens config; consumed by <ActivityForm> and the bespoke screens. */
export interface FieldDef {
id: string;
label: string;
type: FieldType;
required?: boolean;
/** Designer `field_defaults[id].hidden` render nothing, but still seed +
* submit its mapped value (used for filter-only inputs like assign_city). */
hidden?: boolean;
/** Designer `field_defaults[id].disabled` render read-only; the value is
* computed (e.g. premium_amount written by the custom_js). */
disabled?: boolean;
/** Designer `field_defaults[id].value` a server-supplied default to seed
* with (e.g. a hidden preferred_language defaulting to "english"). */
prefill?: unknown;
options?: FieldOption[];
/** Dataset-backed select: options are fetched live from /dataset-options
* (filter_options-aware) rather than `options`. See DatasetSelectField. */
optionSource?: string; // 'dataset' | undefined (inline)
datasetUuid?: string;
displayKeys?: string[];
valueKey?: string;
prefix?: string;
placeholder?: string;
/** Companion/record key to seed from (defaults to id minus the _input suffix). */
seedKey?: string;
// lookup
lookupTemplate?: string;
lookupDisplay?: string[]; // columns shown in the label
lookupStorage?: string[]; // columns persisted on submit
// ocr
docLabel?: string; // "PAN" / "Aadhaar"
ocrMappings?: OcrMapping[];
}
/** Activity display copy + uid. Fields come from the schema, not from here. */
export interface ActivityMeta {
uid: string;
name: string;
submitLabel: string;
/** Past-tense confirmation, e.g. "advanced to Qualified". */
done: string;
}
// Activity keys are stable, human-readable handles used by STATE_NEXT.
export const ACTIVITY_META = {
capture: {
uid: 'cc1a73d5-61e1-4416-af30-2b1eeb623a58',
name: 'Capture Lead',
submitLabel: 'Create lead',
done: 'created — added to the pipeline as a New Lead',
},
qualify: {
uid: '6b741e44-37f2-4bb0-90ab-ed139f03b7b3',
name: 'Qualify Lead',
submitLabel: 'Qualify lead',
done: 'advanced to Qualified',
},
assign: {
uid: 'da61aaa1-003b-4601-9f42-f93ff230497f',
name: 'Assign Lead',
submitLabel: 'Assign lead',
done: 'advanced to Assigned',
},
contact: {
uid: 'fea6b3a8-846d-4f6f-8e92-033c4cf4b6e2',
name: 'Log First Contact',
submitLabel: 'Log contact',
done: 'advanced to Contacted',
},
schedule: {
uid: 'c444d32f-ed6a-4e36-b8b7-b82e73a141d4',
name: 'Schedule Meeting',
submitLabel: 'Schedule meeting',
done: 'advanced to Meeting Scheduled',
},
assess: {
uid: 'ba3d3e7f-0d3f-49f2-a156-6cace33c228a',
name: 'Assess Eligibility',
submitLabel: 'Submit assessment',
done: 'advanced to Eligibility Assessed',
},
underwrite: {
uid: 'ed8ec0dc-4c2c-4d9e-b798-757f6a9d4730',
name: 'Submit Underwriting',
submitLabel: 'Submit underwriting',
done: 'advanced to Underwriting',
},
issue: {
uid: '7b8fb734-a780-4c6a-837b-c5e7ca28e489',
name: 'Issue Policy',
submitLabel: 'Issue policy',
done: 'advanced to Policy Issued',
},
onboard: {
uid: 'ae415f4e-a33e-432e-882f-733238de8bcc',
name: 'Onboard Customer',
submitLabel: 'Complete onboarding',
done: 'advanced to Customer 360',
},
disqualify: {
uid: 'ea99fc60-caba-45c1-afc1-7386a2fa9220',
name: 'Disqualify',
submitLabel: 'Disqualify lead',
done: 'moved to Disqualified',
},
} satisfies Record<string, ActivityMeta>;
export type ActivityKey = keyof typeof ACTIVITY_META;
// OCR field configs for Collect Documents (extraction → mapped target inputs).
export const OCR_FIELDS: Record<string, { id: string; docLabel: string; mappings: OcrMapping[] }> = {
pan: {
id: 'pan_ocr_2',
docLabel: 'PAN',
mappings: [{ extraction_key: 'pan_number', target_field: 'pan_number_input' }],
},
aadhaar: {
id: 'aadhar_ocr_2',
docLabel: 'Aadhaar',
mappings: [{ extraction_key: 'aadhaar_number', target_field: 'aadhaar_number_input' }],
},
};
// Per-state next action. `route` → dedicated rich screen; else inline form.
// Routed steps (recommend / documents / underwriting / issue) render their own
// screen, so `activity` is only set when the step renders an inline form.
export interface NextAction {
activity?: ActivityKey;
route?: string;
/** Button label for routed steps. */
label?: string;
}
export const STATE_NEXT: Record<string, NextAction> = {
'New Lead': { activity: 'qualify' },
Qualified: { activity: 'assign', route: '/assign', label: 'Assign lead' },
Assigned: { activity: 'contact' },
Contacted: { activity: 'schedule' },
'Meeting Scheduled': { route: '/recommend', label: 'Recommend product' },
'Product Recommended': { route: '/documents', label: 'Collect documents' },
'Documents Collected': { activity: 'assess' },
'Eligibility Assessed': { route: '/underwriting', label: 'Submit underwriting' },
Underwriting: { route: '/issue', label: 'Issue policy' },
'Policy Issued': { activity: 'onboard' },
};
// States from which Disqualify is a valid (secondary) action.
export const DISQUALIFY_STATES = new Set([
'New Lead',
'Qualified',
'Assigned',
'Contacted',
'Meeting Scheduled',
'Product Recommended',
'Documents Collected',
'Eligibility Assessed',
'Underwriting',
]);

50
src/api/leads.tsx Normal file
View File

@ -0,0 +1,50 @@
import { createContext, useContext, useMemo } from 'react';
import type { ReactNode } from 'react';
import type { Lead } from '../types';
import { useQuery, useZino } from './provider';
import { recordToLead } from './adapters';
import { RECORD_VIEW_IDS } from './config';
import type { LeadRecord, RecordViewField } from './types';
interface LeadsContextValue {
/** Domain-mapped leads (pipeline list, roster, KPIs). */
leads: Lead[];
/** Raw recordview rows — action screens read region/income/etc. from these. */
records: LeadRecord[];
/** Recordview field config — drives the filter/search controls. */
fields: RecordViewField[];
loading: boolean;
error: string | null;
refetch: () => void;
}
const LeadsContext = createContext<LeadsContextValue | null>(null);
/**
* Fetch the full lead recordview once and share it. Both the sidebar roster
* (AI-employee counts + pipeline subtitle) and the pipeline screen read from
* here, so the list loads with a single request.
*/
export function LeadsProvider({ children }: { children: ReactNode }) {
const { client } = useZino();
const { data, loading, error, refetch } = useQuery(
() => client.recordView(RECORD_VIEW_IDS.ALL_LEADS, { limit: 200, sortBy: 'updated_at', sortDir: 'desc' }),
[],
);
const records = useMemo(() => (data?.data ?? []) as LeadRecord[], [data]);
const fields = useMemo(() => data?.config?.fields ?? [], [data]);
const leads = useMemo(() => records.map(recordToLead), [records]);
const value = useMemo<LeadsContextValue>(
() => ({ leads, records, fields, loading, error, refetch }),
[leads, records, fields, loading, error, refetch],
);
return <LeadsContext.Provider value={value}>{children}</LeadsContext.Provider>;
}
export function useLeads(): LeadsContextValue {
const ctx = useContext(LeadsContext);
if (!ctx) throw new Error('useLeads must be used within <LeadsProvider>');
return ctx;
}

79
src/api/meetings.ts Normal file
View File

@ -0,0 +1,79 @@
// Derive a flat meeting/booking list from the shared lead recordview.
// One lead row carries at most one meeting (meeting_datetime + mode + address),
// owned by owner_agent. No separate agent/meeting table exists — the agent
// roster is whatever owner_agent values the leads carry.
import { useMemo } from 'react';
import { useLeads } from './leads';
import type { LeadRecord } from './types';
export interface Meeting {
leadId: string;
leadName: string;
/** Normalized agent display name, or 'Unassigned'. */
agent: string;
agentId: number | null;
/** ISO datetime of the slot. */
datetime: string;
/** Raw mode key (video | phone | branch | home_visit | …). */
mode: string;
/** Zoom link / branch name / address — may be empty. */
address?: string;
/** current_state_name — where the lead sits in the lifecycle. */
stage: string;
interest?: string;
}
const UNASSIGNED = 'Unassigned';
// owner_agent arrives as {id,name} (clean lookup rows), a bare string (legacy /
// test rows), or null. Flatten to a stable {name,id}.
function normAgent(raw: unknown): { name: string; id: number | null } {
if (raw && typeof raw === 'object') {
const o = raw as { id?: number; name?: string };
const name = (o.name ?? '').trim();
return { name: name || UNASSIGNED, id: typeof o.id === 'number' ? o.id : null };
}
if (typeof raw === 'string') {
const name = raw.trim();
return { name: name || UNASSIGNED, id: null };
}
return { name: UNASSIGNED, id: null };
}
function recordToMeeting(r: LeadRecord): Meeting | null {
if (!r.meeting_datetime) return null;
const dt = new Date(r.meeting_datetime);
if (Number.isNaN(dt.getTime())) return null;
const agent = normAgent(r.owner_agent);
const address = typeof r.meeting_address === 'string' ? r.meeting_address.trim() : '';
return {
leadId: String(r.instance_id),
leadName: r.lead_name ?? `Lead ${r.instance_id}`,
agent: agent.name,
agentId: agent.id,
datetime: r.meeting_datetime,
mode: (r.meeting_mode ?? '').toLowerCase(),
address: address || undefined,
stage: r.current_state_name ?? '—',
interest: r.product_interest ?? undefined,
};
}
export function useMeetings(): { meetings: Meeting[]; agents: string[]; loading: boolean; error: string | null } {
const { records, loading, error } = useLeads();
return useMemo(() => {
const meetings = records
.map(recordToMeeting)
.filter((m): m is Meeting => m !== null)
.sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime());
// Roster ordered by meeting load, Unassigned always last.
const counts = new Map<string, number>();
for (const m of meetings) counts.set(m.agent, (counts.get(m.agent) ?? 0) + 1);
const agents = [...counts.keys()].sort((a, b) => {
if (a === UNASSIGNED) return 1;
if (b === UNASSIGNED) return -1;
return (counts.get(b) ?? 0) - (counts.get(a) ?? 0);
});
return { meetings, agents, loading, error };
}, [records, loading, error]);
}

127
src/api/provider.tsx Normal file
View File

@ -0,0 +1,127 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { ReactNode } from 'react';
import { ZinoClient } from './client';
import type { User } from './types';
interface ZinoContextValue {
client: ZinoClient;
user: User | null;
login: (email: string, password: string, orgId?: string) => Promise<void>;
logout: () => void;
}
const ZinoContext = createContext<ZinoContextValue | null>(null);
export function ZinoProvider({ baseUrl, children }: { baseUrl: string; children: ReactNode }) {
// One client per provider instance. onAuthError clears the user so routes
// bounce back to /login.
const clientRef = useRef<ZinoClient>();
if (!clientRef.current) {
clientRef.current = new ZinoClient(baseUrl);
}
const client = clientRef.current;
// Restore the session synchronously from the persisted JWT so a hard
// navigation to a protected route doesn't flash through /login.
const [user, setUser] = useState<User | null>(() => client.currentUser());
useEffect(() => {
client.setAuthErrorHandler(() => setUser(null));
}, [client]);
const value = useMemo<ZinoContextValue>(
() => ({
client,
user,
login: async (email, password, orgId) => {
const res = await client.login(email, password, orgId);
setUser(res.user ?? client.currentUser());
},
logout: () => {
client.logout();
setUser(null);
},
}),
[client, user],
);
return <ZinoContext.Provider value={value}>{children}</ZinoContext.Provider>;
}
export function useZino(): ZinoContextValue {
const ctx = useContext(ZinoContext);
if (!ctx) throw new Error('useZino must be used within <ZinoProvider>');
return ctx;
}
// --- minimal data-fetching hook (no extra deps) ---
export interface QueryState<T> {
data: T | null;
loading: boolean;
error: string | null;
refetch: () => void;
}
/**
* Fire `fn` whenever a dependency in `deps` changes. Tracks loading/error and
* ignores results from a stale call. `enabled=false` short-circuits.
* `refetchMs` (opt-in) polls on that interval; the interval is cleared on
* unmount, dep change, or when disabled.
*/
export function useQuery<T>(
fn: () => Promise<T>,
deps: unknown[],
enabled = true,
refetchMs?: number,
): QueryState<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(enabled);
const [error, setError] = useState<string | null>(null);
const [tick, setTick] = useState(0);
const refetch = useCallback(() => setTick((t) => t + 1), []);
useEffect(() => {
if (!enabled) {
setLoading(false);
return;
}
let live = true;
setLoading(true);
setError(null);
fn()
.then((d) => {
if (live) setData(d);
})
.catch((e: unknown) => {
if (live) setError(e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Request failed'));
})
.finally(() => {
if (live) setLoading(false);
});
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, tick, enabled]);
// Opt-in background polling. Kept separate from the fetch effect so the
// interval doesn't restart on every refetch; ticking triggers it instead.
useEffect(() => {
if (!enabled || !refetchMs || refetchMs <= 0) return;
const id = setInterval(() => setTick((t) => t + 1), refetchMs);
return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, enabled, refetchMs]);
return { data, loading, error, refetch };
}

104
src/api/schema.ts Normal file
View File

@ -0,0 +1,104 @@
// Maps a live form-screens response onto aria's FieldDef shape, so forms render
// from config instead of hand-mirrored defs. The designer's `grid_config`
// decides which fields are user-facing; `fields[]` is the metadata pool.
import type { FieldDef, FieldType } from './forms';
import type { FormScreenField, FormScreenResponse } from './types';
const TYPE_MAP: Record<string, FieldType> = {
text: 'text',
email: 'email',
phone: 'phone',
number: 'number',
date: 'date',
datetime: 'datetime',
longtext: 'longtext',
select: 'select',
multiselect: 'multiselect',
boolean: 'boolean',
lookup: 'lookup',
ocr: 'ocr',
file: 'file',
};
/** One server field FieldDef, or null if it's not a user input (id_gen,
* disabled/computed, or an unknown type). */
function toFieldDef(f: FormScreenField): FieldDef | null {
if (f.properties?.disabled) return null; // server-generated (id_gen)
const type = TYPE_MAP[f.data_type];
if (!type) return null; // id_gen + anything unmapped
const p = f.properties ?? {};
const def: FieldDef = {
id: f.id,
label: f.name,
type,
required: !!f.mandatory,
seedKey: f.mapped_workflow_field ?? f.id.replace(/_input$/, ''),
};
if (type === 'select' || type === 'multiselect') {
if (p.option_source === 'dataset' && p.dataset_uuid) {
// Dataset-backed: options fetched live (filter_options-aware) at render.
def.optionSource = 'dataset';
def.datasetUuid = p.dataset_uuid;
def.displayKeys = p.display_keys ?? [];
def.valueKey = p.value_key ?? '';
def.options = [];
} else {
def.options = p.options ?? [];
}
}
if (type === 'lookup' && p.lookup_config) {
def.lookupTemplate = p.lookup_config.rdbms_template_uuid;
def.lookupDisplay = (p.lookup_config.display_columns ?? []).map((c) => c.name);
def.lookupStorage = (p.lookup_config.storage_columns ?? []).map((c) => c.name);
}
if (type === 'ocr' && p.ocr_config) {
def.docLabel = p.ocr_config.template ?? f.name;
def.ocrMappings = p.ocr_config.field_mappings ?? [];
}
return def;
}
/** User-facing fields for a form, in the designer's layout order. */
export function schemaToFields(resp: FormScreenResponse): FieldDef[] {
const byUid = new Map(resp.fields.map((f) => [f.uid, f]));
const grid = (resp.grid_config ?? []).filter((g) => g.type === 'field');
const ordered: FormScreenField[] = grid.length
? grid
.slice()
.sort((a, b) => a.y - b.y || a.x - b.x)
.map((g) => byUid.get(g.fieldUid))
.filter((f): f is FormScreenField => !!f)
: resp.fields;
return ordered
.map(toFieldDef)
.filter((d): d is FieldDef => d !== null)
.map((d) => ({
...d,
hidden: !!resp.field_defaults?.[d.id]?.hidden,
disabled: !!resp.field_defaults?.[d.id]?.disabled,
prefill: resp.field_defaults?.[d.id]?.value,
}));
}
/** Single field's def by id for bespoke screens that need one field's
* lookup template / options / mapping without rendering the whole form. */
export function fieldFromSchema(
resp: FormScreenResponse | null | undefined,
id: string,
): FieldDef | undefined {
const f = resp?.fields.find((x) => x.id === id);
if (!f) return undefined;
const def = toFieldDef(f);
if (!def) return undefined;
return {
...def,
hidden: !!resp?.field_defaults?.[id]?.hidden,
disabled: !!resp?.field_defaults?.[id]?.disabled,
prefill: resp?.field_defaults?.[id]?.value,
};
}

261
src/api/types.ts Normal file
View File

@ -0,0 +1,261 @@
// Live API shapes + the SDK's domain types.
export interface ApiError {
status: number;
message: string;
}
export interface User {
id: string;
org_id: string;
name: string;
email: string;
roles: string[];
groups: string[];
}
export interface LoginResponse {
token: string;
user: User;
}
// --- Record view (POST /app/{appId}/view/recordview) ---
export interface RecordViewField {
field_key: string;
output_label: string;
data_type: string;
is_filter: boolean;
is_search: boolean;
}
export interface RecordViewResponse {
config: { fields: RecordViewField[] };
data: Record<string, unknown>[];
pagination?: { page: number; limit: number; total_count: number; total_pages: number };
}
export interface RecordViewParams {
page?: number;
limit?: number;
sortBy?: string;
sortDir?: 'asc' | 'desc';
search?: string;
filters?: Array<{ field_key: string; value: string; data_type?: string }>;
}
// --- Form schema (POST /app/{appId}/view/form-screens) ---
// The live activity form config. `fields` is the metadata pool; `grid_config`
// is the designer's layout and decides which fields are user-facing.
export interface FormScreenFieldProps {
options?: Array<{ label: string; value: string }>;
/** Dataset-backed select/multiselect: options come live from
* /dataset-options (filter_options-aware), not the static `options` above.
* `display_keys` are joined for the label; `value_key` is the stored value. */
option_source?: string; // 'dataset' | (else inline)
dataset_uuid?: string;
display_keys?: string[];
value_key?: string;
lookup_config?: {
rdbms_template_uuid: string;
display_columns?: Array<{ name: string }>;
storage_columns?: Array<{ name: string }>;
};
ocr_config?: {
template?: string;
field_mappings?: Array<{ target_field: string; extraction_key: string }>;
};
source?: string;
disabled?: boolean;
}
export interface FormScreenField {
id: string;
uid: string;
name: string;
data_type: string;
mandatory?: boolean;
/** Companion/record column this field writes — the canonical seed key. */
mapped_workflow_field?: string;
type?: string; // 'mapped' | 'local'
properties?: FormScreenFieldProps | null;
}
export interface FormScreenGridItem {
type: string; // 'field' | ...
fieldUid: string;
x: number;
y: number;
}
/** One condition of a field_rule filter_config. The lookup row's
* `target_column` is compared (currently only `equals`) against the current
* value of `form_field`. When the source field is itself a lookup/object,
* `source_value_key` names the sub-key to read off it (e.g. a region lookup's
* "region" column); empty means use the form value directly. */
export interface FieldRuleCondition {
operator: string;
form_field: string;
target_column: string;
source_value_key?: string;
}
/** A designer field_rule. We honour `action: "filter_options"`, which scopes a
* target field's options/lookup rows by another field's current value. */
export interface FieldRule {
id: string;
action: string;
active?: boolean;
target_field: string;
filter_config?: {
logic?: string; // "AND" | "OR"
conditions?: FieldRuleCondition[];
} | null;
}
/** Per-field designer defaults, keyed by field id. `hidden` drops the field
* from the form UI while its mapped value is still seeded + submitted. */
export interface FieldDefault {
hidden?: boolean;
/** Read-only in the form — value is computed (e.g. premium_amount by custom_js). */
disabled?: boolean;
value?: unknown;
}
export interface FormScreenResponse {
activity_uid: string;
activity_name: string;
fields: FormScreenField[];
grid_config?: FormScreenGridItem[];
/** Designer field_rules (cascading filter_options etc.). */
field_rules?: FieldRule[];
/** Per-field designer defaults (e.g. `{ assign_city: { hidden: true } }`). */
field_defaults?: Record<string, FieldDefault>;
}
// --- Audit (GET /app/{appId}/view/audit) ---
export interface AuditEntry {
id: number;
user_id: string;
user_roles: string[];
activity_id: string;
data: Record<string, unknown>;
execution_state?: string;
created_at: string;
}
// --- AI decisions (GET /monitor/decisions) ---
export interface AiDecision {
id: number;
ai_user_id: string;
instance_id: string;
activity_id: string;
activity_name?: string;
/** Server-resolved display name for the AI employee (preferred over the
* static AI_EMPLOYEES map). */
employee_name?: string;
reasoning: string;
confidence: number;
status: string;
instance_state?: string;
knowledge_sources?: unknown;
tool_calls?: unknown;
/** The activity payload the AI employee submitted e.g. the Aria Advisor
* product recommendation carries ai_recommended_product_input /
* ai_suggested_sum_assured_input / ai_recommendation_confidence_input here. */
data?: Record<string, unknown>;
created_at: string;
}
// --- Workflow execution (core) ---
export interface ActivityResult {
success?: boolean;
instance_id?: number | string;
data?: Record<string, unknown>;
message?: string;
status_code?: number;
error?: string;
}
// Generated policy schedule .docx, written by the Onboard trigger (policy_doc
// is a jsonb array on the lead record).
export interface PolicyDoc {
url: string;
blob_path?: string;
mime_type?: string;
size_bytes?: number;
original_name?: string;
}
// Several recordview fields come back as structured objects, not scalars.
// Normalize via the helpers in adapters.ts before rendering/seeding.
export interface PhoneValue {
dial_code?: string;
phone?: string;
phone_with_dial_code?: string;
}
export interface RegionValue {
region?: string;
}
// `recommended_product` is a lookup into the product catalog (plan_name is the
// display column); `owner_agent` is a {id,name} lookup.
export interface ProductLookup {
id?: number;
plan_name?: string;
[key: string]: unknown;
}
// A row from the all-leads record view, typed for what we read.
export interface LeadRecord {
instance_id: number;
current_state_name?: string | null;
lead_name?: string | null;
phone?: string | PhoneValue | null;
email?: string | null;
city?: string | null;
state_region?: string | null;
date_of_birth?: string | null;
annual_income?: number | null;
occupation?: string | null;
preferred_language?: string | null;
product_interest?: string | null;
lead_score?: number | null;
lead_segment?: string | null;
channel_source?: string | null;
owner_agent?: { id: number; name: string } | null;
assigned_region?: string | RegionValue | null;
sum_assured?: number | null;
premium_amount?: number | null;
premium_frequency?: string | null;
recommended_product?: string | ProductLookup | null;
riders?: string | string[] | null;
recommendation_notes?: string | null;
meeting_datetime?: string | null;
meeting_mode?: string | null;
doc_status?: string | null;
eligibility_status?: string | null;
eligibility_notes?: string | null;
verified_income?: number | null;
underwriting_ref?: string | null;
underwriting_status?: string | null;
pan_number?: string | null;
aadhaar_number?: string | null;
policy_number?: string | null;
policy_issue_date?: string | null;
policy_term?: number | null;
maturity_date?: string | null;
// Written by the Onboard trigger (5d), read off the lead record.
welcome_email_status?: string | null;
policy_doc?: PolicyDoc[] | null;
// Recordview system columns — last-activity / creation time, used to sort
// worklists latest-first.
created_at?: string | null;
updated_at?: string | null;
[key: string]: unknown;
}

View File

@ -0,0 +1,209 @@
// Recordview filter toolbar: an inline search box + a "Filters" modal driven by
// the recordview's `is_filter` / `is_search` config. Drops in above any lead
// table (Pipeline, the worklists). Owns its modal; filtering itself is the pure
// applyFilters() in api/filters.ts, so the parent stays in control of the state.
import { useMemo, useState } from 'react';
import { Search, SlidersHorizontal, X } from 'lucide-react';
import { Button, Input, Modal, MultiSelect } from './core';
import type { LeadRecord, RecordViewField } from '../api/types';
import {
activeFilterCount,
distinctValues,
filterableFields,
filterKind,
isActive,
prettify,
searchableFields,
type FieldFilter,
type FilterState,
} from '../api/filters';
export interface LeadFiltersProps {
/** Unfiltered rows — option lists are derived from these. */
records: LeadRecord[];
fields: RecordViewField[];
value: FilterState;
onChange: (next: FilterState) => void;
/** Field keys to omit (e.g. current_state_name on a state-scoped worklist). */
excludeKeys?: string[];
}
export function LeadFilters({ records, fields, value, onChange, excludeKeys = [] }: LeadFiltersProps) {
const [open, setOpen] = useState(false);
const searchFields = useMemo(() => searchableFields(fields), [fields]);
// Only surface filter fields that actually have values to filter on.
const fFields = useMemo(
() =>
filterableFields(fields)
.filter((f) => !excludeKeys.includes(f.field_key))
.map((f) => ({ field: f, options: filterKind(f.data_type) === 'set' ? distinctValues(records, f.field_key) : [] }))
.filter(({ field, options }) => filterKind(field.data_type) !== 'set' || options.length > 0),
[fields, records, excludeKeys],
);
const activeCount = activeFilterCount({ ...value, byField: pruneExcluded(value.byField, excludeKeys) });
const searchLabel = searchFields.map((f) => f.output_label).join(', ') || 'leads';
if (!searchFields.length && !fFields.length) return null;
const setField = (key: string, next: FieldFilter) =>
onChange({ ...value, byField: { ...value.byField, [key]: next } });
const clearField = (key: string) => {
const rest = { ...value.byField };
delete rest[key];
onChange({ ...value, byField: rest });
};
const clearAll = () => onChange({ search: '', byField: {} });
return (
<div className="flex flex-wrap items-center gap-2.5 px-[18px] py-3 border-b border-border-subtle">
{searchFields.length > 0 && (
<Input
value={value.search}
onChange={(e) => onChange({ ...value, search: e.target.value })}
placeholder={`Search ${searchLabel}`}
prefix={<Search size={15} />}
className="w-full max-w-[280px]"
/>
)}
{fFields.length > 0 && (
<Button
variant="secondary"
size="sm"
iconLeft={<SlidersHorizontal size={14} />}
onClick={() => setOpen(true)}
>
Filters
{activeCount > 0 && (
<span className="ml-1 inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-pill bg-sunrise text-white text-2xs font-bold nums">
{activeCount}
</span>
)}
</Button>
)}
{(activeCount > 0 || value.search) && (
<button
type="button"
onClick={clearAll}
className="text-xs font-semibold text-muted hover:text-strong inline-flex items-center gap-1"
>
<X size={13} /> Clear
</button>
)}
{/* Active-filter chips */}
{fFields.map(({ field }) => {
const f = value.byField[field.field_key];
if (!isActive(f)) return null;
return (
<span
key={field.field_key}
className="inline-flex items-center gap-1.5 rounded-pill bg-sunrise-100 text-sunrise-600 text-2xs font-semibold pl-2.5 pr-1.5 py-1"
>
<span className="opacity-70">{field.output_label}:</span>
{summarize(f, field.data_type)}
<button type="button" onClick={() => clearField(field.field_key)} className="hover:opacity-70">
<X size={12} />
</button>
</span>
);
})}
<Modal open={open} onClose={() => setOpen(false)} title="Filter leads" width="md">
<div className="flex flex-col gap-5">
{fFields.map(({ field, options }) => {
const kind = filterKind(field.data_type);
const f = value.byField[field.field_key] ?? {};
if (kind === 'set') {
return (
<MultiSelect
key={field.field_key}
label={field.output_label}
options={options.map((v) => ({ value: v, label: prettify(v) }))}
value={f.values ?? []}
onChange={(vals) => setField(field.field_key, { values: vals })}
/>
);
}
if (kind === 'number') {
return (
<div key={field.field_key} className="flex flex-col gap-1.5">
<span className="text-sm font-medium text-muted">{field.output_label}</span>
<div className="flex items-center gap-2">
<Input
type="number"
placeholder="Min"
value={f.min ?? ''}
onChange={(e) => setField(field.field_key, { ...f, min: numOrUndef(e.target.value) })}
/>
<span className="text-faint"></span>
<Input
type="number"
placeholder="Max"
value={f.max ?? ''}
onChange={(e) => setField(field.field_key, { ...f, max: numOrUndef(e.target.value) })}
/>
</div>
</div>
);
}
return (
<div key={field.field_key} className="flex flex-col gap-1.5">
<span className="text-sm font-medium text-muted">{field.output_label}</span>
<div className="flex items-center gap-2">
<Input
type="date"
value={f.from ?? ''}
onChange={(e) => setField(field.field_key, { ...f, from: e.target.value || undefined })}
/>
<span className="text-faint"></span>
<Input
type="date"
value={f.to ?? ''}
onChange={(e) => setField(field.field_key, { ...f, to: e.target.value || undefined })}
/>
</div>
</div>
);
})}
</div>
<div className="mt-6 flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={() => onChange({ ...value, byField: {} })}>
Clear all
</Button>
<Button size="sm" onClick={() => setOpen(false)}>
Done
</Button>
</div>
</Modal>
</div>
);
}
function numOrUndef(s: string): number | undefined {
if (s === '') return undefined;
const n = Number(s);
return Number.isNaN(n) ? undefined : n;
}
function pruneExcluded(byField: Record<string, FieldFilter>, exclude: string[]): Record<string, FieldFilter> {
if (!exclude.length) return byField;
const out: Record<string, FieldFilter> = {};
for (const [k, v] of Object.entries(byField)) if (!exclude.includes(k)) out[k] = v;
return out;
}
function summarize(f: FieldFilter, dataType: string): string {
if (filterKind(dataType) === 'set') {
const vs = f.values ?? [];
return vs.length <= 2 ? vs.map(prettify).join(', ') : `${vs.length} selected`;
}
if (filterKind(dataType) === 'number') {
return `${f.min ?? '…'} ${f.max ?? '…'}`;
}
return `${f.from ?? '…'} ${f.to ?? '…'}`;
}

View File

@ -0,0 +1,22 @@
// Zino wordmark (copied from sm2 renderer assets/zino-logo.svg). Inlined as a
// component so it works under both the dev root and the /lead-to-policy/ build
// base without an asset-path dance. Brand orange (#ed563b) by default; pass a
// className with a `text-*` colour + `fill-current` to recolour.
export function ZinoLogo({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 486.596 229"
role="img"
aria-label="Zino"
className={className}
fill="#ed563b"
>
<g transform="translate(-61 -34)" fillRule="evenodd">
<path d="M464.867,87.973a54.254,54.254,0,0,0-15.651-38.165,53.085,53.085,0,0,0-75.566,0A54.248,54.248,0,0,0,358,87.973V241.024s.193,21.3.763,21.589,7.674.391,12.6.386h.386c4.738,0,11.084-.01,11.827-.385.76-.384,1.142-173.286,1.145-174.632v-.008a27.124,27.124,0,0,1,7.825-19.082,26.542,26.542,0,0,1,37.783,0,27.129,27.129,0,0,1,7.826,19.082h0s-.534,53.337-.38,87.513c.152,33.726.76,85.2,1.14,86.356.362,1.1,6.936,1.13,11.81,1.153l.734,0c5.214.027,12.261-.452,13.3-1.157s0-173.869,0-173.869Z" transform="translate(-70.29)" />
<path d="M661.263,64.763a46.933,46.933,0,0,1,4.067,19.071V209.842h0a53.434,53.434,0,0,1-106.844,1.424h-.022V83.834a46.934,46.934,0,0,1,4.068-19.071A49.748,49.748,0,0,1,574.114,48.6a53.837,53.837,0,0,1,17.335-10.8,57.009,57.009,0,0,1,40.9,0A53.84,53.84,0,0,1,649.68,48.6,49.76,49.76,0,0,1,661.263,64.763ZM585.18,209.744a26.717,26.717,0,0,0,53.433,0V83.834a23.467,23.467,0,0,0-2.034-9.535,24.88,24.88,0,0,0-5.791-8.084,26.926,26.926,0,0,0-8.668-5.4,28.505,28.505,0,0,0-20.448,0,26.927,26.927,0,0,0-8.668,5.4,24.88,24.88,0,0,0-5.791,8.084,23.466,23.466,0,0,0-2.034,9.535Z" transform="translate(-117.733)" />
<path d="M69.512,35.045c-1.221.732-1.527,3.345-1.527,12.022s.305,11.29,1.527,12.022c.916.627,15.674,1.045,34.91,1.045,24.325,0,33.281.314,33.281,1.15,0,1.15-1.934,5.541-12.519,29.167-1.934,4.286-8.651,19.34-14.961,33.452S97.3,152.757,95.465,156.834c-8.142,17.876-17,37.53-21.882,48.611-2.952,6.586-6.717,15.054-8.346,18.817-2.239,4.914-3.257,9.3-3.867,15.89-1.221,12.754.509,18.712,6.31,21.849,2.748,1.463,97.5,1.254,99.233-.209,1.628-1.464,1.832-18.818.305-21.953-1.018-1.882-2.544-1.986-39.184-1.986-27.277,0-38.167-.314-38.167-1.15,0-.627,3.054-7.84,6.718-15.994,3.664-8.05,7.836-17.354,9.262-20.49s4.58-10.036,6.921-15.158c2.34-5.227,6.92-15.263,10.178-22.476s8.956-19.863,12.722-28.226,9.771-21.535,13.231-29.272,8.956-19.758,12.112-26.657A89.4,89.4,0,0,0,164.432,66.6c.8-4.207,2.278-11.688,2.278-11.688,0-13.172-1.424-16.622-7.633-19.235C155.514,34.209,149.408,34,112.971,34,88.341,34,70.428,34.418,69.512,35.045Z" />
<path d="M263.343,35.149c-1.73.94-1.832,7.522-1.832,113.768,0,79.4.305,112.933,1.12,113.351.611.418,6.412.731,12.824.731,8.752,0,12.009-.314,12.62-1.358.509-.836.916-51.609.916-112.933,0-99.874-.2-111.575-1.628-113.037C286.141,34.313,283.495,34,275.455,34,269.755,34,264.259,34.522,263.343,35.149Z" transform="translate(-47.454)" />
</g>
</svg>
);
}

View File

@ -0,0 +1,102 @@
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { Button } from '../core/Button';
import type { ButtonSize, ButtonVariant } from '../core/Button';
import { Modal } from '../core/Modal';
import { ActivityForm } from '../form/ActivityForm';
import { DISQUALIFY_STATES } from '../../api/forms';
import type { LeadRecord } from '../../api/types';
import { STEP_BY_STATE, DISQUALIFY_STEP, CAPTURE_STEP, type ActivityStep } from './registry';
// A button that opens its step's form in a modal for one lead. Bespoke steps
// render their *Body; generic steps render <ActivityForm> from the live schema.
function StepButton({
step,
record,
onDone,
variant,
size = 'sm',
}: {
step: ActivityStep;
record: LeadRecord;
onDone: () => void;
variant: ButtonVariant;
size?: ButtonSize;
}) {
const [open, setOpen] = useState(false);
const subtitle = `${record.lead_name ?? `Lead ${record.instance_id}`} · #${record.instance_id} · ${record.current_state_name ?? ''}`;
return (
<>
<Button variant={variant} size={size} onClick={() => setOpen(true)}>
{step.buttonLabel}
</Button>
{open && (
<Modal open onClose={() => setOpen(false)} title={step.title} subtitle={subtitle} width={step.width}>
{step.Body ? (
<step.Body
instanceId={record.instance_id}
record={record}
onSuccess={() => {
setOpen(false);
onDone();
}}
/>
) : (
<ActivityForm
meta={step.meta!}
instanceId={record.instance_id}
record={record}
onSuccess={() => {
setOpen(false);
onDone();
}}
/>
)}
</Modal>
)}
</>
);
}
/** The per-row action cell: the one primary activity for this lead's state,
* plus Disqualify wherever it's valid. */
export function LeadActions({ record, onDone }: { record: LeadRecord; onDone: () => void }) {
const state = record.current_state_name ?? '';
const primary = STEP_BY_STATE[state];
const canDisqualify = DISQUALIFY_STATES.has(state);
if (!primary && !canDisqualify) {
return <span className="text-2xs text-faint"></span>;
}
return (
<div className="flex items-center justify-end gap-2">
{primary && <StepButton step={primary} record={record} onDone={onDone} variant="primary" />}
{canDisqualify && <StepButton step={DISQUALIFY_STEP} record={record} onDone={onDone} variant="ghost" />}
</div>
);
}
/** Toolbar "Add Lead" — Capture (INIT) submitted via /start, no instance yet. */
export function AddLeadButton({ onDone }: { onDone: () => void }) {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="primary" size="md" onClick={() => setOpen(true)}>
<Plus size={16} />
Add Lead
</Button>
{open && (
<Modal open onClose={() => setOpen(false)} title={CAPTURE_STEP.title} width={CAPTURE_STEP.width}>
<ActivityForm
meta={CAPTURE_STEP.meta!}
onSuccess={() => {
setOpen(false);
onDone();
}}
/>
</Modal>
)}
</>
);
}

View File

@ -0,0 +1,202 @@
import { useEffect, useMemo, useState } from 'react';
import { CheckCircle2, UserCheck } from 'lucide-react';
import { AiSuggestionPanel, Button, Card } from '../';
import { DatasetSelectField, LookupField, SchemaGuard } from '../form';
import type { LookupValue } from '../form';
import { SelectField } from '../core/SelectField';
import { Input } from '../core/Input';
import { useQuery, useZino } from '../../api/provider';
import { decisionToCard } from '../../api/adapters';
import { fieldFromSchema } from '../../api/schema';
import { ACTIVITY_META } from '../../api/forms';
import type { ActivityBodyProps } from './types';
const DEF = ACTIVITY_META.assign;
export function AssignBody({ instanceId, record, onSuccess }: ActivityBodyProps) {
const { client } = useZino();
const schemaQ = useQuery(() => client.formSchema(DEF.uid, instanceId), [instanceId]);
const CITY = useMemo(() => fieldFromSchema(schemaQ.data, 'assign_city'), [schemaQ.data]);
const REGION = useMemo(() => fieldFromSchema(schemaQ.data, 'assigned_region_input'), [schemaQ.data]);
const OWNER = useMemo(() => fieldFromSchema(schemaQ.data, 'owner_agent_input'), [schemaQ.data]);
const [city, setCity] = useState('');
const [region, setRegion] = useState<LookupValue | null>(null);
const [agent, setAgent] = useState<LookupValue | null>(null);
useEffect(() => {
if (!record) return;
setCity(String((record as Record<string, unknown>).city ?? ''));
setRegion((record.assigned_region as LookupValue) ?? null);
setAgent((record.owner_agent as LookupValue) ?? null);
}, [record]);
// Form values keyed exactly as the rules' `form_field`s reference them.
const formData: Record<string, unknown> = {
assign_city: city,
assigned_region_input: region,
owner_agent_input: agent,
};
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
async function submit() {
if (!region) {
setResult({ ok: false, msg: 'Assigned region is required.' });
return;
}
setSubmitting(true);
setResult(null);
try {
const res = await client.performActivity(instanceId, DEF.uid, {
assigned_region_input: region,
...(agent ? { owner_agent_input: agent } : {}),
});
setResult({ ok: true, msg: 'Lead assigned — advanced to Assigned.' });
onSuccess?.(res);
} catch (e) {
setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') });
} finally {
setSubmitting(false);
}
}
return (
<SchemaGuard loading={schemaQ.loading} error={schemaQ.error} action="assign this lead">
<div className="grid gap-[18px] items-start grid-cols-1 lg:grid-cols-[1fr_340px]">
<div className="flex flex-col gap-[18px] min-w-0">
{result && (
<div
className={
result.ok
? 'flex items-center gap-2 text-sm text-emerald-700 bg-emerald-100 border border-emerald-300 rounded-md px-4 py-3'
: 'text-sm text-amber-700 bg-escalated-soft border border-amber-300 rounded-md px-4 py-3'
}
>
{result.ok && <CheckCircle2 size={16} />}
{result.msg}
</div>
)}
<Card title="Assign to agent">
<div className="grid grid-cols-2 gap-4">
{/* assign_city is filter-only: when the designer marks it hidden
(field_defaults.assign_city.hidden), we don't render it but still
seed it from the lead's city and send it so region filters. */}
{CITY &&
!CITY.hidden &&
(() => {
// city drives the region list — drop stale region/agent picks on change.
const onCity = (v: string) => {
setCity(v);
setRegion(null);
setAgent(null);
};
if (CITY.optionSource === 'dataset' && CITY.datasetUuid) {
return (
<DatasetSelectField
label={CITY.label}
required={CITY.required}
value={city}
onChange={onCity}
activityId={DEF.uid}
fieldId={CITY.id}
instanceId={instanceId}
formData={formData}
fieldRules={schemaQ.data?.field_rules}
placeholder={CITY.required ? 'Select…' : '— None —'}
/>
);
}
return CITY.options?.length ? (
<SelectField
label={CITY.label}
required={CITY.required}
value={city}
onChange={onCity}
options={CITY.options}
placeholder={CITY.required ? 'Select…' : '— None —'}
/>
) : (
<Input
label={CITY.label}
required={CITY.required}
value={city}
onChange={(e) => onCity(e.target.value)}
/>
);
})()}
{REGION && (
<LookupField
label={REGION.label}
required={REGION.required}
templateUid={REGION.lookupTemplate!}
displayCols={REGION.lookupDisplay ?? []}
storageCols={REGION.lookupStorage ?? []}
activityId={DEF.uid}
fieldId={REGION.id}
instanceId={instanceId}
value={region}
onChange={(v) => {
setRegion(v);
setAgent(null); // region drives the agent list — drop a stale pick
}}
formData={formData}
/>
)}
{OWNER && (
<LookupField
label={OWNER.label}
templateUid={OWNER.lookupTemplate!}
displayCols={OWNER.lookupDisplay ?? []}
storageCols={OWNER.lookupStorage ?? []}
activityId={DEF.uid}
fieldId={OWNER.id}
instanceId={instanceId}
value={agent}
onChange={setAgent}
formData={formData}
/>
)}
</div>
<div className="flex gap-2.5 mt-5">
<Button variant="primary" disabled={submitting} onClick={submit}>
{submitting ? 'Assigning…' : 'Assign lead'}
</Button>
</div>
</Card>
</div>
<div className="flex flex-col gap-4">
<div className="bg-navy-grad rounded-lg px-5 py-[22px] shadow-md">
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy-muted mb-3.5">Assignment</div>
<div className="flex items-center gap-3">
<span className="w-10 h-10 rounded-md bg-[rgba(251,169,76,0.18)] text-sunrise-300 flex items-center justify-center">
<UserCheck size={20} />
</span>
<div className="min-w-0">
<div className="text-base font-bold text-on-navy truncate">
{agent ? String(agent.name ?? '—') : 'No agent yet'}
</div>
<div className="text-sm text-on-navy-muted truncate">
{agent?.region ? String(agent.region) : region?.region ? String(region.region) : 'Pick a region & agent'}
</div>
</div>
</div>
</div>
<AssignAiSuggestion instanceId={instanceId} />
</div>
</div>
</SchemaGuard>
);
}
function AssignAiSuggestion({ instanceId }: { instanceId: number | string }) {
const { client } = useZino();
const q = useQuery(() => client.aiDecisions(instanceId as number), [instanceId]);
const latest = (q.data ?? [])[0];
if (!latest) return null;
return <AiSuggestionPanel {...decisionToCard(latest)} />;
}

View File

@ -0,0 +1,414 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { AlertTriangle, CheckCircle2, FileText, Info, ShieldCheck, UploadCloud } from 'lucide-react';
import { Avatar, Badge, Button, Card, DocumentUploadCard, formatINR, Input, SelectField } from '../';
import type { BadgeTone, DocStatus, OcrField } from '../';
import { SchemaGuard } from '../form';
import { useQuery, useZino } from '../../api/provider';
import { fieldFromSchema } from '../../api/schema';
import { ACTIVITIES } from '../../api/config';
import { OCR_FIELDS } from '../../api/forms';
import { crossCheck, type DocExtract, type DocKey } from '../../lib/kyc-match';
import type { ActivityBodyProps } from './types';
const ACT = ACTIVITIES.COLLECT_DOCUMENTS;
const F = ACT.fields;
const STATUS_TONE: Record<string, BadgeTone> = {
pending: 'neutral',
verified: 'success',
mismatch: 'warning',
incomplete: 'info',
};
const DOC_LABEL: Record<DocKey, string> = { pan: 'PAN', aadhaar: 'Aadhaar' };
function mismatchLine(f: { doc: DocKey; field: 'name' | 'dob'; captured: string; ocr: string }): string {
const what = f.field === 'name' ? 'name' : 'DOB';
const fmt = (v: string) => (f.field === 'name' ? `"${v || '—'}"` : v || '—');
return `${DOC_LABEL[f.doc]} ${what} ${fmt(f.ocr)} ≠ captured ${fmt(f.captured)}`;
}
function OcrCapture({
ocr,
docKey,
activityId,
instanceId,
onAutofill,
onFile,
onExtract,
}: {
ocr: (typeof OCR_FIELDS)[keyof typeof OCR_FIELDS];
docKey: DocKey;
activityId: string;
instanceId?: number | string;
onAutofill: (targetField: string, value: unknown) => void;
onFile: (file: File | null) => void;
onExtract: (doc: DocKey, ex: DocExtract) => void;
}) {
const { client } = useZino();
const inputRef = useRef<HTMLInputElement>(null);
const [status, setStatus] = useState<DocStatus>('empty');
const [fileName, setFileName] = useState<string | null>(null);
const [fields, setFields] = useState<OcrField[]>([]);
const [error, setError] = useState<string | null>(null);
function reset() {
setStatus('empty');
setFileName(null);
setFields([]);
setError(null);
onFile(null);
onExtract(docKey, { name: null, dob: null }); // clear this doc's KYC cross-check
}
async function handlePick(file: File) {
setFileName(file.name);
setStatus('processing');
setError(null);
onFile(file);
try {
const res = await client.extractOcr(file, { activityId, fieldId: ocr.id, instanceId });
const extracted = res.extracted ?? {};
for (const m of ocr.mappings) {
if (Object.prototype.hasOwnProperty.call(extracted, m.extraction_key)) {
onAutofill(m.target_field, extracted[m.extraction_key]);
}
}
const asStr = (v: unknown) => (v == null ? null : String(v));
onExtract(docKey, { name: asStr(extracted.full_name), dob: asStr(extracted.date_of_birth) });
setFields(
Object.entries(extracted)
.filter(([, v]) => v !== null && v !== undefined && v !== '')
.map(([k, v]) => ({ label: k.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()), value: String(v), mono: /number|pan|aadhaar/i.test(k) })),
);
setStatus('extracted');
if (res.parse_error) setError(res.parse_error);
} catch (e) {
setStatus('empty');
setError(e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'OCR failed'));
onFile(null);
}
}
return (
<div className="h-full">
<input
ref={inputRef}
type="file"
accept=".pdf,.png,.jpg,.jpeg"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handlePick(f);
e.target.value = '';
}}
/>
<DocumentUploadCard
docType={ocr.docLabel}
status={status}
fields={fields}
fileName={fileName}
onUpload={() => inputRef.current?.click()}
onConfirm={() => setStatus('verified')}
onReplace={() => inputRef.current?.click()}
onRemove={reset}
/>
{error && <div className="text-2xs text-amber-700 mt-1.5">{error}</div>}
</div>
);
}
export function DocumentsBody({ instanceId, record, onSuccess }: ActivityBodyProps) {
const { client } = useZino();
const schemaQ = useQuery(() => client.formSchema(ACT.uid, instanceId), [instanceId]);
const docStatusOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.docStatus)?.options ?? [], [schemaQ.data]);
const [pan, setPan] = useState('');
const [aadhaar, setAadhaar] = useState('');
const [verifiedIncome, setVerifiedIncome] = useState(0);
const [docStatus, setDocStatus] = useState('verified');
const [files, setFiles] = useState<Record<string, File | null>>({});
const [ocrExtracts, setOcrExtracts] = useState<Partial<Record<DocKey, DocExtract>>>({});
const statusTouched = useRef(false);
useEffect(() => {
if (!record) return;
setPan(record.pan_number || '');
setAadhaar(record.aadhaar_number || '');
setVerifiedIncome((record.verified_income as number) || record.annual_income || 0);
setDocStatus(record.doc_status || 'verified');
setFiles({});
setOcrExtracts({});
statusTouched.current = false;
}, [record]);
const setFile = (fieldId: string) => (f: File | null) => setFiles((p) => ({ ...p, [fieldId]: f }));
const applyExtract = (doc: DocKey, ex: DocExtract) => setOcrExtracts((p) => ({ ...p, [doc]: ex }));
const kyc = useMemo(
() => crossCheck({ name: record?.lead_name, dob: record?.date_of_birth }, ocrExtracts),
[record, ocrExtracts],
);
useEffect(() => {
if (kyc.hasMismatch && !statusTouched.current) setDocStatus('mismatch');
}, [kyc.hasMismatch]);
const applyAutofill = (target: string, value: unknown) => {
const v = value == null ? '' : String(value);
if (target === F.panNumber) setPan(v.toUpperCase());
else if (target === F.aadhaarNumber) setAadhaar(v.replace(/\s/g, ''));
};
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
async function submit() {
setSubmitting(true);
setResult(null);
const activityId = ACT.uid;
try {
const fileData: Record<string, unknown> = {};
for (const [fieldId, file] of Object.entries(files)) {
if (!file) continue;
const meta = await client.uploadFile(file, { activityId, fieldId, instanceId });
fileData[fieldId] = [meta];
}
const res = await client.performActivity(instanceId, activityId, {
[F.panNumber]: pan,
[F.aadhaarNumber]: aadhaar,
[F.verifiedIncome]: verifiedIncome,
[F.docStatus]: docStatus,
...fileData,
});
setResult({ ok: true, msg: 'Documents submitted — lead advanced to Documents Collected.' });
onSuccess?.(res);
} catch (e) {
setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') });
} finally {
setSubmitting(false);
}
}
const checklist = [
{ doc: 'PAN number', done: !!pan },
{ doc: 'Aadhaar number', done: !!aadhaar },
{ doc: 'Verified income', done: verifiedIncome > 0 },
{ doc: 'Status confirmed', done: docStatus === 'verified' },
];
const completion = Math.round((checklist.filter((c) => c.done).length / checklist.length) * 100);
return (
<SchemaGuard
loading={schemaQ.loading}
error={schemaQ.error}
action="collect documents for this lead"
>
<div className="grid gap-[18px] items-start grid-cols-1 lg:grid-cols-[1fr_320px]">
<div className="flex flex-col gap-[18px] min-w-0">
<div className="flex items-center gap-3 bg-card border border-border-subtle rounded-lg px-[18px] py-3.5 shadow-sm">
{record ? (
<>
<Avatar name={record.lead_name ?? `Lead ${record.instance_id}`} size={40} />
<div className="flex-1">
<div className="text-base font-bold text-strong">
KYC for {record.lead_name ?? `Lead ${record.instance_id}`} · #{record.instance_id}
</div>
<div className="text-xs text-faint">Collected by Aria Underwrite · {record.current_state_name}</div>
</div>
</>
) : (
<div className="flex-1 text-sm text-faint">No lead record provided.</div>
)}
</div>
{result && (
<div
className={
result.ok
? 'flex items-center gap-2 text-sm text-emerald-700 bg-emerald-100 border border-emerald-300 rounded-md px-4 py-3'
: 'text-sm text-amber-700 bg-escalated-soft border border-amber-300 rounded-md px-4 py-3'
}
>
{result.ok && <CheckCircle2 size={16} />}
{result.msg}
</div>
)}
{kyc.hasMismatch ? (
<div className="flex items-start gap-2.5 text-sm text-amber-800 bg-escalated-soft border border-amber-300 rounded-md px-4 py-3">
<AlertTriangle size={16} className="text-amber-600 shrink-0 mt-0.5" />
<div className="flex-1">
<div className="font-semibold">Identity mismatch review before advancing</div>
<ul className="mt-1.5 list-disc pl-4 space-y-0.5">
{kyc.fields
.filter((f) => f.status === 'mismatch')
.map((f) => (
<li key={`${f.doc}-${f.field}`} className="font-numeric">
{mismatchLine(f)}
</li>
))}
</ul>
<div className="mt-1.5 text-xs text-amber-700">
Document status set to "Mismatch". Override only once you've confirmed the documents belong to this lead.
</div>
</div>
</div>
) : kyc.anyChecked ? (
<div className="flex items-center gap-2 text-sm text-emerald-700 bg-emerald-100 border border-emerald-300 rounded-md px-4 py-3">
<ShieldCheck size={16} />
OCR identity matches the captured lead (name + DOB).
</div>
) : null}
<div className="grid grid-cols-2 gap-[18px]">
<OcrCapture
ocr={OCR_FIELDS.pan}
docKey="pan"
activityId={ACT.uid}
instanceId={instanceId}
onAutofill={applyAutofill}
onFile={setFile(OCR_FIELDS.pan.id)}
onExtract={applyExtract}
/>
<OcrCapture
ocr={OCR_FIELDS.aadhaar}
docKey="aadhaar"
activityId={ACT.uid}
instanceId={instanceId}
onAutofill={applyAutofill}
onFile={setFile(OCR_FIELDS.aadhaar.id)}
onExtract={applyExtract}
/>
</div>
<Card title="KYC details">
<div className="grid grid-cols-2 gap-4">
<Input label="PAN number" value={pan} onChange={(e) => setPan(e.target.value.toUpperCase())} placeholder="ABCDE1234F" />
<Input label="Aadhaar number" value={aadhaar} onChange={(e) => setAadhaar(e.target.value)} placeholder="XXXX XXXX XXXX" />
<Input
label="Verified income"
prefix="₹"
type="number"
value={verifiedIncome}
onChange={(e) => setVerifiedIncome(Number(e.target.value) || 0)}
/>
<SelectField
label="Document status"
required
options={docStatusOptions}
value={docStatus}
onChange={(v) => {
statusTouched.current = true;
setDocStatus(v as DocStatus);
}}
/>
</div>
<div className="mt-4">
<SalarySlipUpload value={files[F.salarySlip] ?? null} onChange={setFile(F.salarySlip)} />
</div>
<div className="flex items-start gap-3 bg-info-soft rounded-md px-3.5 py-3 mt-4">
<Info size={16} className="text-sky-600 shrink-0 mt-0.5" />
<div className="text-xs text-muted leading-normal">
Upload PAN / Aadhaar to auto-extract the numbers via OCR, then confirm the values and the verification
status before advancing. Files upload on submit.
</div>
</div>
<div className="flex gap-2.5 mt-5">
<Button variant="primary" disabled={submitting} onClick={submit}>
{submitting ? 'Submitting…' : 'Submit documents'}
</Button>
</div>
</Card>
</div>
<div className="flex flex-col gap-4">
<section className="bg-card rounded-lg border border-border-subtle shadow-sm p-5">
<h3 className="mt-0 mb-3.5 text-sm font-semibold text-strong">Document checklist</h3>
<div className="flex flex-col gap-2.5">
{checklist.map((c) => (
<div key={c.doc} className="flex items-center justify-between">
<span className="text-sm text-body">{c.doc}</span>
<Badge tone={c.done ? 'success' : 'neutral'} dot={c.done}>
{c.done ? 'Done' : 'Pending'}
</Badge>
</div>
))}
</div>
<div className="h-px bg-border-subtle my-4" />
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-body">Identity check</span>
<Badge tone={kyc.hasMismatch ? 'warning' : kyc.anyChecked ? 'success' : 'neutral'} dot>
{kyc.hasMismatch ? 'Mismatch' : kyc.anyChecked ? 'Verified' : 'Awaiting OCR'}
</Badge>
</div>
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-muted">Current status</span>
<Badge tone={STATUS_TONE[docStatus] ?? 'neutral'} dot>
{docStatusOptions.find((o) => o.value === docStatus)?.label ?? docStatus}
</Badge>
</div>
<div className="flex items-center justify-between mb-3.5 mt-3">
<span className="text-sm text-muted">KYC completion</span>
<span className="font-numeric font-extrabold text-xl text-sunrise-600">{completion}%</span>
</div>
<div className="h-2 bg-slate-100 rounded-pill overflow-hidden mb-[18px]">
<div className="h-full bg-sunrise transition-[width] duration-500" style={{ width: `${completion}%` }} />
</div>
</section>
{record?.annual_income ? (
<div className="bg-info-soft rounded-md px-3.5 py-3 text-xs text-muted">
Stated annual income on file: {formatINR(record.annual_income)}.
</div>
) : null}
</div>
</div>
</SchemaGuard>
);
}
function SalarySlipUpload({ value, onChange }: { value: File | null; onChange: (f: File | null) => void }) {
const ref = useRef<HTMLInputElement>(null);
return (
<div>
<span className="text-sm font-medium text-muted">Salary slip</span>
<input
ref={ref}
type="file"
accept=".pdf,.png,.jpg,.jpeg"
className="hidden"
onChange={(e) => {
onChange(e.target.files?.[0] ?? null);
e.target.value = '';
}}
/>
<button
type="button"
onClick={() => ref.current?.click()}
className="mt-1.5 w-full flex items-center gap-2.5 border border-dashed border-border-default rounded-md bg-slate-50 px-3.5 py-3 cursor-pointer text-left"
>
{value ? <FileText size={18} className="text-sunrise-600 shrink-0" /> : <UploadCloud size={18} className="text-sunrise-500 shrink-0" />}
<span className="text-sm text-body truncate flex-1">{value ? value.name : 'Upload salary slip (PDF / image)'}</span>
{value && (
<span
onClick={(e) => {
e.stopPropagation();
onChange(null);
}}
className="text-2xs font-semibold text-link"
>
Remove
</span>
)}
</button>
</div>
);
}

View File

@ -0,0 +1,159 @@
import { useEffect, useMemo, useState } from 'react';
import { CheckCircle2 } from 'lucide-react';
import { Button, Card, formatINR, Input } from '../';
import { SchemaGuard } from '../form';
import { useQuery, useZino } from '../../api/provider';
import { ACTIVITIES } from '../../api/config';
import type { ActivityBodyProps } from './types';
const F = ACTIVITIES.ISSUE_POLICY.fields;
function todayISO(): string {
return new Date().toISOString().slice(0, 10);
}
function addYears(iso: string, years: number): string {
if (!iso || !years) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
d.setFullYear(d.getFullYear() + years);
return d.toISOString().slice(0, 10);
}
export function IssuePolicyBody({ instanceId, record, onSuccess }: ActivityBodyProps) {
const { client } = useZino();
// Fields are hand-built (no schema-driven options), but we still fetch the
// form schema so an RBAC denial gates the form up front instead of only
// surfacing on submit.
const schemaQ = useQuery(() => client.formSchema(ACTIVITIES.ISSUE_POLICY.uid, instanceId), [instanceId]);
const [issueDate, setIssueDate] = useState(todayISO());
const [term, setTerm] = useState(20);
const [premium, setPremium] = useState(0);
useEffect(() => {
if (!record) return;
setIssueDate(record.policy_issue_date || todayISO());
setTerm(record.policy_term || 20);
setPremium(record.premium_amount || 0);
}, [record]);
const commencement = issueDate;
const maturity = useMemo(() => addYears(issueDate, term), [issueDate, term]);
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
async function submit() {
setSubmitting(true);
setResult(null);
try {
const res = await client.performActivity(instanceId, ACTIVITIES.ISSUE_POLICY.uid, {
[F.issueDate]: issueDate,
[F.term]: term,
[F.maturity]: maturity,
[F.premium]: premium,
});
setResult({ ok: true, msg: 'Policy issued — number generated automatically.' });
onSuccess?.(res);
} catch (e) {
setResult({
ok: false,
msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed'),
});
} finally {
setSubmitting(false);
}
}
const lead = record;
return (
<SchemaGuard loading={schemaQ.loading} error={schemaQ.error} action="issue this policy">
<div className="grid gap-[18px] items-start grid-cols-1 lg:grid-cols-[1fr_360px]">
<div className="flex flex-col gap-[18px] min-w-0">
{result && (
<div
className={
result.ok
? 'flex items-center gap-2 text-sm text-emerald-700 bg-emerald-100 border border-emerald-300 rounded-md px-4 py-3'
: 'text-sm text-amber-700 bg-escalated-soft border border-amber-300 rounded-md px-4 py-3'
}
>
{result.ok && <CheckCircle2 size={16} />}
{result.msg}
</div>
)}
<Card title="Issue policy">
<div className="grid grid-cols-2 gap-4">
<Input
label="Policy issue date"
type="date"
value={issueDate}
onChange={(e) => setIssueDate(e.target.value)}
/>
<Input
label="Policy term (years)"
type="number"
value={term}
onChange={(e) => setTerm(Number(e.target.value) || 0)}
/>
<Input
label="Premium amount"
prefix="₹"
type="number"
value={premium}
onChange={(e) => setPremium(Number(e.target.value) || 0)}
/>
</div>
<div className="text-xs text-faint mt-2">
Policy number is auto-generated on issue (POL-YYYY-####).
</div>
<div className="flex gap-2.5 mt-5">
<Button variant="primary" disabled={submitting} onClick={submit}>
{submitting ? 'Issuing…' : 'Issue policy'}
</Button>
</div>
</Card>
</div>
{/* RIGHT — policy summary (dates computed in-app) */}
<div className="flex flex-col gap-4">
<div className="bg-navy-grad rounded-lg px-5 py-[22px] shadow-md">
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy-muted mb-3.5">
Policy summary
</div>
<div className="flex justify-between mb-2">
<span className="text-sm text-on-navy-muted">Policy number</span>
<span className="text-sm font-semibold text-on-navy nums">
{lead?.policy_number ?? 'auto on issue'}
</span>
</div>
<div className="flex justify-between mb-2">
<span className="text-sm text-on-navy-muted">Risk commencement</span>
<span className="text-sm font-semibold text-on-navy nums">{commencement || '—'}</span>
</div>
<div className="flex justify-between mb-2">
<span className="text-sm text-on-navy-muted">Term</span>
<span className="text-sm font-semibold text-on-navy nums">{term ? `${term} yrs` : '—'}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-on-navy-muted">Maturity date</span>
<span className="text-sm font-semibold text-on-navy nums">{maturity || '—'}</span>
</div>
<div className="h-px bg-[rgba(255,255,255,0.12)] my-4" />
<div className="flex justify-between">
<span className="text-sm text-on-navy-muted">Premium</span>
<span className="text-sm font-semibold text-on-navy nums">{formatINR(premium)}</span>
</div>
</div>
<div className="text-xs text-faint px-1">
Risk commencement = issue date. Maturity = issue date + term, computed in-app.
</div>
</div>
</div>
</SchemaGuard>
);
}

View File

@ -0,0 +1,498 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { CheckCircle2, ChevronRight, Info, ShieldCheck, Sparkles, Wand2 } from 'lucide-react';
import {
AiSuggestionPanel,
Avatar,
Button,
Card,
formatINR,
Input,
MultiSelect,
RupeeAmount,
SelectField,
} from '../';
import { LookupField, SchemaGuard } from '../form';
import type { LookupValue } from '../form';
import { useQuery, useZino } from '../../api/provider';
import { decisionToCard, recommendationFromDecisions } from '../../api/adapters';
import type { AiRecommendation } from '../../api/adapters';
import { fieldFromSchema } from '../../api/schema';
import { ACTIVITIES } from '../../api/config';
import { allowedRidersFor, normalizeRiderValues, recommendCalc, saValidIssues } from '../../lib/recommend';
import type { ActivityBodyProps } from './types';
import type { LeadRecord } from '../../api/types';
const F = ACTIVITIES.RECOMMEND_PRODUCT.fields;
// Riders arrive as a JSON array, a CSV, or a Postgres array-literal `{a,b}` —
// normalizeRiderValues handles all three.
function toRiderValues(raw: LeadRecord['riders']): string[] {
return normalizeRiderValues(raw);
}
export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyProps) {
const { client } = useZino();
const schemaQ = useQuery(() => client.formSchema(ACTIVITIES.RECOMMEND_PRODUCT.uid, instanceId), [instanceId]);
const productField = useMemo(() => fieldFromSchema(schemaQ.data, F.product), [schemaQ.data]);
const freqOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.premiumFrequency)?.options ?? [], [schemaQ.data]);
const riderOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.riders)?.options ?? [], [schemaQ.data]);
// Premium Amount is designer-disabled (field_defaults.premium_amount_input.disabled):
// the custom_js computes it from the rate-card, so it's read-only, never typed.
const premiumDisabled = useMemo(() => fieldFromSchema(schemaQ.data, F.premiumAmount)?.disabled ?? false, [schemaQ.data]);
const [product, setProduct] = useState<LookupValue | null>(null);
const [sum, setSum] = useState(2000000);
const [freq, setFreq] = useState('annual');
const [premium, setPremium] = useState(0);
const [premiumTouched, setPremiumTouched] = useState(false);
// No seed: riders depend on the picked product. Before a product is chosen the
// field is disabled; the record-load effect fills in any saved riders.
const [riders, setRiders] = useState<string[]>([]);
const [notes, setNotes] = useState('');
// Labels of riders auto-removed on the last product switch (for the inline note).
const [prunedRiders, setPrunedRiders] = useState<string[]>([]);
// Suppress the prune-note on the initial record-driven product set, so reopening
// a lead doesn't flash "removed riders". Re-armed on every record load.
const skipPruneNote = useRef(true);
useEffect(() => {
if (!record) return;
skipPruneNote.current = true;
setPrunedRiders([]);
setProduct((record.recommended_product as LookupValue) ?? null);
setSum(record.sum_assured || 2000000);
setFreq(record.premium_frequency || 'annual');
setRiders(toRiderValues(record.riders));
setNotes(record.recommendation_notes || '');
setPremiumTouched(false);
if (record.premium_amount) {
setPremium(record.premium_amount);
setPremiumTouched(true);
}
}, [record]);
// Riders the picked product permits. null = legacy product value (no
// allowed_riders key) → show ALL options. Empty array = plan allows none.
const allowed = useMemo(() => allowedRidersFor(product as Record<string, unknown> | null), [product]);
const visibleRiderOptions = useMemo(
() => (allowed == null ? riderOptions : riderOptions.filter((o) => allowed.includes(o.value))),
[riderOptions, allowed],
);
const riderLabel = useMemo(() => {
const m = new Map<string, string>();
riderOptions.forEach((o) => m.set(o.value, o.label));
return m;
}, [riderOptions]);
const ridersDisabled = !product || (allowed != null && allowed.length === 0);
const ridersHint = !product
? 'Pick a product first'
: allowed != null && allowed.length === 0
? 'No riders available for this plan'
: undefined;
// On a product switch, drop selected riders the new plan doesn't offer and note
// which were removed. Legacy values (allowed == null) keep every saved rider.
// Keyed on `product` only; setRiders fires solely when the set actually shrinks,
// so there's no render loop.
useEffect(() => {
if (allowed == null) {
setPrunedRiders([]);
return;
}
const removed = riders.filter((r) => !allowed.includes(r));
if (removed.length) setRiders(riders.filter((r) => allowed.includes(r)));
if (skipPruneNote.current) {
skipPruneNote.current = false;
setPrunedRiders([]);
return;
}
setPrunedRiders(removed.map((r) => riderLabel.get(r) ?? r));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [product]);
// Mirror the designer custom_js: rate-card premium + sa_valid (the stage-gate
// field). Without sa_valid the lead can't advance past Meeting Scheduled.
const calc = recommendCalc(product, record?.date_of_birth, sum, record?.annual_income);
// When the field is designer-disabled, the premium is always the computed
// rate-card value — a manual override (premiumTouched) can't apply.
const effectivePremium = !premiumDisabled && premiumTouched ? premium : calc.premium;
const issues = saValidIssues(calc);
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
// One-click apply of Aria Advisor's pre-activity recommendation: sets the sum
// assured straight off, and resolves her product *name* against the live
// catalogue into the lookup value the field needs. The agent can still edit.
const [applying, setApplying] = useState(false);
const [applyNote, setApplyNote] = useState<string | null>(null);
async function applyRecommendation(rec: AiRecommendation) {
setApplyNote(null);
if (rec.sumAssured && rec.sumAssured > 0) {
setSum(rec.sumAssured);
setPremiumTouched(false); // let the rate-card premium recompute
}
if (!productField?.lookupTemplate) return;
setApplying(true);
try {
const { records } = await client.lookupRecords(productField.lookupTemplate, {
activityId: ACTIVITIES.RECOMMEND_PRODUCT.uid,
fieldId: F.product,
instanceId,
search: '',
limit: 50,
});
const match = matchProduct(rec.product, records, productField.lookupDisplay ?? []);
if (match) {
const stored: LookupValue = {};
(productField.lookupStorage ?? []).forEach((c) => (stored[c] = match[c] ?? null));
(productField.lookupDisplay ?? []).forEach((c) => {
if (!(c in stored)) stored[c] = match[c] ?? null;
});
setProduct(stored);
} else {
setApplyNote(`Couldnt match “${rec.product}” to the catalogue — pick the product manually.`);
}
} catch {
setApplyNote('Couldnt load the product catalogue — pick the product manually.');
} finally {
setApplying(false);
}
}
const incomeMultiple = record?.annual_income ? (sum / record.annual_income).toFixed(1) : '—';
async function submit() {
if (!product) {
setResult({ ok: false, msg: 'Pick a product from the catalogue.' });
return;
}
setSubmitting(true);
setResult(null);
try {
const res = await client.performActivity(instanceId, ACTIVITIES.RECOMMEND_PRODUCT.uid, {
[F.product]: product,
[F.sumAssured]: sum,
[F.premiumAmount]: effectivePremium,
[F.premiumFrequency]: freq,
[F.riders]: riders,
[F.notes]: notes,
[F.saValid]: calc.saValid,
});
setResult({
ok: true,
msg: calc.saValid
? 'Recommendation submitted — lead advanced to Product Recommended.'
: 'Recommendation saved, but the sum assured is outside the plans eligible range — the lead stays at Meeting Scheduled until corrected.',
});
onSuccess?.(res);
} catch (e) {
setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') });
} finally {
setSubmitting(false);
}
}
// Product / frequency / riders render from the live schema. Without a guard a
// failed schema fetch (most often an RBAC denial — only some roles can
// recommend) silently drops those fields, leaving a broken half-form with no
// explanation. SchemaGuard surfaces loading + permission/error instead.
return (
<SchemaGuard
loading={schemaQ.loading}
error={schemaQ.error}
empty={!productField}
action="recommend a product for this lead"
>
<div className="grid gap-[18px] items-start grid-cols-1 lg:grid-cols-[1fr_360px]">
<div className="flex flex-col gap-[18px] min-w-0">
{result && (
<div
className={
result.ok
? 'flex items-center gap-2 text-sm text-emerald-700 bg-emerald-100 border border-emerald-300 rounded-md px-4 py-3'
: 'text-sm text-amber-700 bg-escalated-soft border border-amber-300 rounded-md px-4 py-3'
}
>
{result.ok && <CheckCircle2 size={16} />}
{result.msg}
</div>
)}
<Card>
<div className="grid grid-cols-2 gap-4">
{productField && (
<LookupField
label={productField.label}
required={productField.required}
templateUid={productField.lookupTemplate!}
displayCols={productField.lookupDisplay ?? []}
storageCols={productField.lookupStorage ?? []}
activityId={ACTIVITIES.RECOMMEND_PRODUCT.uid}
fieldId={F.product}
instanceId={instanceId}
value={product}
onChange={setProduct}
/>
)}
<Input
label="Sum assured"
prefix="₹"
type="number"
value={sum}
onChange={(e) => setSum(Number(e.target.value) || 0)}
/>
<SelectField
label="Premium frequency"
options={freqOptions}
value={freq}
onChange={setFreq}
/>
<Input
label="Premium amount"
prefix="₹"
type="number"
value={effectivePremium}
disabled={premiumDisabled}
hint={
premiumDisabled
? calc.evaluated
? 'Auto-calculated from the plans rate-card'
: 'Pick a product to compute the premium'
: premiumTouched
? undefined
: calc.evaluated
? `Rate-card — ₹${formatINR(calc.premium)}`
: undefined
}
onChange={(e) => {
if (premiumDisabled) return;
setPremium(Number(e.target.value) || 0);
setPremiumTouched(true);
}}
/>
<div className="col-span-full">
<MultiSelect
label="Riders"
options={visibleRiderOptions}
value={riders}
onChange={setRiders}
disabled={ridersDisabled}
hint={ridersHint}
/>
{prunedRiders.length > 0 && (
<div className="mt-2 flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
<Info size={13} className="mt-0.5 shrink-0" />
<span>Removed {prunedRiders.join(', ')} not offered on this plan.</span>
</div>
)}
</div>
<div className="col-span-full">
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium text-muted">Notes for the customer</span>
<textarea
rows={3}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Why this product fits the lead…"
className="font-sans text-base text-strong border border-border-default rounded-md px-3 py-2.5 resize-y outline-none focus:border-sunrise-500"
/>
</label>
</div>
</div>
{issues.length > 0 && (
<div className="mt-4 flex items-start gap-2.5 rounded-md border border-amber-300 bg-escalated-soft px-4 py-3 text-sm text-amber-800">
<CheckCircle2 size={16} className="mt-0.5 shrink-0 rotate-45 text-amber-600" />
<div>
<div className="font-semibold">Sum assured not yet eligible lead wont advance</div>
<div className="mt-0.5 text-xs text-amber-700">{issues.join('; ')}.</div>
</div>
</div>
)}
<div className="flex gap-2.5 mt-5">
<Button variant="primary" disabled={submitting} onClick={submit}>
{submitting ? 'Submitting…' : 'Send recommendation'}
</Button>
</div>
</Card>
</div>
<div className="flex flex-col gap-4">
<div className="bg-navy-grad rounded-lg px-5 py-[22px] shadow-md">
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy-muted mb-3.5">
{premiumTouched ? 'Premium' : 'Indicative premium'}
</div>
<RupeeAmount value={effectivePremium} size="hero" tone="onNavy" />
<div className="text-sm text-on-navy-muted mt-1.5">
{(freqOptions.find((f) => f.value === freq)?.label ?? freq).toLowerCase()} · excl. 18% GST
</div>
<div className="h-px bg-[rgba(255,255,255,0.12)] my-4" />
<div className="flex justify-between mb-2">
<span className="text-sm text-on-navy-muted">Sum assured</span>
<span className="text-sm font-semibold text-on-navy nums">{formatINR(sum)}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-on-navy-muted">Cover multiple</span>
<span className="text-sm font-semibold text-on-navy">{incomeMultiple}× income</span>
</div>
</div>
<RecommendAiSuggestion
instanceId={instanceId}
onApply={applyRecommendation}
applying={applying}
applyNote={applyNote}
/>
</div>
</div>
</SchemaGuard>
);
}
/** Match Aria's free-text product name to a catalogue row by case/punctuation-
* insensitive containment (longest matching label wins). */
function matchProduct(
aiName: string,
rows: Array<Record<string, unknown>>,
displayCols: string[],
): Record<string, unknown> | null {
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
const ai = norm(aiName);
if (!ai) return null;
let best: Record<string, unknown> | null = null;
let bestLen = 0;
for (const row of rows) {
// Try each display column on its own *and* the joined label — a product
// name like "DigiShield" must still match a multi-column "DigiShield · term".
const candidates = [...displayCols.map((c) => row[c]), displayCols.map((c) => row[c]).join(' ')]
.map((v) => (v == null ? '' : norm(String(v))))
.filter(Boolean);
for (const n of candidates) {
if ((ai.includes(n) || n.includes(ai)) && n.length > bestLen) {
best = row;
bestLen = n.length;
}
}
}
return best;
}
interface RecommendAiSuggestionProps {
instanceId: number | string;
onApply: (rec: AiRecommendation) => void;
applying: boolean;
applyNote: string | null;
}
function RecommendAiSuggestion({ instanceId, onApply, applying, applyNote }: RecommendAiSuggestionProps) {
const { client } = useZino();
const q = useQuery(() => client.aiDecisions(Number(instanceId)), [instanceId]);
const decisions = q.data ?? [];
const rec = recommendationFromDecisions(decisions);
// No structured recommendation yet → fall back to the generic latest-decision
// card so the agent still sees whatever Aria last did.
if (!rec) {
const latest = decisions[0];
if (!latest) return null;
return <AiSuggestionPanel {...decisionToCard(latest)} />;
}
return (
<>
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-faint">Arias recommendation</div>
<AriaRecommendationCard rec={rec} onApply={() => onApply(rec)} applying={applying} applyNote={applyNote} />
</>
);
}
function AriaRecommendationCard({
rec,
onApply,
applying,
applyNote,
}: {
rec: AiRecommendation;
onApply: () => void;
applying: boolean;
applyNote: string | null;
}) {
const [open, setOpen] = useState(false);
const autonomous = rec.confidence >= 0.7;
const accent = autonomous ? 'var(--status-autonomous)' : 'var(--status-escalated)';
const pct = Math.round(Math.max(0, Math.min(1, rec.confidence)) * 100);
return (
<article className="bg-card rounded-2xl border border-border-subtle shadow-sm overflow-hidden font-sans">
{/* header — identity + confidence */}
<header className="flex items-center gap-3 px-5 pt-[18px] pb-3.5">
<Avatar name={rec.employee} ai size={36} className="shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-sm font-bold text-strong leading-tight truncate">{rec.employee}</div>
<div className="text-xs text-faint mt-0.5 truncate">
Pre-meeting suggestion{rec.time && <span> · {rec.time}</span>}
</div>
</div>
<span
className="shrink-0 inline-flex items-center gap-1.5 rounded-pill px-2.5 py-1 text-2xs font-bold tracking-[0.02em] nums"
style={{ background: 'transparent', color: accent }}
title={autonomous ? 'Autonomous' : 'Needs review'}
>
<span className="w-1.5 h-1.5 rounded-full" style={{ background: accent }} />
{pct}%
</span>
</header>
<div className="px-5 pb-5">
{/* recommended product — navy hero tile, echoes the premium card */}
<div className="rounded-xl bg-navy-grad p-4">
<div className="flex items-start gap-3">
<span className="shrink-0 w-11 h-11 rounded-xl bg-sunrise text-white flex items-center justify-center shadow-sunrise">
<ShieldCheck size={22} />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1 text-2xs font-bold uppercase tracking-[0.06em] text-sunrise-300">
<Sparkles size={11} /> Recommends
</div>
<div className="mt-1 text-lg font-bold text-on-navy leading-snug break-words">{rec.product}</div>
</div>
</div>
{rec.sumAssured != null && (
<div className="mt-3.5 flex items-baseline justify-between gap-2 border-t border-[rgba(255,255,255,0.12)] pt-3.5">
<span className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy-muted shrink-0">Suggested cover</span>
<span className="font-numeric text-xl font-extrabold text-on-navy nums truncate">{formatINR(rec.sumAssured)}</span>
</div>
)}
</div>
{/* why it recommended this — expands inline, no inner scrollbar */}
{rec.rationale && (
<div className="mt-3.5">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="inline-flex items-center gap-1.5 bg-none border-none p-0 cursor-pointer font-sans text-xs font-semibold text-link"
>
<ChevronRight size={14} className={open ? 'rotate-90 transition-transform duration-200' : 'transition-transform duration-200'} />
{open ? 'Hide reasoning' : 'Why this product'}
</button>
{open && (
<p className="mt-2.5 px-4 py-3.5 bg-sunk rounded-xl text-sm leading-relaxed text-body whitespace-pre-line">
{rec.rationale}
</p>
)}
</div>
)}
<div className="mt-3.5">
<Button variant="primary" size="sm" full disabled={applying} onClick={onApply} iconLeft={<Wand2 size={14} />}>
{applying ? 'Applying…' : 'Use this suggestion'}
</Button>
{applyNote && <div className="mt-2 text-xs text-amber-700">{applyNote}</div>}
</div>
</div>
</article>
);
}

View File

@ -0,0 +1,221 @@
import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, CheckCircle2, Sparkles } from 'lucide-react';
import { Button, Card, SelectField } from '../';
import { SchemaGuard } from '../form';
import { useQuery, useZino } from '../../api/provider';
import { fieldFromSchema } from '../../api/schema';
import { ACTIVITIES } from '../../api/config';
import type { ActivityBodyProps } from './types';
const F = ACTIVITIES.SUBMIT_UNDERWRITING.fields;
export function UnderwritingBody({ instanceId, record, onSuccess }: ActivityBodyProps) {
const { client } = useZino();
const schemaQ = useQuery(() => client.formSchema(ACTIVITIES.SUBMIT_UNDERWRITING.uid, instanceId), [instanceId]);
const statusOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.status)?.options ?? [], [schemaQ.data]);
const [status, setStatus] = useState('approved');
useEffect(() => {
if (record?.underwriting_status) setStatus(String(record.underwriting_status));
}, [record]);
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
async function submit() {
setSubmitting(true);
setResult(null);
try {
const res = await client.performActivity(instanceId, ACTIVITIES.SUBMIT_UNDERWRITING.uid, {
[F.status]: status,
});
setResult({ ok: true, msg: 'Underwriting verdict submitted.' });
onSuccess?.(res);
} catch (e) {
setResult({
ok: false,
msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed'),
});
} finally {
setSubmitting(false);
}
}
return (
<SchemaGuard
loading={schemaQ.loading}
error={schemaQ.error}
action="submit underwriting for this lead"
>
<div className="grid gap-[18px] items-start grid-cols-1 lg:grid-cols-[1fr_360px]">
<div className="flex flex-col gap-[18px] min-w-0">
{result && (
<div
className={
result.ok
? 'flex items-center gap-2 text-sm text-emerald-700 bg-emerald-100 border border-emerald-300 rounded-md px-4 py-3'
: 'text-sm text-amber-700 bg-escalated-soft border border-amber-300 rounded-md px-4 py-3'
}
>
{result.ok && <CheckCircle2 size={16} />}
{result.msg}
</div>
)}
<Card title="Underwriting decision">
<div className="grid grid-cols-2 gap-4">
<SelectField
label="Underwriting status"
options={statusOptions}
value={status}
onChange={setStatus}
/>
</div>
<div className="text-xs text-faint mt-2">
Underwriting reference is auto-generated on submit (UW-YYYY-####).
</div>
<div className="flex gap-2.5 mt-5">
<Button variant="primary" disabled={submitting} onClick={submit}>
{submitting ? 'Submitting…' : 'Record verdict'}
</Button>
</div>
</Card>
</div>
<div className="flex flex-col gap-4">
{record?.eligibility_notes ? (
<EligibilityNotes notes={String(record.eligibility_notes)} status={record.eligibility_status} />
) : (
<div className="bg-navy-grad rounded-lg px-5 py-[22px] shadow-md text-sm text-on-navy-muted">
No eligibility assessment on file yet Aria Eligibility writes it when documents are collected.
</div>
)}
</div>
</div>
</SchemaGuard>
);
}
// --- Eligibility notes (AI-generated, free-form) -------------------------------
// Aria Eligibility writes eligibility_notes as semi-structured prose:
// "DOCUMENTS: …", "ELIGIBILITY CHECK (…):", "✓ Age …", "REFERRAL REASON: …".
// We parse it leniently into headings / check-items / bullets / paragraphs.
// Nothing is guaranteed — if no structure is detected we fall back to the raw
// text exactly as the model emitted it. Rendered as React children (escaped),
// never dangerouslySetInnerHTML.
type NoteBlock =
| { kind: 'heading'; text: string; inline?: string }
| { kind: 'check'; ok: boolean; text: string }
| { kind: 'bullet'; text: string }
| { kind: 'para'; text: string };
function parseNotes(raw: string): NoteBlock[] {
const blocks: NoteBlock[] = [];
for (const line of raw.split(/\r?\n/)) {
const t = line.trim();
if (!t) continue;
let m: RegExpMatchArray | null;
if ((m = t.match(/^[✓✔☑]\s*(.+)$/))) {
blocks.push({ kind: 'check', ok: true, text: m[1] });
continue;
}
if ((m = t.match(/^[✗✘×✕☒]\s*(.+)$/))) {
blocks.push({ kind: 'check', ok: false, text: m[1] });
continue;
}
if ((m = t.match(/^[-•*]\s+(.+)$/))) {
blocks.push({ kind: 'bullet', text: m[1] });
continue;
}
// Heading: short prefix before a colon whose first word is ALL-CAPS
// (DOCUMENTS, ELIGIBILITY, REFERRAL). Keeps mixed-case tails like
// "(ABSLI DigiShield Plan)" inside the heading.
const colon = t.indexOf(':');
if (colon > 1 && colon <= 60) {
const head = t.slice(0, colon).trim();
const firstWord = (head.split(/\s+/)[0] || '').replace(/[^A-Za-z]/g, '');
if (firstWord.length >= 2 && firstWord === firstWord.toUpperCase()) {
const inline = t.slice(colon + 1).trim();
blocks.push({ kind: 'heading', text: head, inline: inline || undefined });
continue;
}
}
blocks.push({ kind: 'para', text: t });
}
return blocks;
}
function EligibilityNotes({ notes, status }: { notes: string; status?: string | null }) {
const blocks = useMemo(() => parseNotes(notes), [notes]);
const structured = blocks.some((b) => b.kind !== 'para');
const s = (status ?? '').toLowerCase();
// On the navy card, status reads as a tinted text on a translucent chip.
const chipText =
s === 'eligible' ? 'text-emerald-300' : s === 'ineligible' ? 'text-ruby-500' : 'text-amber-300'; // refer / unknown
let first = true;
return (
<div className="bg-navy-grad rounded-lg px-5 py-[22px] shadow-md">
<div className="flex items-center justify-between gap-2 mb-3.5">
<div className="flex items-center gap-1.5 text-2xs font-bold uppercase tracking-[0.06em] text-on-navy-muted">
<Sparkles size={12} className="text-sunrise-300" />
Eligibility assessment · Aria Eligibility
</div>
{status && (
<span className={`shrink-0 rounded-pill bg-white/10 px-2 py-0.5 text-2xs font-bold ${chipText}`}>
{status}
</span>
)}
</div>
{structured ? (
<div className="flex flex-col gap-1.5">
{blocks.map((b, i) => {
if (b.kind === 'heading') {
const el = (
<div key={i} className={first ? '' : 'mt-3 pt-3 border-t border-white/10'}>
<div className="text-2xs font-bold uppercase tracking-[0.04em] text-on-navy-muted">{b.text}</div>
{b.inline && <div className="mt-1 text-sm text-on-navy leading-relaxed">{b.inline}</div>}
</div>
);
first = false;
return el;
}
if (b.kind === 'check') {
return (
<div key={i} className="flex items-start gap-2 text-sm text-on-navy">
{b.ok ? (
<CheckCircle2 size={15} className="mt-0.5 shrink-0 text-emerald-300" />
) : (
<AlertTriangle size={15} className="mt-0.5 shrink-0 text-amber-300" />
)}
<span>{b.text}</span>
</div>
);
}
if (b.kind === 'bullet') {
return (
<div key={i} className="flex items-start gap-2 text-sm text-on-navy">
<span className="mt-1.5 shrink-0 w-1 h-1 rounded-full bg-on-navy-muted" />
<span>{b.text}</span>
</div>
);
}
return (
<p key={i} className="text-sm text-on-navy leading-relaxed">
{b.text}
</p>
);
})}
</div>
) : (
// Fallback — render the model's output verbatim, line breaks preserved.
<p className="text-sm text-on-navy leading-relaxed whitespace-pre-line">{notes}</p>
)}
</div>
);
}

View File

@ -0,0 +1,132 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, KpiCard, LeadRow, LEAD_GRID, Pagination } from '../';
import { LeadFilters } from '../LeadFilters';
import { cn } from '../../lib/cn';
import { useLeads } from '../../api/leads';
import { recordToLead } from '../../api/adapters';
import { applyFilters, EMPTY_FILTER, type FilterState } from '../../api/filters';
import type { LeadRecord } from '../../api/types';
import { LeadActions, AddLeadButton } from './ActivityActions';
const PAGE_SIZE = 10;
/** Epoch ms for a recordview timestamp; 0 (oldest) when missing/unparseable. */
function ms(ts?: string | null): number {
if (!ts) return 0;
const t = new Date(ts).getTime();
return Number.isNaN(t) ? 0 : t;
}
/** A compact stat tile computed over this screen's (already state-filtered) rows. */
export interface TileSpec {
label: string;
value: (rows: LeadRecord[]) => string;
}
export interface WorklistProps {
/** Lifecycle states this screen lists (in display order). */
states: readonly string[];
emptyHint?: string;
showAddLead?: boolean;
/** A small set of stat tiles shown above the table (keep it to 2-4). */
tiles?: TileSpec[];
}
/** A state-scoped lead worklist styled like the Lead Pipeline screen: a KPI row
* + the shared leads table (LeadRow), with each row's one primary action
* (+ Disqualify) in its action cell, launched in a modal. */
export function Worklist({ states, emptyHint, showAddLead, tiles }: WorklistProps) {
const navigate = useNavigate();
const { records, fields, loading, refetch } = useLeads();
const [filters, setFilters] = useState<FilterState>(EMPTY_FILTER);
const allowed = useMemo(() => new Set(states), [states]);
// Scope to this screen's states, latest-first: most recently actioned
// (updated_at) at the top, instance_id as the tiebreaker. Sorting by state
// would bury a freshly-updated lead under older ones in an earlier state.
const scoped = useMemo(
() =>
records
.filter((r) => allowed.has(r.current_state_name ?? ''))
.sort((a, b) => ms(b.updated_at) - ms(a.updated_at) || Number(b.instance_id) - Number(a.instance_id)),
[records, allowed],
);
const recs = useMemo(() => applyFilters(scoped, filters, fields), [scoped, filters, fields]);
const rows = useMemo(() => recs.map((record) => ({ record, lead: recordToLead(record) })), [recs]);
// Client-side pagination over the filtered rows (all leads are already loaded
// via useLeads). Page is clamped so it stays valid after rows shrink.
const [page, setPage] = useState(1);
// Snap back to the first page whenever the filter set changes.
useEffect(() => setPage(1), [filters]);
const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const start = (safePage - 1) * PAGE_SIZE;
const pageRows = rows.slice(start, start + PAGE_SIZE);
return (
<div className="mx-auto flex w-full max-w-[1240px] flex-col gap-5">
{/* KPI row — Pipeline-screen tiles, width-capped so a couple don't stretch huge. */}
{tiles?.length ? (
<div className="grid grid-cols-2 gap-4 sm:flex sm:flex-wrap">
{tiles.map((t) => (
<KpiCard key={t.label} label={t.label} value={t.value(recs)} className="sm:w-[240px]" />
))}
</div>
) : null}
{/* Leads — shared table, with a per-row action cell. */}
<Card
title="Leads in queue"
pad={false}
action={showAddLead ? <AddLeadButton onDone={refetch} /> : undefined}
>
<LeadFilters
records={scoped}
fields={fields}
value={filters}
onChange={setFilters}
excludeKeys={['current_state_name']}
/>
{loading && !rows.length ? (
<div className="p-10 text-center text-sm text-faint">Loading leads</div>
) : (
<div className="overflow-x-auto">
<div className="min-w-[820px]">
<div
className={cn(
'grid gap-3 px-[18px] py-[11px] bg-slate-50 border-b border-border-subtle text-[10px] font-bold uppercase tracking-[0.06em] text-faint',
LEAD_GRID,
)}
>
<span>Lead</span>
<span>Score</span>
<span>Segment</span>
<span>Channel</span>
<span>Owner</span>
<span className="text-right">Action</span>
</div>
{rows.length ? (
pageRows.map(({ record, lead }) => (
<LeadRow
key={record.instance_id}
lead={lead}
onClick={() => navigate(`/leads/${record.instance_id}`)}
action={<LeadActions record={record} onDone={refetch} />}
/>
))
) : (
<div className="p-10 text-center text-sm text-faint">
{scoped.length ? 'No leads match these filters.' : emptyHint ?? 'No leads at this stage.'}
</div>
)}
</div>
</div>
)}
<Pagination page={safePage} pageSize={PAGE_SIZE} total={rows.length} onPage={setPage} />
</Card>
</div>
);
}

View File

@ -0,0 +1,6 @@
export { Worklist } from './Worklist';
export type { WorklistProps, TileSpec } from './Worklist';
export { LeadActions, AddLeadButton } from './ActivityActions';
export { STEP_BY_STATE, DISQUALIFY_STEP, CAPTURE_STEP } from './registry';
export type { ActivityStep } from './registry';
export type { ActivityBodyProps } from './types';

View File

@ -0,0 +1,55 @@
// Per-state action registry. Maps a lead's current_state_name → the single
// primary activity available on it: button label, modal title/width, and the
// body to render. Bespoke steps render a hand-built *Body; generic steps fall
// back to <ActivityForm> driven by the live schema (meta carries the uid).
import type { ComponentType } from 'react';
import type { ModalWidth } from '../core/Modal';
import { ACTIVITY_META, type ActivityMeta } from '../../api/forms';
import type { ActivityBodyProps } from './types';
import { AssignBody } from './AssignBody';
import { RecommendBody } from './RecommendBody';
import { DocumentsBody } from './DocumentsBody';
import { UnderwritingBody } from './UnderwritingBody';
import { IssuePolicyBody } from './IssuePolicyBody';
export interface ActivityStep {
/** Row button + modal title copy. */
buttonLabel: string;
title: string;
width: ModalWidth;
/** Bespoke body — when set, rendered instead of the generic ActivityForm. */
Body?: ComponentType<ActivityBodyProps>;
/** Generic path — fed to <ActivityForm>. Carries the activity uid. */
meta?: ActivityMeta;
}
/** The one primary action per lifecycle state. Absent state = no primary action. */
export const STEP_BY_STATE: Record<string, ActivityStep> = {
'New Lead': { buttonLabel: 'Qualify', title: 'Qualify Lead', width: 'md', meta: ACTIVITY_META.qualify },
Qualified: { buttonLabel: 'Assign', title: 'Assign Lead', width: 'lg', Body: AssignBody },
Assigned: { buttonLabel: 'Log Contact', title: 'Log First Contact', width: 'md', meta: ACTIVITY_META.contact },
Contacted: { buttonLabel: 'Schedule', title: 'Schedule Meeting', width: 'md', meta: ACTIVITY_META.schedule },
'Meeting Scheduled': { buttonLabel: 'Recommend', title: 'Recommend Product', width: 'xl', Body: RecommendBody },
'Product Recommended': { buttonLabel: 'Collect Docs', title: 'Collect Documents', width: 'xl', Body: DocumentsBody },
'Documents Collected': { buttonLabel: 'Assess', title: 'Assess Eligibility', width: 'md', meta: ACTIVITY_META.assess },
'Eligibility Assessed': { buttonLabel: 'Underwrite', title: 'Submit Underwriting', width: 'lg', Body: UnderwritingBody },
Underwriting: { buttonLabel: 'Issue Policy', title: 'Issue Policy', width: 'lg', Body: IssuePolicyBody },
'Policy Issued': { buttonLabel: 'Onboard', title: 'Onboard Customer', width: 'md', meta: ACTIVITY_META.onboard },
};
/** Secondary action — Disqualify, valid from any non-terminal pre-issuance state. */
export const DISQUALIFY_STEP: ActivityStep = {
buttonLabel: 'Disqualify',
title: 'Disqualify Lead',
width: 'md',
meta: ACTIVITY_META.disqualify,
};
/** INIT action — Add Lead (Capture), submitted via /start (no instance yet). */
export const CAPTURE_STEP: ActivityStep = {
buttonLabel: 'Add Lead',
title: 'Capture Lead',
width: 'lg',
meta: ACTIVITY_META.capture,
};

View File

@ -0,0 +1,14 @@
import type { ActivityResult, LeadRecord } from '../../api/types';
/** Contract every activity body conforms to. A body renders ONE activity's
* form (bespoke layout, fields fed from the live schema), submits via
* performActivity for the given instance, and calls onSuccess on success.
* It owns NO page chrome, lead-picker, or routing the Modal + list screen do.
*/
export interface ActivityBodyProps {
instanceId: number | string;
/** Seed values from the row's recordview data. */
record?: LeadRecord | null;
/** Fired after a successful submit — the host closes the modal + refetches. */
onSuccess?: (res: ActivityResult) => void;
}

View File

@ -0,0 +1,83 @@
import type { CSSProperties } from 'react';
import { Lightbulb, Phone, ShieldCheck, Sparkles, type LucideIcon } from 'lucide-react';
import { cn } from '../../lib/cn';
export interface AvatarProps {
name?: string;
src?: string;
/** Diameter in px. @default 40 */
size?: number;
/** Render the AI-employee sunrise glyph instead of initials. @default false */
ai?: boolean;
className?: string;
style?: CSSProperties;
}
const COLORS = ['#F26B3A', '#0E9466', '#2480DB', '#7C5BD6', '#D98512', '#D63A52'];
function hashIdx(s = ''): number {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return h % COLORS.length;
}
function initials(name = ''): string {
const p = name.trim().split(/\s+/);
return ((p[0]?.[0] || '') + (p[1]?.[0] || '')).toUpperCase();
}
// Each AI employee does something distinct, so each gets its own glyph + glow
// instead of one shared orange bot. Matched by name; unrecognised AI actors
// (and plain "Aria", the Qualifier) fall back to the sunrise Sparkles look.
type AiPersona = { Icon: LucideIcon; bg: string; shadow: string; color: string };
const AI_PERSONAS: Array<{ match: RegExp } & AiPersona> = [
{ match: /engage/i, Icon: Phone, bg: 'linear-gradient(135deg,#19C2A8,#0E9466)', shadow: '0 2px 10px rgba(14,148,102,.45)', color: '#0E9466' },
{ match: /underwrite/i, Icon: ShieldCheck, bg: 'linear-gradient(135deg,#4F9BF5,#2480DB)', shadow: '0 2px 10px rgba(36,128,219,.45)', color: '#2480DB' },
{ match: /advisor/i, Icon: Lightbulb, bg: 'linear-gradient(135deg,#9B7BE8,#7C5BD6)', shadow: '0 2px 10px rgba(124,91,214,.45)', color: '#7C5BD6' },
];
function aiPersona(name: string): AiPersona | null {
return AI_PERSONAS.find((p) => p.match.test(name)) ?? null;
}
/** Solid accent color for an AI employee, matched by name. Falls back to the
* shared sunrise orange for unrecognised actors (and plain "Aria"). */
export function aiEmployeeColor(name = ''): string {
return aiPersona(name)?.color ?? 'var(--sunrise-600, #F26B3A)';
}
/** Circular avatar with deterministic color from name; supports AI-employee glyph. */
export function Avatar({ name = '', src, size = 40, ai = false, className, style }: AvatarProps) {
const dim: CSSProperties = { width: size, height: size, fontSize: Math.round(size * 0.4) };
if (src) {
return (
<img
src={src}
alt={name}
className={cn('rounded-full object-cover', className)}
style={{ ...dim, ...style }}
/>
);
}
const persona = ai ? aiPersona(name) : null;
const AiIcon = persona?.Icon ?? Sparkles;
return (
<span
className={cn(
'rounded-full inline-flex items-center justify-center font-sans font-bold text-white shrink-0',
ai && !persona && 'bg-sunrise shadow-sunrise',
className,
)}
style={{
...dim,
background: ai ? persona?.bg : COLORS[hashIdx(name)],
boxShadow: persona?.shadow,
...style,
}}
>
{ai ? <AiIcon size={Math.round(size * 0.46)} /> : initials(name)}
</span>
);
}

View File

@ -0,0 +1,51 @@
import type { ReactNode } from 'react';
import { cn } from '../../lib/cn';
export type BadgeTone =
| 'neutral'
| 'navy'
| 'success'
| 'warning'
| 'danger'
| 'info'
| 'accent';
export interface BadgeProps {
children?: ReactNode;
/** @default "neutral" */
tone?: BadgeTone;
/** Show a leading status dot. @default false */
dot?: boolean;
/** @default "md" */
size?: 'sm' | 'md';
className?: string;
}
const TONES: Record<BadgeTone, { wrap: string; dot: string }> = {
neutral: { wrap: 'bg-slate-100 text-slate-700', dot: 'bg-slate-500' },
navy: { wrap: 'bg-navy-900 text-white', dot: 'bg-sunrise-400' },
success: { wrap: 'bg-emerald-100 text-emerald-700', dot: 'bg-emerald-600' },
warning: { wrap: 'bg-amber-100 text-amber-700', dot: 'bg-amber-600' },
danger: { wrap: 'bg-ruby-100 text-ruby-700', dot: 'bg-ruby-600' },
info: { wrap: 'bg-sky-100 text-sky-700', dot: 'bg-sky-600' },
accent: { wrap: 'bg-sunrise-100 text-sunrise-600', dot: 'bg-sunrise-500' },
};
/** Compact status pill with optional leading dot. */
export function Badge({ children, tone = 'neutral', dot = false, size = 'md', className }: BadgeProps) {
const t = TONES[tone];
const sm = size === 'sm';
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-pill font-sans font-semibold leading-none whitespace-nowrap',
sm ? 'text-2xs px-2 py-1' : 'text-xs px-2.5 py-[5px]',
t.wrap,
className,
)}
>
{dot && <span className={cn('w-1.5 h-1.5 rounded-full', t.dot)} />}
{children}
</span>
);
}

View File

@ -0,0 +1,65 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { cn } from '../../lib/cn';
export type ButtonVariant = 'primary' | 'navy' | 'secondary' | 'ghost' | 'danger';
export type ButtonSize = 'sm' | 'md' | 'lg';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Visual style. @default "primary" */
variant?: ButtonVariant;
/** @default "md" */
size?: ButtonSize;
iconLeft?: ReactNode;
iconRight?: ReactNode;
/** Stretch to container width. @default false */
full?: boolean;
}
const SIZES: Record<ButtonSize, string> = {
sm: 'h-[34px] px-3.5 text-[13px] rounded-sm',
md: 'h-[42px] px-[18px] text-base rounded-md',
lg: 'h-[50px] px-6 text-md rounded-md',
};
const VARIANTS: Record<ButtonVariant, string> = {
primary: 'bg-sunrise text-white border border-transparent shadow-sunrise',
navy: 'bg-navy-900 text-on-navy border border-transparent shadow-sm',
secondary: 'bg-card text-strong border border-border-default shadow-xs',
ghost: 'bg-transparent text-body border border-transparent',
danger: 'bg-ruby-600 text-white border border-transparent shadow-sm',
};
/**
* Aria primary action button. Sunrise gradient for primary,
* solid navy for secondary, quiet ghost for tertiary.
*/
export function Button({
children,
variant = 'primary',
size = 'md',
iconLeft,
iconRight,
full = false,
className,
...rest
}: ButtonProps) {
return (
<button
className={cn(
'inline-flex items-center justify-center gap-2 font-sans font-semibold leading-none',
'cursor-pointer transition-[transform,filter,box-shadow] duration-150',
'hover:brightness-[0.98] active:scale-[0.97]',
'disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100',
SIZES[size],
VARIANTS[variant],
full ? 'w-full' : 'w-auto',
className,
)}
{...rest}
>
{iconLeft}
{children}
{iconRight}
</button>
);
}

View File

@ -0,0 +1,58 @@
import type { CSSProperties, ReactNode } from 'react';
import { cn } from '../../lib/cn';
export type Elevation = 'none' | 'xs' | 'sm' | 'md' | 'lg';
export interface CardProps {
children?: ReactNode;
/** Optional header title. */
title?: ReactNode;
/** Optional header trailing node (button, menu). */
action?: ReactNode;
/** Apply body padding. @default true */
pad?: boolean;
/** @default "sm" */
elevation?: Elevation;
className?: string;
bodyClassName?: string;
style?: CSSProperties;
}
const SHADOW: Record<Elevation, string> = {
none: 'shadow-none',
xs: 'shadow-xs',
sm: 'shadow-sm',
md: 'shadow-md',
lg: 'shadow-lg',
};
/** Surface container with soft elevation. Optional header & padding control. */
export function Card({
children,
title,
action,
pad = true,
elevation = 'sm',
className,
bodyClassName,
style,
}: CardProps) {
return (
<section
className={cn(
'bg-card rounded-lg border border-border-subtle overflow-hidden font-sans',
SHADOW[elevation],
className,
)}
style={style}
>
{title && (
<header className="flex items-center justify-between px-5 py-4 border-b border-border-subtle">
<h3 className="m-0 text-md font-semibold text-strong">{title}</h3>
{action}
</header>
)}
<div className={cn(pad ? 'p-5' : 'p-0', bodyClassName)}>{children}</div>
</section>
);
}

View File

@ -0,0 +1,60 @@
import type { InputHTMLAttributes, ReactNode } from 'react';
import { cn } from '../../lib/cn';
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'prefix'> {
label?: string;
hint?: string;
error?: string;
/** Leading adornment, e.g. "₹". */
prefix?: ReactNode;
/** Trailing adornment, e.g. "/ year". */
suffix?: ReactNode;
/** Class for the outer label wrapper. */
className?: string;
}
/** Labeled text/number input with optional prefix, suffix, hint and error. */
export function Input({
label,
hint,
error,
prefix,
suffix,
required,
className,
disabled,
...rest
}: InputProps) {
return (
<label className={cn('flex flex-col gap-1.5 font-sans', disabled && 'cursor-not-allowed', className)}>
{label && (
<span className="text-sm font-medium text-muted">
{label}
{required && <span className="text-ruby-600"> *</span>}
</span>
)}
<div
className={cn(
'flex items-center gap-2 rounded-md px-3 h-[42px] border transition-[border-color,box-shadow] duration-150',
disabled ? 'bg-sunk border-border-subtle' : 'bg-card',
error ? 'border-ruby-600' : !disabled && 'border-border-default focus-ring',
)}
>
{prefix && <span className={cn('text-base font-semibold', disabled ? 'text-faint' : 'text-faint')}>{prefix}</span>}
<input
required={required}
disabled={disabled}
className={cn(
'flex-1 w-full border-none outline-none bg-transparent font-sans text-base',
disabled ? 'text-muted cursor-not-allowed' : 'text-strong',
)}
{...rest}
/>
{suffix && <span className="text-faint text-sm">{suffix}</span>}
</div>
{(hint || error) && (
<span className={cn('text-xs', error ? 'text-ruby-600' : 'text-faint')}>{error || hint}</span>
)}
</label>
);
}

View File

@ -0,0 +1,81 @@
import { useEffect, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { cn } from '../../lib/cn';
export type ModalWidth = 'sm' | 'md' | 'lg' | 'xl';
const WIDTH: Record<ModalWidth, string> = {
sm: 'max-w-[480px]',
md: 'max-w-[640px]',
lg: 'max-w-[820px]',
xl: 'max-w-[1000px]',
};
export interface ModalProps {
open: boolean;
onClose: () => void;
title?: ReactNode;
subtitle?: ReactNode;
/** @default "md" */
width?: ModalWidth;
children?: ReactNode;
}
/** Portal modal host — backdrop, Esc / click-out close, scroll-locked body.
* Aria's equivalent of the renderer's FormPopup; hosts an activity body. */
export function Modal({ open, onClose, title, subtitle, width = 'md', children }: ModalProps) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = prev;
};
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div
className="fixed inset-0 z-[10000] flex items-start justify-center overflow-y-auto bg-black/40 p-4 sm:p-8"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
role="dialog"
aria-modal="true"
className={cn(
// Capped well below the viewport so the box stays a contained dialog
// (visible backdrop margin) — the body scrolls internally rather than
// the whole dialog growing with content and taking over the screen.
'w-full bg-card rounded-lg border border-border-subtle shadow-lg my-auto',
'flex flex-col max-h-[85vh]',
WIDTH[width],
)}
>
<header className="shrink-0 flex items-start justify-between gap-3 px-5 py-4 border-b border-border-subtle">
<div className="min-w-0">
{title && <h3 className="m-0 text-md font-semibold text-strong truncate">{title}</h3>}
{subtitle && <div className="text-xs text-faint mt-0.5">{subtitle}</div>}
</div>
<button
onClick={onClose}
aria-label="Close"
className="shrink-0 -mr-1 -mt-1 p-1.5 rounded-md text-faint hover:text-strong hover:bg-black/5"
>
<X size={18} />
</button>
</header>
<div className="flex-1 min-h-0 overflow-y-auto p-5 scrollbar-slim">{children}</div>
</div>
</div>,
document.body,
);
}

View File

@ -0,0 +1,109 @@
import { useState } from 'react';
import { Check, Plus } from 'lucide-react';
import { cn } from '../../lib/cn';
export interface MultiSelectOption {
value: string;
label: string;
}
export interface MultiSelectProps {
label?: string;
options?: Array<string | MultiSelectOption>;
/** Controlled selected values. */
value?: string[];
defaultValue?: string[];
onChange?: (next: string[]) => void;
className?: string;
/** Render chips as read-only (no toggling) and show `hint` instead of being editable. */
disabled?: boolean;
/** Small muted helper line — used for the disabled-state explanation. */
hint?: string;
}
/** Chip-based multiselect. Click options to toggle; selected render as filled chips. */
export function MultiSelect({
label,
options = [],
value,
defaultValue = [],
onChange,
className,
disabled = false,
hint,
}: MultiSelectProps) {
const controlled = value !== undefined;
const [internal, setInternal] = useState<string[]>(defaultValue);
const selected = controlled ? value : internal;
const set = (next: string[]) => {
if (!controlled) setInternal(next);
onChange?.(next);
};
const toggle = (v: string) =>
set(selected.includes(v) ? selected.filter((x) => x !== v) : [...selected, v]);
return (
<div className={cn('flex flex-col gap-1.5 font-sans', className)}>
{label && <span className="text-sm font-medium text-muted">{label}</span>}
{disabled ? (
// Read-only: chips don't toggle and a hint explains why (no product yet,
// or the plan offers no riders). Muted so it reads as inactive.
<div className="flex flex-col gap-1.5">
{options.length > 0 && (
<div className="flex flex-wrap gap-2 opacity-60">
{options.map((o) => {
const val = typeof o === 'string' ? o : o.value;
const lab = typeof o === 'string' ? o : o.label;
const on = selected.includes(val);
return (
<span
key={val}
className={cn(
'inline-flex items-center gap-1.5 px-3 py-[7px] rounded-pill font-sans text-sm font-semibold cursor-default border',
on
? 'bg-sunrise-100 text-sunrise-600 border-transparent'
: 'bg-card text-body border-border-default',
)}
>
<span className={cn('flex', on ? 'opacity-100' : 'opacity-40')}>
{on ? <Check size={13} /> : <Plus size={13} />}
</span>
{lab}
</span>
);
})}
</div>
)}
{hint && <span className="text-xs text-faint">{hint}</span>}
</div>
) : (
<div className="flex flex-wrap gap-2">
{options.map((o) => {
const val = typeof o === 'string' ? o : o.value;
const lab = typeof o === 'string' ? o : o.label;
const on = selected.includes(val);
return (
<button
key={val}
type="button"
onClick={() => toggle(val)}
className={cn(
'inline-flex items-center gap-1.5 px-3 py-[7px] rounded-pill font-sans text-sm font-semibold cursor-pointer border transition-all duration-150',
on
? 'bg-sunrise-100 text-sunrise-600 border-transparent'
: 'bg-card text-body border-border-default',
)}
>
<span className={cn('flex', on ? 'opacity-100' : 'opacity-40')}>
{on ? <Check size={13} /> : <Plus size={13} />}
</span>
{lab}
</button>
);
})}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,41 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from './Button';
export interface PaginationProps {
/** 1-based current page. */
page: number;
pageSize: number;
/** Total rows across all pages (already filtered). */
total: number;
onPage: (page: number) => void;
}
/** Client-side pager: "Showing ab of N" + Prev/Next. Renders nothing when the
* set fits on one page. Shared by the Pipeline table and the worklists. */
export function Pagination({ page, pageSize, total, onPage }: PaginationProps) {
if (total <= pageSize) return null;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const safePage = Math.min(Math.max(page, 1), totalPages);
const start = (safePage - 1) * pageSize;
return (
<div className="flex items-center justify-between gap-3 border-t border-border-subtle px-[18px] py-3">
<span className="text-xs text-faint">
Showing {start + 1}{Math.min(start + pageSize, total)} of {total}
</span>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" disabled={safePage <= 1} onClick={() => onPage(safePage - 1)}>
<ChevronLeft size={15} />
Prev
</Button>
<span className="px-1 text-xs font-medium text-muted nums">
{safePage} / {totalPages}
</span>
<Button variant="secondary" size="sm" disabled={safePage >= totalPages} onClick={() => onPage(safePage + 1)}>
Next
<ChevronRight size={15} />
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,182 @@
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { ChevronDown, X } from 'lucide-react';
import { cn } from '../../lib/cn';
export interface PickerShellProps<T> {
label?: string;
required?: boolean;
hint?: string;
/** Class for the outer label wrapper (width etc.). */
className?: string;
/** Text shown in the trigger for the current selection ('' = nothing picked). */
displayLabel: string;
placeholder?: string;
/** Rows shown in the open panel (already filtered/fetched by the caller). */
items: T[];
renderItem: (item: T) => ReactNode;
itemKey: (item: T, i: number) => string | number;
onPick: (item: T) => void;
/** When provided and something is selected, a clear (✕) button shows. */
onClear?: () => void;
/** Show the search box (default true). */
searchable?: boolean;
search: string;
onSearch: (s: string) => void;
loading?: boolean;
emptyText?: string;
/** Notifies the caller so it can fetch on open / reset on close. */
onOpenChange?: (open: boolean) => void;
disabled?: boolean;
}
/** Shared searchable-picker chrome: trigger pill + portal dropdown with an
* optional search box and a scrollable option list. Both Select (static
* options) and LookupField (remote rows) render through this so the two are
* visually identical one picker UI everywhere. The panel lives in a portal
* so a Card's `overflow-hidden` can't clip it. */
export function PickerShell<T>({
label,
required,
hint,
className,
displayLabel,
placeholder = 'Select…',
items,
renderItem,
itemKey,
onPick,
onClear,
searchable = true,
search,
onSearch,
loading,
emptyText = 'No matches.',
onOpenChange,
disabled,
}: PickerShellProps<T>) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null);
const toggle = (next: boolean) => {
if (disabled) return;
setOpen(next);
onOpenChange?.(next);
};
// Anchor the portal panel under the trigger; track scroll/resize while open.
useLayoutEffect(() => {
if (!open) return;
const place = () => {
const el = triggerRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
setPos({ top: r.bottom + 4, left: r.left, width: r.width });
};
place();
window.addEventListener('scroll', place, true);
window.addEventListener('resize', place);
return () => {
window.removeEventListener('scroll', place, true);
window.removeEventListener('resize', place);
};
}, [open]);
// Close on outside click — ignore clicks inside the trigger or the portal panel.
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
const t = e.target as Node;
if (triggerRef.current?.contains(t) || panelRef.current?.contains(t)) return;
toggle(false);
};
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
return (
<label className={cn('flex flex-col gap-1.5 font-sans', className)}>
{label && (
<span className="text-sm font-medium text-muted">
{label}
{required && <span className="text-ruby-600"> *</span>}
</span>
)}
<div
ref={triggerRef}
className={cn(
'flex items-center gap-2 bg-card rounded-md px-3 h-[42px] border border-border-default focus-ring',
disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
)}
onClick={() => toggle(!open)}
>
<span className={cn('flex-1 truncate text-base', displayLabel ? 'text-strong' : 'text-faint')}>
{displayLabel || placeholder}
</span>
{onClear && displayLabel ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClear();
}}
className="text-faint hover:text-strong"
>
<X size={15} />
</button>
) : (
<ChevronDown size={15} className="text-faint" />
)}
</div>
{hint && <span className="text-xs text-faint">{hint}</span>}
{open &&
pos &&
createPortal(
<div
ref={panelRef}
style={{ position: 'fixed', top: pos.top, left: pos.left, width: pos.width, zIndex: 10050 }}
className="bg-card border border-border-default rounded-md shadow-lg overflow-hidden"
>
{searchable && (
<div className="p-2 border-b border-border-subtle">
<input
autoFocus
value={search}
onChange={(e) => onSearch(e.target.value)}
placeholder="Search…"
className="w-full h-9 px-2.5 border border-border-default rounded-md outline-none text-sm focus:border-sunrise-500"
/>
</div>
)}
<div className="max-h-56 overflow-auto">
{loading ? (
<div className="px-3 py-3 text-sm text-faint">Searching</div>
) : items.length ? (
items.map((item, i) => (
<button
type="button"
key={itemKey(item, i)}
onClick={() => {
onPick(item);
toggle(false);
}}
className="w-full text-left px-3 py-2.5 text-sm text-body hover:bg-slate-50 border-none bg-transparent cursor-pointer"
>
{renderItem(item)}
</button>
))
) : (
<div className="px-3 py-3 text-sm text-faint">{emptyText}</div>
)}
</div>
</div>,
document.body,
)}
</label>
);
}

View File

@ -0,0 +1,50 @@
import type { SelectHTMLAttributes } from 'react';
import { ChevronDown } from 'lucide-react';
import { cn } from '../../lib/cn';
export interface SelectOption {
value: string;
label: string;
}
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
hint?: string;
/** Either strings or {value,label} objects. */
options?: Array<string | SelectOption>;
/** Class for the outer label wrapper. */
className?: string;
}
/** Labeled native select styled to match Input. */
export function Select({ label, hint, options = [], required, className, ...rest }: SelectProps) {
return (
<label className={cn('flex flex-col gap-1.5 font-sans', className)}>
{label && (
<span className="text-sm font-medium text-muted">
{label}
{required && <span className="text-ruby-600"> *</span>}
</span>
)}
<div className="relative bg-card rounded-md h-[42px] border border-border-default transition-[border-color,box-shadow] duration-150 focus-ring">
<select
required={required}
className="w-full h-full border-none outline-none bg-transparent appearance-none pl-3 pr-9 font-sans text-base text-strong cursor-pointer"
{...rest}
>
{options.map((o) => {
const val = typeof o === 'string' ? o : o.value;
const lab = typeof o === 'string' ? o : o.label;
return (
<option key={val} value={val}>
{lab}
</option>
);
})}
</select>
<ChevronDown size={15} className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-faint" />
</div>
{hint && <span className="text-xs text-faint">{hint}</span>}
</label>
);
}

View File

@ -0,0 +1,91 @@
import { useMemo, useState } from 'react';
import { PickerShell } from './PickerShell';
export interface SelectFieldOption {
value: string;
label: string;
}
export interface SelectFieldProps {
label?: string;
required?: boolean;
hint?: string;
className?: string;
/** Either strings or {value,label} objects. */
options?: Array<string | SelectFieldOption>;
value: string;
onChange: (value: string) => void;
placeholder?: string;
/** Force the search box on/off. Defaults to on when there are >7 options. */
searchable?: boolean;
/** Allow clearing back to ''. Defaults to true for non-required fields. */
clearable?: boolean;
disabled?: boolean;
}
const norm = (o: string | SelectFieldOption): SelectFieldOption =>
typeof o === 'string' ? { value: o, label: o } : o;
/** Single-select that renders through the shared PickerShell, so it matches
* LookupField exactly. Static options, searched/filtered locally. An empty-
* value option (e.g. {value:'', label:'Choose…'}) is treated as the
* placeholder rather than a list row, and option values are de-duplicated. */
export function SelectField({
label,
required,
hint,
className,
options = [],
value,
onChange,
placeholder,
searchable,
clearable,
disabled,
}: SelectFieldProps) {
const [search, setSearch] = useState('');
// Normalize, lift any empty-value option's label to the placeholder, dedupe.
const { opts, ph } = useMemo(() => {
const all = options.map(norm);
const empty = all.find((o) => o.value === '');
const seen = new Set<string>();
const opts = all.filter((o) => {
if (o.value === '' || seen.has(o.value)) return false;
seen.add(o.value);
return true;
});
return { opts, ph: placeholder ?? empty?.label };
}, [options, placeholder]);
const items = useMemo(() => {
const q = search.trim().toLowerCase();
return q ? opts.filter((o) => o.label.toLowerCase().includes(q)) : opts;
}, [opts, search]);
const display = opts.find((o) => o.value === value)?.label ?? '';
const canSearch = searchable ?? opts.length > 7;
const canClear = (clearable ?? !required) && value !== '';
return (
<PickerShell<SelectFieldOption>
label={label}
required={required}
hint={hint}
className={className}
displayLabel={display}
placeholder={ph}
items={items}
renderItem={(o) => o.label}
itemKey={(o) => o.value}
onPick={(o) => onChange(o.value)}
onClear={canClear ? () => onChange('') : undefined}
searchable={canSearch}
search={search}
onSearch={setSearch}
onOpenChange={(open) => !open && setSearch('')}
emptyText="No options."
disabled={disabled}
/>
);
}

View File

@ -0,0 +1,22 @@
export { Button } from './Button';
export type { ButtonProps, ButtonVariant, ButtonSize } from './Button';
export { Card } from './Card';
export type { CardProps, Elevation } from './Card';
export { Badge } from './Badge';
export type { BadgeProps, BadgeTone } from './Badge';
export { Avatar, aiEmployeeColor } from './Avatar';
export type { AvatarProps } from './Avatar';
export { Input } from './Input';
export type { InputProps } from './Input';
export { Select } from './Select';
export type { SelectProps, SelectOption } from './Select';
export { SelectField } from './SelectField';
export type { SelectFieldProps, SelectFieldOption } from './SelectField';
export { PickerShell } from './PickerShell';
export type { PickerShellProps } from './PickerShell';
export { MultiSelect } from './MultiSelect';
export type { MultiSelectProps, MultiSelectOption } from './MultiSelect';
export { Modal } from './Modal';
export type { ModalProps, ModalWidth } from './Modal';
export { Pagination } from './Pagination';
export type { PaginationProps } from './Pagination';

View File

@ -0,0 +1,404 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { CheckCircle2, FileText, UploadCloud } from 'lucide-react';
import { Button } from '../core/Button';
import { Input } from '../core/Input';
import { SelectField } from '../core/SelectField';
import { MultiSelect } from '../core/MultiSelect';
import { LookupField } from './LookupField';
import type { LookupValue } from './LookupField';
import { DatasetSelectField } from './DatasetSelectField';
import { isPermissionError, SchemaGuard } from './SchemaGuard';
import { useQuery, useZino } from '../../api/provider';
import { schemaToFields } from '../../api/schema';
import type { ActivityMeta, FieldDef } from '../../api/forms';
import type { FieldRule, LeadRecord } from '../../api/types';
import type { ActivityResult } from '../../api/types';
export interface ActivityFormProps {
/** Activity display copy + uid; fields are fetched live from the schema. */
meta: ActivityMeta;
/** Omit for the INIT activity (Capture Lead) — submits via /start. */
instanceId?: number | string;
/** Seed values from an existing record. */
record?: LeadRecord | null;
onSuccess?: (res: ActivityResult) => void;
/** Override the green confirmation line. */
successMessage?: string;
}
// datetime-local ("YYYY-MM-DDTHH:MM") → RFC 3339 with local TZ offset, the
// format the workflow engine validates datetime fields against.
function toRfc3339(local: string): string {
const d = new Date(local);
if (Number.isNaN(d.getTime())) return local;
const pad = (n: number) => String(n).padStart(2, '0');
const off = -d.getTimezoneOffset();
const sign = off >= 0 ? '+' : '-';
const abs = Math.abs(off);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
}
function seedFor(field: FieldDef, record?: LeadRecord | null): unknown {
const key = field.seedKey ?? field.id.replace(/_input$/, '');
const fromRecord = record ? (record as Record<string, unknown>)[key] : undefined;
if (fromRecord !== undefined && fromRecord !== null) return fromRecord;
// Designer-supplied default (field_defaults.value) — lets a hidden field like
// preferred_language submit a fixed value (e.g. "english") with no UI.
if (field.prefill !== undefined && field.prefill !== null && field.prefill !== '') return field.prefill;
switch (field.type) {
case 'multiselect':
return [];
case 'boolean':
return false;
case 'file':
return null; // holds a File once picked; uploaded on submit
default:
return '';
}
}
/** Renders an activity's fields from the live form-screens schema and submits
* via performActivity (or startInstance when no instanceId is given). */
export function ActivityForm({ meta, instanceId, record, onSuccess, successMessage }: ActivityFormProps) {
const { client } = useZino();
const schemaQ = useQuery(
() => client.formSchema(meta.uid, instanceId),
[meta.uid, instanceId],
);
const fields = useMemo<FieldDef[]>(
() => (schemaQ.data ? schemaToFields(schemaQ.data) : []),
[schemaQ.data],
);
// Cascading rules (filter_options) — passed to dataset-backed selects so a
// dependent field (e.g. City) refetches its options when its source (State) changes.
const fieldRules = useMemo<FieldRule[]>(() => schemaQ.data?.field_rules ?? [], [schemaQ.data]);
const [values, setValues] = useState<Record<string, unknown>>({});
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
// Seed once the schema loads (and re-seed if the record changes).
useEffect(() => {
if (!fields.length) return;
const init: Record<string, unknown> = {};
fields.forEach((f) => (init[f.id] = seedFor(f, record)));
setValues(init);
}, [fields, record]);
const set = (id: string, v: unknown) => setValues((p) => ({ ...p, [id]: v }));
const missing = useMemo(
() =>
fields.filter((f) => {
if (!f.required || f.hidden) return false; // hidden fields are auto-seeded, not user-entered
const v = values[f.id];
if (Array.isArray(v)) return v.length === 0;
return v === '' || v === null || v === undefined;
}),
[fields, values],
);
async function submit() {
if (missing.length) {
setResult({ ok: false, msg: `Fill required: ${missing.map((m) => m.label).join(', ')}` });
return;
}
setSubmitting(true);
setResult(null);
try {
// Drop empty optional values so we don't overwrite with blanks.
const data: Record<string, unknown> = {};
for (const f of fields) {
const v = values[f.id];
if (v === '' || v === null || v === undefined) continue;
if (Array.isArray(v) && v.length === 0) continue;
if (f.type === 'file') {
if (!(v instanceof File)) continue;
const fileMeta = await client.uploadFile(v, { activityId: meta.uid, fieldId: f.id, instanceId });
data[f.id] = [fileMeta];
continue;
}
data[f.id] =
f.type === 'number' ? Number(v) || 0 : f.type === 'datetime' ? toRfc3339(String(v)) : v;
}
const res =
instanceId != null
? await client.performActivity(instanceId, meta.uid, data)
: await client.startInstance(meta.uid, data);
setResult({ ok: true, msg: successMessage ?? `${meta.name} ${meta.done}.` });
onSuccess?.(res);
} catch (e) {
const raw = e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed');
setResult({
ok: false,
msg: isPermissionError(raw) ? `You dont have permission to ${meta.name.toLowerCase()}.` : raw,
});
} finally {
setSubmitting(false);
}
}
return (
<SchemaGuard
loading={schemaQ.loading}
error={schemaQ.error}
empty={!fields.length}
action={meta.name.toLowerCase()}
>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-4">
{fields.filter((f) => !f.hidden).map((f) => (
<Field
key={f.id}
field={f}
value={values[f.id]}
onChange={(v) => set(f.id, v)}
activityId={meta.uid}
instanceId={instanceId}
formData={values}
fieldRules={fieldRules}
/>
))}
</div>
{result && (
<div
className={
result.ok
? 'flex items-center gap-2 text-sm text-emerald-700 bg-emerald-100 border border-emerald-300 rounded-md px-4 py-3'
: 'text-sm text-amber-700 bg-escalated-soft border border-amber-300 rounded-md px-4 py-3'
}
>
{result.ok && <CheckCircle2 size={16} />}
{result.msg}
</div>
)}
<div>
<Button variant="primary" disabled={submitting || result?.ok} onClick={submit}>
{submitting ? 'Submitting…' : result?.ok ? 'Done' : meta.submitLabel}
</Button>
</div>
</div>
</SchemaGuard>
);
}
/** File picker for schema `file` fields. Holds the chosen File in form state;
* ActivityForm uploads it on submit and sends the returned [meta]. */
function FileInput({
label,
required,
value,
onChange,
className,
}: {
label: string;
required?: boolean;
value: unknown;
onChange: (f: File | null) => void;
className?: string;
}) {
const ref = useRef<HTMLInputElement>(null);
const file = value instanceof File ? value : null;
return (
<div className={className}>
<span className="text-sm font-medium text-muted">
{label}
{required && <span className="text-ruby-600"> *</span>}
</span>
<input
ref={ref}
type="file"
accept=".pdf,.png,.jpg,.jpeg"
className="hidden"
onChange={(e) => {
onChange(e.target.files?.[0] ?? null);
e.target.value = '';
}}
/>
<button
type="button"
onClick={() => ref.current?.click()}
className="mt-1.5 w-full flex items-center gap-2.5 border border-dashed border-border-default rounded-md bg-slate-50 px-3.5 py-3 cursor-pointer text-left"
>
{file ? (
<FileText size={18} className="text-sunrise-600 shrink-0" />
) : (
<UploadCloud size={18} className="text-sunrise-500 shrink-0" />
)}
<span className="text-sm text-body truncate flex-1">
{file ? file.name : 'Upload file (PDF / image)'}
</span>
{file && (
<span
onClick={(e) => {
e.stopPropagation();
onChange(null);
}}
className="text-2xs font-semibold text-link"
>
Remove
</span>
)}
</button>
</div>
);
}
function Field({
field,
value,
onChange,
activityId,
instanceId,
formData,
fieldRules,
}: {
field: FieldDef;
value: unknown;
onChange: (v: unknown) => void;
activityId: string;
instanceId?: number | string;
formData?: Record<string, unknown>;
fieldRules?: FieldRule[];
}) {
const full =
field.type === 'longtext' ||
field.type === 'multiselect' ||
field.type === 'lookup' ||
field.type === 'file';
const wrap = full ? 'col-span-full' : '';
switch (field.type) {
case 'file':
return (
<FileInput
className={wrap}
label={field.label}
required={field.required}
value={value}
onChange={onChange}
/>
);
case 'longtext':
return (
<label className={`${wrap} flex flex-col gap-1.5`}>
<span className="text-sm font-medium text-muted">
{field.label}
{field.required && <span className="text-ruby-600"> *</span>}
</span>
<textarea
rows={3}
value={String(value ?? '')}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
className="font-sans text-base text-strong border border-border-default rounded-md px-3 py-2.5 resize-y outline-none focus:border-sunrise-500"
/>
</label>
);
case 'select':
// Dataset-backed select fetches its options live (filter_options-aware);
// static selects render from the inline options.
if (field.optionSource === 'dataset' && field.datasetUuid) {
return (
<DatasetSelectField
label={field.label}
required={field.required}
value={String(value ?? '')}
onChange={onChange}
activityId={activityId}
fieldId={field.id}
instanceId={instanceId}
formData={formData}
fieldRules={fieldRules}
placeholder={field.required ? 'Select…' : '— None —'}
/>
);
}
return (
<SelectField
label={field.label}
required={field.required}
value={String(value ?? '')}
onChange={onChange}
options={field.options ?? []}
placeholder={field.required ? 'Select…' : '— None —'}
/>
);
case 'multiselect':
return (
<div className={wrap}>
<MultiSelect
label={field.label}
value={Array.isArray(value) ? (value as string[]) : []}
onChange={onChange}
options={field.options ?? []}
/>
</div>
);
case 'boolean':
return (
<label className="flex items-center gap-2.5 h-[42px] mt-6 cursor-pointer select-none">
<input
type="checkbox"
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
className="w-4 h-4 accent-sunrise-500"
/>
<span className="text-sm font-medium text-body">{field.label}</span>
</label>
);
case 'lookup':
return (
<div className={wrap}>
<LookupField
label={field.label}
required={field.required}
templateUid={field.lookupTemplate!}
displayCols={field.lookupDisplay ?? []}
storageCols={field.lookupStorage ?? []}
activityId={activityId}
fieldId={field.id}
instanceId={instanceId}
value={(value as LookupValue) ?? null}
onChange={onChange}
formData={formData}
/>
</div>
);
default: {
const htmlType =
field.type === 'number'
? 'number'
: field.type === 'date'
? 'date'
: field.type === 'datetime'
? 'datetime-local'
: field.type === 'email'
? 'email'
: field.type === 'phone'
? 'tel'
: 'text';
return (
<Input
label={field.label}
required={field.required}
type={htmlType}
prefix={field.prefix}
placeholder={field.placeholder}
value={String(value ?? '')}
onChange={(e) => onChange(e.target.value)}
/>
);
}
}
}

View File

@ -0,0 +1,110 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { SelectField, type SelectFieldOption } from '../core/SelectField';
import { useZino } from '../../api/provider';
import type { FieldRule } from '../../api/types';
export interface DatasetSelectFieldProps {
label?: string;
required?: boolean;
value: string;
onChange: (v: string) => void;
activityId: string;
fieldId: string;
instanceId?: number | string;
/** Current form values sent to /dataset-options so the server applies the
* activity's filter_options (e.g. City options scoped to the chosen State). */
formData?: Record<string, unknown>;
/** Activity field_rules used to find which fields this one depends on, so we
* only refetch when a dependency value changes (not on every keystroke). */
fieldRules?: FieldRule[];
placeholder?: string;
}
/** The form fields whose values drive this field's filter_options. */
function depFieldsFor(rules: FieldRule[] | undefined, fieldId: string): string[] {
const out = new Set<string>();
for (const r of rules ?? []) {
if (r.action !== 'filter_options' || r.active === false || r.target_field !== fieldId) continue;
for (const c of r.filter_config?.conditions ?? []) {
if (c.form_field) out.add(c.form_field);
}
}
return [...out];
}
/** Dataset-backed single-select. Fetches options from /dataset-options (the
* server applies the activity's filter_options against the dependency values we
* send), refetching when a dependency changes, and renders the shared
* SelectField so it looks identical to a static select. When a dependency
* changes and the current selection is no longer valid, it clears it. */
export function DatasetSelectField({
label,
required,
value,
onChange,
activityId,
fieldId,
instanceId,
formData,
fieldRules,
placeholder,
}: DatasetSelectFieldProps) {
const { client } = useZino();
const [options, setOptions] = useState<SelectFieldOption[]>([]);
const deps = useMemo(() => depFieldsFor(fieldRules, fieldId), [fieldRules, fieldId]);
// Stable key over the dependency VALUES — refetch only when these change.
const depKey = useMemo(
() => deps.map((f) => `${f}=${JSON.stringify(formData?.[f] ?? null)}`).join('&'),
[deps, formData],
);
// Send only the dependency values — enough to resolve filter_options server-side.
const depData = useMemo(() => {
const fd: Record<string, unknown> = {};
for (const f of deps) fd[f] = formData?.[f];
return fd;
}, [deps, formData]);
// Refs so stale-selection cleanup reads the latest value/onChange without
// re-running the fetch effect (which keys only on depKey).
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const valueRef = useRef(value);
valueRef.current = value;
const firstDepKey = useRef(depKey);
useEffect(() => {
let live = true;
client
.datasetOptions({ activityId, fieldId, instanceId, limit: 200, formData: depData })
.then((r) => {
if (!live) return;
const opts = (r.options ?? []).map((o) => ({ value: String(o.value), label: o.label }));
setOptions(opts);
// After a dependency actually CHANGED, drop a now-invalid selection
// (skip the initial load so a prefilled value isn't cleared).
if (depKey !== firstDepKey.current) {
const v = valueRef.current;
if (v && !opts.some((o) => o.value === String(v))) onChangeRef.current('');
}
})
.catch(() => {
if (live) setOptions([]);
});
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [client, activityId, fieldId, instanceId, depKey]);
return (
<SelectField
label={label}
required={required}
value={String(value ?? '')}
onChange={onChange}
options={options}
placeholder={placeholder}
/>
);
}

View File

@ -0,0 +1,134 @@
import { useEffect, useState } from 'react';
import { PickerShell } from '../core/PickerShell';
import { useZino } from '../../api/provider';
export interface LookupValue {
[col: string]: unknown;
}
export interface LookupFieldProps {
label?: string;
required?: boolean;
templateUid: string;
/** Columns joined to form the option label. */
displayCols: string[];
/** Columns persisted on the value object. */
storageCols: string[];
activityId: string;
fieldId: string;
instanceId?: number | string;
value: LookupValue | null;
onChange: (v: LookupValue | null) => void;
/** Current form values sent to the lookup endpoint so the server applies
* the activity's field_rules filter_options (cascading lookups, e.g. owner
* agents scoped to the picked region, regions scoped to the picked city).
* Keys MUST match the rules' `form_field`s; lookup values stay objects so
* the server can read a rule's `source_value_key` sub-column. */
formData?: Record<string, unknown>;
}
type Row = Record<string, unknown>;
/** Searchable RDBMS-lookup picker (owner agent etc.). Mirrors the renderer's
* FFLookupField: projects the picked row onto storage + display columns.
* Renders through the shared PickerShell so it's identical to SelectField. */
export function LookupField({
label,
required,
templateUid,
displayCols,
storageCols,
activityId,
fieldId,
instanceId,
value,
onChange,
formData,
}: LookupFieldProps) {
const { client } = useZino();
// Serialized so the effect refetches when a dependency value changes.
const formKey = JSON.stringify(formData ?? {});
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const [rows, setRows] = useState<Row[]>([]);
const [loading, setLoading] = useState(false);
const labelOf = (src: LookupValue | Row | null) =>
src
? displayCols
.map((c) => src[c])
.filter((v) => v !== null && v !== undefined && v !== '')
.join(', ')
: '';
// Collapse rows that project to the same stored value — e.g. many branches
// sharing one region show "North" once. Lookups whose storage carries a
// unique id never collapse.
const dedupe = (list: Row[]) => {
const cols = storageCols.length ? storageCols : displayCols;
const seen = new Set<string>();
return list.filter((row) => {
const sig = JSON.stringify(cols.map((c) => row[c] ?? null));
if (seen.has(sig)) return false;
seen.add(sig);
return true;
});
};
// Debounced fetch while the dropdown is open.
useEffect(() => {
if (!open) return;
let live = true;
setLoading(true);
const t = setTimeout(() => {
client
.lookupRecords(templateUid, { activityId, fieldId, instanceId, search, limit: 50, formData })
.then((r) => {
// The server scopes rows via filter_options using the form_data we
// sent (target columns like `city` aren't selected into the rows, so
// they can only be filtered server-side).
if (live) setRows(dedupe(r.records ?? []));
})
.catch(() => {
if (live) setRows([]);
})
.finally(() => {
if (live) setLoading(false);
});
}, 220);
return () => {
live = false;
clearTimeout(t);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, search, templateUid, activityId, fieldId, instanceId, client, formKey]);
function pick(row: Row) {
const stored: LookupValue = {};
storageCols.forEach((c) => (stored[c] = row[c] ?? null));
displayCols.forEach((c) => {
if (!(c in stored)) stored[c] = row[c] ?? null;
});
onChange(stored);
}
return (
<PickerShell<Row>
label={label}
required={required}
displayLabel={labelOf(value)}
items={rows}
renderItem={(row) => labelOf(row) || 'Row'}
itemKey={(_row, i) => i}
onPick={pick}
onClear={() => onChange(null)}
search={search}
onSearch={setSearch}
loading={loading}
onOpenChange={(o) => {
setOpen(o);
if (!o) setSearch('');
}}
/>
);
}

View File

@ -0,0 +1,51 @@
import type { ReactNode } from 'react';
import { Lock } from 'lucide-react';
/** True for a backend RBAC denial so we can show a human line, not the raw
* "user N does not have permission for activity <uuid>". */
export function isPermissionError(msg?: string | null): boolean {
return !!msg && /permission|not authoriz|unauthoriz|forbidden|\b403\b/i.test(msg);
}
interface SchemaGuardProps {
loading: boolean;
error: string | null;
/** Verb phrase for the denial copy, e.g. "collect documents for this lead". */
action: string;
/** True when the schema loaded but yielded no usable fields. */
empty?: boolean;
children: ReactNode;
}
/** Gate any activity body on its form-screens fetch. Until the schema resolves
* cleanly we never render the form: an RBAC denial (or any load error) shows a
* human-readable card instead of a live-looking form that 403s only on submit.
* Bespoke bodies and the generic <ActivityForm> share this so the guard can't
* drift out of sync between them. */
export function SchemaGuard({ loading, error, action, empty, children }: SchemaGuardProps) {
if (loading) {
return <div className="py-6 text-sm text-faint">Loading form</div>;
}
if (error || empty) {
if (isPermissionError(error)) {
return (
<div className="flex items-start gap-2.5 rounded-md border border-amber-200 bg-amber-50 px-4 py-3.5 text-sm text-amber-800">
<Lock size={16} className="mt-0.5 shrink-0 text-amber-600" />
<div>
<div className="font-semibold">You dont have permission for this action</div>
<div className="mt-0.5 text-xs text-amber-700">
Your role cant {action}. Ask an admin to grant your role access to this activity, or
sign in as a role that can.
</div>
</div>
</div>
);
}
return (
<div className="py-4 text-sm text-amber-700">
{error ? `Couldnt load the form: ${error}` : 'This activity has no input fields.'}
</div>
);
}
return <>{children}</>;
}

View File

@ -0,0 +1,4 @@
export * from './ActivityForm';
export * from './DatasetSelectField';
export * from './LookupField';
export * from './SchemaGuard';

4
src/components/index.ts Normal file
View File

@ -0,0 +1,4 @@
// Aria design-system components — import from a single entry point:
// import { Button, AIDecisionCard, RupeeAmount } from '@/components';
export * from './core';
export * from './insurance';

View File

@ -0,0 +1,140 @@
import { useState } from 'react';
import type { CSSProperties } from 'react';
import { ChevronRight, FileText } from 'lucide-react';
import { cn } from '../../lib/cn';
import { Avatar } from '../core/Avatar';
import { ConfidenceRing } from './ConfidenceRing';
import { ReasoningBody } from './ReasoningBody';
export type Citation = string | { title: string };
export interface AIDecisionCardProps {
employee?: string;
role?: string;
activity?: string;
/** Confidence 01. @default 0.85 */
confidence?: number;
summary?: string;
reasoning?: string | null;
citations?: Citation[];
time?: string | null;
defaultExpanded?: boolean;
/** Autonomy threshold. @default 0.7 */
threshold?: number;
className?: string;
style?: CSSProperties;
}
/**
* AI DECISION CARD Aria's signature component.
* Which AI employee acted, the activity, a confidence ring, a one-line
* reasoning summary, expandable full reasoning, and cited-knowledge chips.
*/
export function AIDecisionCard({
employee = 'Aria',
role = 'Lead Qualifier',
activity,
confidence = 0.85,
summary,
reasoning = null,
citations = [],
time = null,
defaultExpanded = false,
threshold = 0.7,
className,
style,
}: AIDecisionCardProps) {
const [open, setOpen] = useState(defaultExpanded);
const autonomous = confidence >= threshold;
const accent = autonomous ? 'var(--status-autonomous)' : 'var(--status-escalated)';
const accentSoft = autonomous ? 'var(--status-autonomous-soft)' : 'var(--status-escalated-soft)';
return (
<article
className={cn('relative bg-card rounded-lg border border-border-subtle shadow-md overflow-hidden font-sans', className)}
style={style}
>
{/* accent rail */}
<span
className={cn('absolute top-0 left-0 bottom-0 w-1', autonomous && 'bg-sunrise')}
style={autonomous ? undefined : { background: 'var(--status-escalated)' }}
/>
{/* header band — identity + confidence, tinted by autonomy state */}
<header
className="flex items-center gap-3 pl-[22px] pr-5 py-3.5 border-b border-border-subtle"
style={{ background: accentSoft }}
>
<Avatar name={employee} ai size={38} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-base font-bold text-strong leading-tight">{employee}</span>
<span className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint bg-card border border-border-subtle px-[7px] py-[3px] rounded-pill">
{role}
</span>
</div>
<div className="text-xs text-muted mt-0.5 truncate">
{activity}
{time && <span className="text-faint"> · {time}</span>}
</div>
</div>
<div className="shrink-0">
<ConfidenceRing value={confidence} size={64} threshold={threshold} />
</div>
</header>
<div className="pl-[22px] pr-5 py-[18px]">
{/* status pill */}
<div
className="inline-flex items-center gap-1.5 text-2xs font-bold tracking-[0.03em] px-[11px] py-[5px] rounded-pill mb-2.5"
style={{ background: accentSoft, color: accent }}
>
<span className="w-1.5 h-1.5 rounded-full" style={{ background: accent }} />
{autonomous ? 'AUTONOMOUS DECISION' : 'ESCALATED TO HUMAN'}
</div>
{/* one-line reasoning summary */}
<p className="m-0 text-base leading-normal text-body">{summary}</p>
{/* expandable full reasoning */}
{reasoning && (
<div className="mt-3">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="inline-flex items-center gap-1.5 bg-none border-none p-0 cursor-pointer font-sans text-xs font-semibold text-link"
>
<ChevronRight size={14} className={cn('transition-transform duration-200', open && 'rotate-90')} />
{open ? 'Hide full reasoning' : 'Show full reasoning'}
</button>
{open && (
<div className="mt-2.5 px-4 py-3.5 bg-sunk rounded-lg">
<ReasoningBody text={reasoning} accent={accent} accentSoft={accentSoft} />
</div>
)}
</div>
)}
{/* citation chips */}
{citations.length > 0 && (
<div className="mt-3.5">
<div className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint mb-[7px]">
Cited knowledge
</div>
<div className="flex flex-wrap gap-[7px]">
{citations.map((c, i) => (
<span
key={i}
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted bg-card border border-border-default rounded-sm px-2.5 py-[5px]"
>
<FileText size={12} className="text-sunrise-500" />
{typeof c === 'string' ? c : c.title}
</span>
))}
</div>
</div>
)}
</div>
</article>
);
}

View File

@ -0,0 +1,192 @@
import { useState } from 'react';
import { ChevronDown, FileText, RefreshCw, TriangleAlert } from 'lucide-react';
import { cn } from '../../lib/cn';
import { Avatar } from '../core/Avatar';
import { ReasoningBody } from './ReasoningBody';
import type { Decision } from '../../types';
export interface AIDecisionStreamProps {
decisions: Decision[];
loading?: boolean;
/** Manual refresh handler. Shown as a refresh button in the header. */
onRefresh?: () => void;
/** Autonomy threshold. @default 0.7 */
threshold?: number;
className?: string;
}
/**
* AI DECISION STREAM the stacked-decision view.
* A single-open accordion: each row is a scannable summary (employee, activity,
* confidence, one-line reasoning); expanding reveals the full reasoning in a
* height-capped, scrollable box so a wall of text never blows out the column.
*/
export function AIDecisionStream({
decisions,
loading = false,
onRefresh,
threshold = 0.7,
className,
}: AIDecisionStreamProps) {
// Single-open accordion; all collapsed by default (-1 = none open).
const [openIdx, setOpenIdx] = useState(-1);
const escalations = decisions.filter((d) => d.confidence < threshold);
return (
<div className={cn('flex flex-col gap-3.5', className)}>
<div className="flex items-center justify-between">
<h3 className="m-0 text-sm font-bold text-strong">AI decision stream</h3>
<div className="flex items-center gap-2">
<span className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy bg-navy-900 px-2 py-1 rounded-pill">
{decisions.length} action{decisions.length === 1 ? '' : 's'}
</span>
{onRefresh && (
<button
type="button"
onClick={onRefresh}
disabled={loading}
title="Refresh AI decisions"
aria-label="Refresh AI decisions"
className="w-7 h-7 rounded-md border border-border-subtle bg-card cursor-pointer text-muted hover:text-strong disabled:opacity-50 flex items-center justify-center shrink-0"
>
<RefreshCw size={14} className={cn(loading && 'animate-spin')} />
</button>
)}
</div>
</div>
{escalations.length > 0 && (
<div className="bg-escalated-soft border border-amber-300 rounded-md px-3.5 py-3 flex gap-3">
<TriangleAlert size={18} className="text-amber-600 shrink-0 mt-0.5" />
<div>
<div className="text-sm font-bold text-amber-700">
{escalations.length} escalation{escalations.length > 1 ? 's' : ''} need you
</div>
<div className="text-xs text-muted mt-0.5">
{escalations[0].employee} · confidence {Math.round(escalations[0].confidence * 100)}%.
</div>
</div>
</div>
)}
{/* Placeholder only on first load; a manual refresh keeps the list visible
(the header button spins) so the stream never blanks out. */}
{loading && decisions.length === 0 && (
<div className="text-sm text-faint">Loading decisions</div>
)}
{!loading && decisions.length === 0 && (
<div className="text-sm text-faint">No AI decisions for this lead yet.</div>
)}
{decisions.length > 0 && (
<div className="bg-card rounded-lg border border-border-subtle shadow-sm overflow-hidden font-sans divide-y divide-border-subtle">
{decisions.map((d, i) => (
<DecisionRow
key={i}
decision={d}
threshold={threshold}
open={openIdx === i}
onToggle={() => setOpenIdx((cur) => (cur === i ? -1 : i))}
/>
))}
</div>
)}
</div>
);
}
function DecisionRow({
decision: d,
threshold,
open,
onToggle,
}: {
decision: Decision;
threshold: number;
open: boolean;
onToggle: () => void;
}) {
const autonomous = d.confidence >= threshold;
const accent = autonomous ? 'var(--status-autonomous)' : 'var(--status-escalated)';
const accentSoft = autonomous ? 'var(--status-autonomous-soft)' : 'var(--status-escalated-soft)';
const pct = Math.round(d.confidence * 100);
return (
<div className="relative">
{/* accent rail on the open row */}
{open && (
<span
className={cn('absolute top-0 left-0 bottom-0 w-1', autonomous && 'bg-sunrise')}
style={autonomous ? undefined : { background: accent }}
/>
)}
<button
type="button"
onClick={onToggle}
aria-expanded={open}
className={cn(
'w-full flex items-start gap-3 text-left px-3.5 py-3 cursor-pointer bg-transparent border-none transition-colors',
open ? 'bg-sunk/60' : 'hover:bg-sunk/40',
)}
>
<Avatar name={d.employee} ai size={30} className="mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-strong truncate">{d.employee}</span>
{/* confidence chip */}
<span
className="ml-auto shrink-0 inline-flex items-center gap-1 text-2xs font-bold tracking-[0.02em] px-1.5 py-0.5 rounded-pill nums"
style={{ background: accentSoft, color: accent }}
>
<span className="w-1.5 h-1.5 rounded-full" style={{ background: accent }} />
{pct}%
</span>
<ChevronDown
size={15}
className={cn('shrink-0 text-faint transition-transform duration-200', open && 'rotate-180')}
/>
</div>
<div className="text-2xs text-faint mt-0.5">
{d.activity}
{d.time && <span> · {d.time}</span>}
</div>
<p className={cn('m-0 mt-1.5 text-sm leading-snug text-body', !open && 'line-clamp-2')}>
{d.summary}
</p>
</div>
</button>
{open && (d.reasoning || d.citations.length > 0) && (
<div className="px-3.5 pb-4">
{d.reasoning && (
<div className="px-4 py-3.5 bg-sunk rounded-lg">
<ReasoningBody text={d.reasoning} accent={accent} accentSoft={accentSoft} />
</div>
)}
{d.citations.length > 0 && (
<div className="mt-3">
<div className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint mb-[7px]">
Cited knowledge
</div>
<div className="flex flex-wrap gap-[7px]">
{d.citations.map((c, i) => (
<span
key={i}
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted bg-card border border-border-default rounded-sm px-2.5 py-[5px]"
>
<FileText size={12} className="text-sunrise-500" />
{c}
</span>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,92 @@
import { useState } from 'react';
import { Sparkles, ChevronDown, ChevronUp, Check, AlertTriangle } from 'lucide-react';
import { cn } from '../../lib/cn';
import { Avatar } from '../core/Avatar';
import { AIDecisionCard } from './AIDecisionCard';
import type { AIDecisionCardProps } from './AIDecisionCard';
export interface AiSuggestionPanelProps extends AIDecisionCardProps {
/** Eyebrow heading above the card. @default "AI suggestion" */
label?: string;
/** Start as a one-line strip, expandable to the full card. @default true */
collapsible?: boolean;
className?: string;
}
/**
* Sidebar framing for an AI employee's decision inside an activity form.
* Defaults to a compact one-line strip (avatar · name · confidence · summary)
* so it sits comfortably beside small forms, and expands to the full
* AIDecisionCard on click. Pass `collapsible={false}` to always show the card.
*/
export function AiSuggestionPanel({
label = 'AI suggestion',
collapsible = true,
className,
...card
}: AiSuggestionPanelProps) {
const [open, setOpen] = useState(!collapsible);
const {
employee = 'Aria',
confidence = 0.85,
threshold = 0.7,
summary,
} = card;
const autonomous = confidence >= threshold;
const accent = autonomous ? 'var(--status-autonomous)' : 'var(--status-escalated)';
const accentSoft = autonomous ? 'var(--status-autonomous-soft)' : 'var(--status-escalated-soft)';
const pct = Math.round(Math.max(0, Math.min(1, confidence)) * 100);
return (
<div className={cn('flex flex-col gap-2.5', className)}>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-2xs font-bold uppercase tracking-[0.06em] text-faint">
<Sparkles size={12} className="text-sunrise-500" />
{label}
</div>
{collapsible && open && (
<button
type="button"
onClick={() => setOpen(false)}
className="inline-flex items-center gap-1 bg-none border-none p-0 cursor-pointer font-sans text-2xs font-semibold text-link"
>
<ChevronUp size={12} />
Collapse
</button>
)}
</div>
{open ? (
<AIDecisionCard {...card} />
) : (
<button
type="button"
onClick={() => setOpen(true)}
className="group relative w-full text-left bg-card rounded-lg border border-border-subtle shadow-sm hover:shadow-md transition-shadow overflow-hidden font-sans flex items-center gap-3 pl-[14px] pr-3 py-2.5"
>
{/* accent rail */}
<span className="absolute top-0 left-0 bottom-0 w-1" style={{ background: accent }} />
<Avatar name={employee} ai size={30} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-strong leading-tight truncate">{employee}</span>
<span
className="inline-flex items-center gap-1 text-2xs font-bold tracking-[0.03em] px-1.5 py-0.5 rounded-pill shrink-0"
style={{ background: accentSoft, color: accent }}
>
{autonomous ? <Check size={10} strokeWidth={3} /> : <AlertTriangle size={10} strokeWidth={3} />}
{pct}%
</span>
</div>
{summary && <div className="text-xs text-muted mt-0.5 truncate">{summary}</div>}
</div>
<ChevronDown
size={16}
className="shrink-0 text-faint transition-transform group-hover:translate-y-0.5"
/>
</button>
)}
</div>
);
}

View File

@ -0,0 +1,41 @@
import type { LucideIcon } from 'lucide-react';
import { Building2, CalendarDays, Globe, Landmark, MessageCircle, Phone, Share2 } from 'lucide-react';
import { cn } from '../../lib/cn';
import type { Channel } from '../../types';
export interface ChannelBadgeProps {
/** @default "Web" */
channel?: Channel;
/** @default "md" */
size?: 'sm' | 'md';
className?: string;
}
const CH: Record<Channel, { wrap: string; Icon: LucideIcon }> = {
WhatsApp: { wrap: 'bg-emerald-100 text-emerald-700', Icon: MessageCircle },
Web: { wrap: 'bg-sky-100 text-sky-700', Icon: Globe },
Referral: { wrap: 'bg-sunrise-100 text-sunrise-600', Icon: Share2 },
Agency: { wrap: 'bg-slate-100 text-navy-700', Icon: Building2 },
Bancassurance: { wrap: 'bg-slate-100 text-slate-700', Icon: Landmark },
Direct: { wrap: 'bg-slate-100 text-slate-600', Icon: Phone },
Event: { wrap: 'bg-amber-100 text-amber-700', Icon: CalendarDays },
};
/** Acquisition channel badge with glyph + color. */
export function ChannelBadge({ channel = 'Web', size = 'md', className }: ChannelBadgeProps) {
const c = CH[channel];
const sm = size === 'sm';
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-pill font-sans font-semibold leading-none whitespace-nowrap',
sm ? 'text-2xs px-[9px] py-1' : 'text-xs px-[11px] py-[5px]',
c.wrap,
className,
)}
>
<c.Icon size={sm ? 11 : 13} />
{channel}
</span>
);
}

View File

@ -0,0 +1,80 @@
import type { CSSProperties } from 'react';
import { cn } from '../../lib/cn';
export interface ConfidenceRingProps {
/** Confidence 01. */
value?: number;
/** Diameter in px. @default 72 */
size?: number;
stroke?: number;
showLabel?: boolean;
/** Autonomy threshold. @default 0.7 */
threshold?: number;
className?: string;
style?: CSSProperties;
}
/**
* Confidence ring (01). 0.70 emerald "Autonomous", <0.70 amber "Escalated".
* The signature trust signal across Aria's AI Decision Cards.
*/
export function ConfidenceRing({
value = 0,
size = 72,
stroke = 7,
showLabel = true,
threshold = 0.7,
className,
style,
}: ConfidenceRingProps) {
const v = Math.max(0, Math.min(1, value));
const autonomous = v >= threshold;
const color = autonomous ? 'var(--status-autonomous)' : 'var(--status-escalated)';
const r = (size - stroke) / 2;
const circ = 2 * Math.PI * r;
const dash = circ * v;
const pct = Math.round(v * 100);
return (
<div
className={cn('inline-flex flex-col items-center gap-1.5 font-sans', className)}
style={style}
>
<div className="relative" style={{ width: size, height: size }}>
<svg width={size} height={size} style={{ transform: 'rotate(-90deg)' }}>
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--surface-sunk)" strokeWidth={stroke} />
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke={color}
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={`${dash} ${circ}`}
style={{ transition: 'stroke-dasharray .6s cubic-bezier(.22,1,.36,1)' }}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span
className="font-numeric font-extrabold text-strong leading-none nums"
style={{ fontSize: size * 0.3 }}
>
{pct}
</span>
<span className="text-faint font-semibold" style={{ fontSize: size * 0.12 }}>
%
</span>
</div>
</div>
{showLabel && (
<span
className="text-2xs font-bold tracking-[0.04em] uppercase"
style={{ color }}
>
{autonomous ? 'Autonomous' : 'Escalated'}
</span>
)}
</div>
);
}

View File

@ -0,0 +1,161 @@
import { CreditCard, UploadCloud } from 'lucide-react';
import { cn } from '../../lib/cn';
import { Badge } from '../core/Badge';
export type DocStatus = 'empty' | 'processing' | 'extracted' | 'verified';
export interface OcrField {
label: string;
value: string;
mono?: boolean;
confidence?: number;
}
export interface DocumentUploadCardProps {
/** @default "PAN" */
docType?: string;
/** @default "empty" */
status?: DocStatus;
fields?: OcrField[];
fileName?: string | null;
onUpload?: () => void;
onConfirm?: () => void;
/** Pick a different file (re-runs OCR). Shown once a doc is uploaded. */
onReplace?: () => void;
/** Clear the uploaded doc + its extracted fields. */
onRemove?: () => void;
className?: string;
}
const STATUS_BADGE: Record<DocStatus, React.ReactNode> = {
empty: null,
processing: (
<Badge tone="info" dot>
Running OCR
</Badge>
),
extracted: (
<Badge tone="warning" dot>
Confirm fields
</Badge>
),
verified: (
<Badge tone="success" dot>
Verified
</Badge>
),
};
/**
* Document upload card with OCR for KYC (PAN / Aadhaar).
* Empty = dropzone; filled shows OCR-extracted fields + verification status.
*/
export function DocumentUploadCard({
docType = 'PAN',
status = 'empty',
fields = [],
fileName = null,
onUpload,
onConfirm,
onReplace,
onRemove,
className,
}: DocumentUploadCardProps) {
const isEmpty = status === 'empty';
return (
<div className={cn('h-full flex flex-col bg-card border border-border-subtle rounded-lg shadow-sm overflow-hidden font-sans', className)}>
<header className="flex items-center justify-between px-[18px] py-3.5 border-b border-border-subtle">
<div className="flex items-center gap-2.5">
<span className="w-[34px] h-[34px] rounded-sm bg-sunrise-100 text-sunrise-600 flex items-center justify-center">
<CreditCard size={18} />
</span>
<div>
<div className="text-base font-bold text-strong">{docType} card</div>
{fileName && <div className="text-2xs text-faint">{fileName}</div>}
</div>
</div>
{STATUS_BADGE[status]}
</header>
<div className="flex-1 flex flex-col p-[18px]">
{isEmpty ? (
<button
type="button"
onClick={onUpload}
className="flex-1 w-full border-2 border-dashed border-border-default rounded-md bg-slate-50 px-4 py-7 cursor-pointer flex flex-col items-center justify-center gap-2 font-sans"
>
<UploadCloud size={28} className="text-sunrise-500" />
<span className="text-sm font-semibold text-strong">Upload {docType} card</span>
<span className="text-xs text-faint">JPG, PNG or PDF · OCR auto-extracts fields</span>
</button>
) : (
<div className="flex-1 flex flex-col gap-0.5">
<div className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint mb-2">
OCR-extracted fields
</div>
{fields.map((f, i) => (
<div
key={i}
className={cn(
'flex items-center justify-between py-[9px]',
i < fields.length - 1 && 'border-b border-border-subtle',
)}
>
<span className="text-xs text-muted">{f.label}</span>
<span className="flex items-center gap-[7px]">
<span
className={cn(
'text-sm font-semibold text-strong',
f.mono ? 'font-mono tracking-[0.04em]' : 'font-sans',
)}
>
{f.value}
</span>
{f.confidence != null && (
<span
className={cn(
'text-[10px] font-bold',
f.confidence >= 0.9 ? 'text-emerald-600' : 'text-amber-600',
)}
>
{Math.round(f.confidence * 100)}%
</span>
)}
</span>
</div>
))}
{/* mt-auto keeps the action row pinned to the card bottom, so both
cards' Confirm/Replace align even with differing field counts. */}
<div className="mt-auto flex flex-col gap-2.5 pt-3.5">
{status === 'extracted' && (
<button
type="button"
onClick={onConfirm}
className="w-full h-10 border-none rounded-md bg-sunrise text-white font-sans text-base font-semibold cursor-pointer shadow-sunrise"
>
Confirm &amp; verify
</button>
)}
{(onReplace || onRemove) && status !== 'processing' && (
<div className="flex items-center justify-center gap-3 text-xs">
{onReplace && (
<button type="button" onClick={onReplace} className="font-semibold text-link">
Replace file
</button>
)}
{onReplace && onRemove && <span className="text-faint">·</span>}
{onRemove && (
<button type="button" onClick={onRemove} className="font-semibold text-faint hover:text-ruby-600">
Remove
</button>
)}
</div>
)}
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,76 @@
import type { ReactNode } from 'react';
import { ArrowDown, ArrowUp } from 'lucide-react';
import { cn } from '../../lib/cn';
export interface KpiCardProps {
label: string;
value: string | number;
unit?: string;
delta?: string | null;
deltaDir?: 'up' | 'down';
icon?: ReactNode;
/** Use the navy-gradient feature treatment. @default false */
accent?: boolean;
className?: string;
}
/** KPI stat card — oversized numeral, label, optional delta trend. */
export function KpiCard({
label,
value,
unit = '',
delta = null,
deltaDir = 'up',
icon,
accent = false,
className,
}: KpiCardProps) {
const positive = deltaDir === 'up';
return (
<div
className={cn(
'rounded-lg shadow-sm p-5 flex flex-col gap-3.5 font-sans border',
accent ? 'bg-navy-grad border-transparent' : 'bg-card border-border-subtle',
className,
)}
>
<div className="flex items-center justify-between">
<span
className={cn(
'text-xs font-semibold tracking-[0.04em] uppercase',
accent ? 'text-on-navy-muted' : 'text-faint',
)}
>
{label}
</span>
{icon && <span className={cn('flex', accent ? 'text-sunrise-300' : 'text-faint')}>{icon}</span>}
</div>
<div className="flex items-baseline gap-1">
<span
className={cn(
'font-numeric font-extrabold text-4xl leading-none tracking-[-0.02em] nums',
accent ? 'text-white' : 'text-strong',
)}
>
{value}
</span>
{unit && (
<span className={cn('text-xl font-bold', accent ? 'text-on-navy-muted' : 'text-faint')}>{unit}</span>
)}
</div>
{delta != null && (
<div className="flex items-center gap-1.5">
<span
className={cn(
'inline-flex items-center gap-1 text-xs font-bold',
positive ? 'text-emerald-600' : 'text-ruby-600',
)}
>
{positive ? <ArrowUp size={13} /> : <ArrowDown size={13} />} {delta}
</span>
<span className={cn('text-xs', accent ? 'text-on-navy-muted' : 'text-faint')}>vs last week</span>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,85 @@
import type { ReactNode } from 'react';
import { cn } from '../../lib/cn';
import type { Lead, Tone } from '../../types';
import { Avatar } from '../core/Avatar';
import { SegmentBadge } from './SegmentBadge';
import { ChannelBadge } from './ChannelBadge';
export interface LeadRowProps {
lead: Lead;
onClick?: () => void;
className?: string;
/** When set, replaces the "last action" cell (e.g. a worklist action button).
* Clicks inside it don't trigger the row's onClick. */
action?: ReactNode;
}
/** Shared grid template for the leads table — keep header + rows in sync. */
export const LEAD_GRID = 'grid-cols-[2fr_64px_96px_120px_1.4fr_2fr]';
const DOT: Record<Tone, string> = {
neutral: 'bg-muted',
success: 'bg-emerald-600',
warning: 'bg-amber-600',
accent: 'bg-sunrise-600',
info: 'bg-sky-600',
};
function scoreColor(score: number): string {
if (score >= 75) return 'var(--sunrise-600)';
if (score >= 50) return 'var(--amber-600)';
return 'var(--slate-500)';
}
/** Leads-list table row name, score, segment, channel, owner, last AI action
* (or a custom `action` cell). */
export function LeadRow({ lead, onClick, className, action }: LeadRowProps) {
return (
<div
onClick={onClick}
className={cn(
'grid items-center gap-3 px-[18px] py-3.5 bg-card border-b border-border-subtle font-sans transition-colors duration-150',
LEAD_GRID,
onClick && 'cursor-pointer hover:bg-slate-50',
className,
)}
>
{/* name */}
<div className="flex items-center gap-2.5 min-w-0">
<Avatar name={lead.name} size={36} />
<div className="min-w-0">
<div className="text-base font-semibold text-strong truncate">{lead.name}</div>
<div className="text-xs text-faint">{lead.city}</div>
</div>
</div>
{/* score */}
<div className="font-numeric font-extrabold text-2xl tracking-[-0.02em] nums" style={{ color: scoreColor(lead.score) }}>
{lead.score}
</div>
{/* segment */}
<div>
<SegmentBadge segment={lead.segment} size="sm" />
</div>
{/* channel */}
<div>
<ChannelBadge channel={lead.channel} size="sm" />
</div>
{/* owner */}
<div className="flex items-center gap-2 min-w-0">
<Avatar name={lead.owner} ai={lead.ownerAi} size={24} />
<span className="text-sm text-body truncate">{lead.owner}</span>
</div>
{/* last AI action — or a custom action cell */}
{action !== undefined ? (
<div className="flex justify-end" onClick={(e) => e.stopPropagation()}>
{action}
</div>
) : (
<div className="flex items-center gap-[7px] min-w-0">
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0', DOT[lead.lastTone])} />
<span className="text-sm text-muted truncate">{lead.lastAction}</span>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,123 @@
import { cn } from '../../lib/cn';
export interface ReasoningParts {
/** Lead-in prose before the first enumerated point. */
intro: string;
/** The enumerated reasoning points. */
steps: string[];
}
/**
* Parse an AI reasoning blob into a lead-in + enumerated steps.
* Handles the common "(1) … (2) …" / "1) … 2) …" inline enumeration and
* "-"/"•"-prefixed bullet lines. Falls back to plain prose (no steps) for
* anything else, so short / unstructured reasoning still renders cleanly.
*/
export function parseReasoning(raw: string): ReasoningParts {
const text = (raw ?? '').trim();
if (!text) return { intro: '', steps: [] };
// Inline numbered enumeration — "(1) …" or "1) …". Only digit-in-paren forms,
// so "(18-65 band)", "(WARM segment)", "60/100" never false-match.
const markers = [...text.matchAll(/(?:^|\s)\(?(\d{1,2})\)\s+/g)];
if (markers.length >= 2) {
const intro = text.slice(0, markers[0].index).trim();
const steps: string[] = [];
for (let i = 0; i < markers.length; i++) {
const start = markers[i].index! + markers[i][0].length;
const end = i + 1 < markers.length ? markers[i + 1].index! : text.length;
const seg = text.slice(start, end).trim().replace(/[,;]\s*$/, '');
if (seg) steps.push(seg);
}
return { intro, steps };
}
// Newline bullets — "- ", "• ", "* ".
const lines = text.split(/\n+/).map((l) => l.trim()).filter(Boolean);
const isBullet = (l: string) => /^[-•*]\s+/.test(l);
if (lines.filter(isBullet).length >= 2) {
const introLines: string[] = [];
let i = 0;
while (i < lines.length && !isBullet(lines[i])) introLines.push(lines[i++]);
const steps = lines.slice(i).filter(isBullet).map((l) => l.replace(/^[-•*]\s+/, ''));
return { intro: introLines.join(' '), steps };
}
return { intro: text, steps: [] };
}
/**
* Split a prose blob into sentence-sized facts for scannable rendering.
* Only breaks on terminal punctuation that follows a word/paren/quote and
* precedes a capital so decimals (2.76), ranges (18-65), and multiples
* (10-20x) never split mid-fact. Newlines are flattened first.
*/
function splitSentences(raw: string): string[] {
return (raw ?? '')
.replace(/\s*\n+\s*/g, ' ')
.trim()
.split(/(?<=[a-zA-Z)\]%"'])[.!?]+\s+(?=[A-Z"'(])/)
.map((s) => s.trim())
.filter(Boolean);
}
export interface ReasoningBodyProps {
text: string;
/** Accent color for the step badges (matches the decision's autonomy state). */
accent?: string;
accentSoft?: string;
className?: string;
}
/** Renders an AI reasoning blob as a lead-in paragraph + numbered step list. */
export function ReasoningBody({
text,
accent = 'var(--status-autonomous)',
accentSoft = 'var(--status-autonomous-soft)',
className,
}: ReasoningBodyProps) {
const { intro, steps } = parseReasoning(text);
// No enumeration — split the prose into sentence-sized facts and render them
// as a scannable list, so a dense wall of reasoning reads point-by-point.
if (steps.length === 0) {
const facts = splitSentences(text);
if (facts.length < 2) {
return (
<p className={cn('m-0 text-sm leading-relaxed text-body', className)}>{text.trim()}</p>
);
}
return (
<ul className={cn('m-0 p-0 list-none flex flex-col gap-2 text-sm leading-snug text-body', className)}>
{facts.map((f, i) => (
<li key={i} className="flex gap-2.5">
<span
className="mt-[7px] shrink-0 w-1.5 h-1.5 rounded-full"
style={{ background: accent }}
/>
<span className="flex-1">{f}</span>
</li>
))}
</ul>
);
}
return (
<div className={cn('text-sm leading-relaxed text-body', className)}>
{intro && <p className="m-0 mb-3">{intro}</p>}
<ol className="m-0 p-0 list-none flex flex-col gap-2.5">
{steps.map((s, i) => (
<li key={i} className="flex gap-2.5">
<span
className="shrink-0 mt-px w-5 h-5 rounded-full text-[11px] font-bold flex items-center justify-center nums"
style={{ background: accentSoft, color: accent }}
>
{i + 1}
</span>
<span className="flex-1 leading-snug">{s}</span>
</li>
))}
</ol>
</div>
);
}

View File

@ -0,0 +1,69 @@
import type { CSSProperties, ReactNode } from 'react';
import { cn } from '../../lib/cn';
/**
* Indian-grouped rupee figure (lakh/crore). e.g. 20000000 2,00,00,000.
*/
export function formatINR(n: number | null | undefined): string {
if (n == null || Number.isNaN(n)) return '—';
const neg = n < 0;
const s = Math.round(Math.abs(n)).toString();
let last3 = s.slice(-3);
let rest = s.slice(0, -3);
if (rest) last3 = ',' + last3;
rest = rest.replace(/\B(?=(\d{2})+(?!\d))/g, ',');
return (neg ? '-' : '') + rest + last3;
}
export type RupeeSize = 'sm' | 'md' | 'lg' | 'xl' | 'hero';
export type RupeeTone = 'default' | 'accent' | 'navy' | 'muted' | 'onNavy';
export interface RupeeAmountProps {
value: number;
/** @default "md" */
size?: RupeeSize;
/** @default "default" */
tone?: RupeeTone;
sub?: ReactNode;
className?: string;
style?: CSSProperties;
}
const SIZES: Record<RupeeSize, number> = { sm: 16, md: 22, lg: 32, xl: 44, hero: 64 };
const TONES: Record<RupeeTone, string> = {
default: 'var(--text-strong)',
accent: 'var(--sunrise-600)',
navy: 'var(--navy-900)',
muted: 'var(--text-muted)',
onNavy: 'var(--text-on-navy)',
};
// Large sizes shrink for long figures so a crore-scale number never spills out
// of its container. Keyed off the formatted length (digits + grouping commas).
function fitScale(size: RupeeSize, len: number): number {
if (size !== 'hero' && size !== 'xl') return 1;
if (len > 12) return 0.6; // ₹12,00,00,000+
if (len > 10) return 0.72; // ₹2,76,00,000
if (len > 8) return 0.85; // ₹20,00,000
return 1;
}
/** Oversized confident rupee numeral via Inter Tight. Auto-fits long figures. */
export function RupeeAmount({ value, size = 'md', tone = 'default', sub, className, style }: RupeeAmountProps) {
const text = formatINR(value);
const fs = SIZES[size] * fitScale(size, text.length);
return (
<span className={cn('flex flex-col leading-none font-numeric max-w-full min-w-0', className)} style={style}>
<span
className="font-extrabold tracking-[-0.02em] nums inline-flex items-baseline gap-0.5 max-w-full whitespace-nowrap overflow-hidden"
style={{ fontSize: fs, color: TONES[tone] }}
>
<span className="font-bold shrink-0" style={{ fontSize: fs * 0.62 }}>
</span>
<span className="truncate">{text}</span>
</span>
{sub && <span className="font-sans text-xs font-medium text-faint mt-1">{sub}</span>}
</span>
);
}

View File

@ -0,0 +1,35 @@
import { cn } from '../../lib/cn';
import type { Segment } from '../../types';
export interface SegmentBadgeProps {
/** @default "Warm" */
segment?: Segment;
/** @default "md" */
size?: 'sm' | 'md';
className?: string;
}
const SEG: Record<Segment, { wrap: string; dot: string }> = {
Hot: { wrap: 'bg-sunrise-100 text-sunrise-600', dot: 'bg-sunrise-600' },
Warm: { wrap: 'bg-amber-100 text-amber-700', dot: 'bg-amber-600' },
Cold: { wrap: 'bg-slate-100 text-slate-600', dot: 'bg-slate-500' },
};
/** Lead temperature badge — Hot / Warm / Cold. */
export function SegmentBadge({ segment = 'Warm', size = 'md', className }: SegmentBadgeProps) {
const s = SEG[segment];
const sm = size === 'sm';
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-pill font-sans font-bold leading-none',
sm ? 'text-2xs px-[9px] py-1' : 'text-xs px-[11px] py-[5px]',
s.wrap,
className,
)}
>
<span className={cn('w-1.5 h-1.5 rounded-full', s.dot)} />
{segment}
</span>
);
}

View File

@ -0,0 +1,64 @@
import type { ReactNode } from 'react';
import { cn } from '../../lib/cn';
import { aiEmployeeColor } from '../core/Avatar';
import type { Tone } from '../../types';
export interface TimelineEntryProps {
actor: string;
/** Render the AI sunrise dot + accent actor color. */
ai?: boolean;
action: string;
time: string;
children?: ReactNode;
/** @default "neutral" */
tone?: Tone;
/** Hide the trailing connector rail for the last entry. */
last?: boolean;
className?: string;
}
const DOT: Record<Tone, string> = {
neutral: 'bg-slate-400',
success: 'bg-emerald-500',
warning: 'bg-amber-500',
accent: 'bg-sunrise-500',
info: 'bg-sky-500',
};
/** Audit/timeline entry — actor, action, timestamp, optional body. Connected by a rail. */
export function TimelineEntry({
actor,
ai = false,
action,
time,
children,
tone = 'neutral',
last = false,
className,
}: TimelineEntryProps) {
// Match the AI-employee color used by their Avatar + decision cards, so each
// employee reads the same hue everywhere (timeline dot + actor name).
const aiColor = ai ? aiEmployeeColor(actor) : undefined;
return (
<div className={cn('flex gap-3 font-sans', className)}>
<div className="flex flex-col items-center shrink-0">
<span
className={cn(
'w-3 h-3 rounded-full border-2 border-card mt-[3px]',
!ai && DOT[tone],
)}
style={{ boxShadow: '0 0 0 1px var(--border-subtle)', background: aiColor }}
/>
{!last && <span className="flex-1 w-0.5 bg-border-subtle mt-0.5 min-h-3.5" />}
</div>
<div className={cn('flex-1', last ? 'pb-0' : 'pb-[18px]')}>
<div className="flex items-baseline gap-2 flex-wrap">
<span className="text-sm font-bold" style={{ color: aiColor || 'var(--text-strong)' }}>{actor}</span>
<span className="text-sm text-body">{action}</span>
<span className="ml-auto text-2xs text-faint whitespace-nowrap">{time}</span>
</div>
{children && <div className="mt-2">{children}</div>}
</div>
</div>
);
}

View File

@ -0,0 +1,75 @@
import { Fragment } from 'react';
import { Check } from 'lucide-react';
import { cn } from '../../lib/cn';
import { STAGES } from '../../api/config';
export interface WorkflowStepperProps {
stages?: readonly string[];
/** Index of the current stage. */
current?: number;
orientation?: 'horizontal' | 'vertical';
className?: string;
}
/** Workflow state stepper across the lead-to-policy lifecycle. */
export function WorkflowStepper({
stages = STAGES,
current = 0,
orientation = 'horizontal',
className,
}: WorkflowStepperProps) {
const vertical = orientation === 'vertical';
// Every dot sits in a fixed 28px (h-7) band so all centers land on the same
// line regardless of dot size; connectors align to that center (14px), not to
// the dot+label column. Keeps the rail straight even with the larger active dot.
return (
<div
className={cn(
'flex font-sans',
vertical ? 'flex-col items-stretch' : 'flex-row items-start',
className,
)}
>
{stages.map((s, i) => {
const done = i < current;
const active = i === current;
const isLast = i === stages.length - 1;
return (
<Fragment key={s}>
<div className={cn('flex shrink-0', vertical ? 'flex-row items-center gap-3' : 'flex-col items-center gap-2')}>
<span className={cn('flex items-center justify-center', vertical ? '' : 'h-7')}>
<span
className={cn(
'rounded-full shrink-0 flex items-center justify-center text-xs font-bold transition-all duration-200',
active && 'w-7 h-7 bg-sunrise text-white shadow-sunrise',
done && 'w-[22px] h-[22px] bg-emerald-600 text-white',
!done && !active && 'w-[22px] h-[22px] bg-card text-slate-500 border-2 border-slate-300',
)}
>
{done ? <Check size={13} strokeWidth={3} /> : i + 1}
</span>
</span>
<span
className={cn(
'text-xs leading-[1.25]',
vertical ? 'text-left whitespace-nowrap' : 'text-center max-w-[78px]',
active ? 'font-bold text-strong' : done ? 'font-medium text-muted' : 'font-medium text-faint',
)}
>
{s}
</span>
</div>
{!isLast && (
<span
className={cn(
i < current ? 'bg-emerald-600' : 'bg-slate-200',
vertical ? 'w-0.5 h-[18px] ml-[10px]' : 'flex-1 h-0.5 min-w-4 mt-[13px]',
)}
/>
)}
</Fragment>
);
})}
</div>
);
}

View File

@ -0,0 +1,26 @@
export { ConfidenceRing } from './ConfidenceRing';
export type { ConfidenceRingProps } from './ConfidenceRing';
export { RupeeAmount, formatINR } from './RupeeAmount';
export type { RupeeAmountProps, RupeeSize, RupeeTone } from './RupeeAmount';
export { SegmentBadge } from './SegmentBadge';
export type { SegmentBadgeProps } from './SegmentBadge';
export { ChannelBadge } from './ChannelBadge';
export type { ChannelBadgeProps } from './ChannelBadge';
export { KpiCard } from './KpiCard';
export type { KpiCardProps } from './KpiCard';
export { WorkflowStepper } from './WorkflowStepper';
export type { WorkflowStepperProps } from './WorkflowStepper';
export { TimelineEntry } from './TimelineEntry';
export type { TimelineEntryProps } from './TimelineEntry';
export { AIDecisionCard } from './AIDecisionCard';
export type { AIDecisionCardProps, Citation } from './AIDecisionCard';
export { AiSuggestionPanel } from './AiSuggestionPanel';
export type { AiSuggestionPanelProps } from './AiSuggestionPanel';
export { AIDecisionStream } from './AIDecisionStream';
export type { AIDecisionStreamProps } from './AIDecisionStream';
export { ReasoningBody, parseReasoning } from './ReasoningBody';
export type { ReasoningBodyProps, ReasoningParts } from './ReasoningBody';
export { LeadRow, LEAD_GRID } from './LeadRow';
export type { LeadRowProps } from './LeadRow';
export { DocumentUploadCard } from './DocumentUploadCard';
export type { DocumentUploadCardProps, DocStatus, OcrField } from './DocumentUploadCard';

218
src/layout/Shell.tsx Normal file
View File

@ -0,0 +1,218 @@
import { useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import type { LucideIcon } from 'lucide-react';
import { ArrowLeft, CalendarClock, FileText, LogOut, Menu, ShieldCheck, Sparkles, UserCheck, Users, X } from 'lucide-react';
import { cn } from '../lib/cn';
import { Avatar } from '../components/core';
import { ZinoLogo } from '../components/ZinoLogo';
import { canSeeScreen } from '../api/config';
import { useZino } from '../api/provider';
import { useLeads } from '../api/leads';
import type { Lead } from '../types';
interface NavItem {
to: string;
label: string;
Icon: LucideIcon;
title: string;
subtitle: string;
}
const NAV: NavItem[] = [
{ to: '/pipeline', label: 'Lead Pipeline', Icon: Users, title: 'Lead pipeline', subtitle: '' },
{ to: '/schedule', label: 'Agent Schedule', Icon: CalendarClock, title: 'Agent schedule', subtitle: 'Sales-agent meetings & booking slots' },
{ to: '/qualify', label: 'Qualify & Assign', Icon: UserCheck, title: 'Qualification & Assignment', subtitle: 'Qualify new leads and assign them to an agent' },
{ to: '/engage', label: 'Customer Interaction', Icon: Sparkles, title: 'Customer Interaction', subtitle: 'Log contact, schedule meetings, recommend products' },
{ to: '/documents', label: 'Documents', Icon: FileText, title: 'Document & Assessment', subtitle: 'KYC collection, OCR, eligibility assessment' },
{ to: '/underwriting', label: 'Underwriting & Policy', Icon: ShieldCheck, title: 'Underwriting & Policy Issuance', subtitle: 'Underwrite, issue policy, onboard customer' },
];
// Pipeline nav subtitle, computed live: "N leads · X% hot · Y% AI-handled".
function pipelineSubtitle(leads: Lead[]): string {
if (!leads.length) return 'No leads yet';
const total = leads.length;
const hot = Math.round((leads.filter((l) => l.segment === 'Hot').length / total) * 100);
const ai = Math.round((leads.filter((l) => l.ownerAi).length / total) * 100);
return `${total.toLocaleString('en-IN')} leads · ${hot}% hot · ${ai}% AI-handled`;
}
function BrandLogo() {
return (
<div className="flex items-center justify-center px-1">
<ZinoLogo className="h-7 w-auto" />
</div>
);
}
/** Sidebar contents — shared between the static desktop rail and the mobile drawer. */
function SidebarInner({ items, activeTo, onNav }: { items: NavItem[]; activeTo: string; onNav: (to: string) => void }) {
return (
<>
<div className="px-1 pt-1 pb-4 border-b border-white/[0.08]">
<BrandLogo />
</div>
<nav className="flex flex-col gap-1">
<div className="px-3 pb-1.5 text-2xs font-bold uppercase tracking-[0.14em] text-on-navy-muted/60">
Workspace
</div>
{items.map((n) => {
const on = activeTo === n.to;
return (
<button
key={n.to}
onClick={() => onNav(n.to)}
className={cn(
'group relative flex items-center gap-3 pl-3 pr-2.5 py-2 rounded-xl border-none cursor-pointer text-left text-[15px] transition-all duration-150',
on
? 'bg-white/[0.08] text-white font-semibold shadow-[inset_0_1px_0_rgba(255,255,255,0.06)] ring-1 ring-white/10'
: 'bg-transparent text-on-navy-muted font-medium hover:bg-white/[0.05] hover:text-on-navy',
)}
>
{/* active accent bar */}
<span
className={cn(
'absolute left-0 top-1/2 -translate-y-1/2 h-5 w-[3px] rounded-r-full bg-gradient-to-b from-sunrise-400 to-sunrise-300 transition-opacity duration-150',
on ? 'opacity-100' : 'opacity-0',
)}
/>
<span
className={cn(
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg transition-colors duration-150',
on
? 'bg-sunrise-500/18 text-sunrise-300'
: 'bg-white/[0.04] text-on-navy-muted group-hover:text-sunrise-300',
)}
>
<n.Icon size={17} />
</span>
<span className="truncate">{n.label}</span>
</button>
);
})}
</nav>
{/* live AI-employee status — pinned to the bottom */}
<div className="mt-auto pt-4 border-t border-white/[0.08]">
<div className="flex items-center gap-2.5 px-2.5 py-2.5 rounded-xl bg-white/[0.04] ring-1 ring-white/[0.06]">
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className="absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-60 animate-ping" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-400" />
</span>
<div className="leading-tight min-w-0">
<div className="text-xs font-semibold text-on-navy">AI employees active</div>
<div className="text-2xs text-on-navy-muted truncate">Qualify · Engage · Underwrite</div>
</div>
</div>
</div>
</>
);
}
function Topbar({ title, subtitle, onMenu, showBack }: { title: string; subtitle: string; onMenu: () => void; showBack: boolean }) {
const { user, logout } = useZino();
const navigate = useNavigate();
return (
<header className="sticky top-0 z-30 h-16 shrink-0 bg-card border-b border-border-subtle flex items-center px-4 lg:px-7 gap-3 lg:gap-5 font-sans">
<button
onClick={onMenu}
aria-label="Open menu"
className="lg:hidden w-[38px] h-[38px] -ml-1 rounded-md border border-border-subtle bg-card cursor-pointer text-muted flex items-center justify-center shrink-0"
>
<Menu size={20} />
</button>
{showBack && (
<button
onClick={() => (window.history.length > 1 ? navigate(-1) : navigate('/pipeline'))}
aria-label="Go back"
title="Go back"
className="w-[38px] h-[38px] rounded-md border border-border-subtle bg-card cursor-pointer text-muted flex items-center justify-center shrink-0 hover:text-strong hover:bg-slate-50"
>
<ArrowLeft size={18} />
</button>
)}
<div className="flex-1 min-w-0">
<h1 className="m-0 text-lg font-bold text-strong truncate">{title}</h1>
{subtitle && <div className="text-xs text-faint mt-px truncate">{subtitle}</div>}
</div>
<div className="flex items-center gap-3.5">
<div className="flex items-center gap-2 pl-1.5">
<Avatar name={user?.name || 'User'} size={36} />
<div className="hidden md:block leading-tight whitespace-nowrap">
<div className="text-sm font-semibold text-strong">{user?.name || 'User'}</div>
<div className="text-2xs text-faint">{user?.email || ''}</div>
</div>
<button
onClick={() => {
logout();
navigate('/login', { replace: true });
}}
title="Sign out"
className="w-[38px] h-[38px] rounded-md border border-border-subtle bg-card cursor-pointer text-muted flex items-center justify-center ml-1"
>
<LogOut size={17} />
</button>
</div>
</div>
</header>
);
}
/** App shell — navy sidebar (nav) + topbar, with routed content. */
export function Shell() {
const location = useLocation();
const navigate = useNavigate();
const { leads } = useLeads();
const { user } = useZino();
const path = location.pathname;
// Nav is RBAC-filtered: a user only sees screens their roles allow.
const visibleNav = useMemo(() => NAV.filter((n) => canSeeScreen(user?.roles, n.to)), [user]);
const navMatch = NAV.find((n) => path.startsWith(n.to));
// Routes reached contextually (no sidebar entry) still get a topbar title.
const active =
navMatch ??
(path.startsWith('/leads')
? { to: '/leads', label: 'Lead 360', Icon: Users, title: 'Lead 360', subtitle: 'Full lead context, AI decision stream & next action' }
: path.startsWith('/new')
? { to: '/new', label: 'New lead', Icon: Users, title: 'Capture lead', subtitle: 'Add a new lead to the pipeline' }
: NAV[0]);
const subtitle = active.to === '/pipeline' ? pipelineSubtitle(leads) : active.subtitle;
const [navOpen, setNavOpen] = useState(false);
// Close the mobile drawer whenever the route changes.
useEffect(() => setNavOpen(false), [path]);
return (
<div className="flex min-h-screen bg-app font-sans">
{/* Static rail — lg and up. Pinned; the page scrolls normally beside it. */}
<aside className="hidden lg:flex w-[248px] shrink-0 bg-navy-grad flex-col p-4 gap-6 font-sans sticky top-0 h-screen self-start overflow-y-auto">
<SidebarInner items={visibleNav} activeTo={navMatch?.to ?? ''} onNav={(to) => navigate(to)} />
</aside>
{/* Drawer — below lg. */}
{navOpen && (
<div className="fixed inset-0 z-50 lg:hidden">
<div className="absolute inset-0 bg-black/40" onClick={() => setNavOpen(false)} />
<aside className="absolute inset-y-0 left-0 w-[248px] max-w-[82%] bg-navy-grad flex flex-col p-4 gap-6 font-sans shadow-2xl">
<button
onClick={() => setNavOpen(false)}
aria-label="Close menu"
className="absolute top-3 right-3 w-9 h-9 rounded-md text-on-navy-muted hover:text-on-navy flex items-center justify-center bg-transparent border-none cursor-pointer"
>
<X size={20} />
</button>
<SidebarInner items={visibleNav} activeTo={navMatch?.to ?? ''} onNav={(to) => navigate(to)} />
</aside>
</div>
)}
<div className="flex-1 flex flex-col min-w-0">
<Topbar title={active.title} subtitle={subtitle} onMenu={() => setNavOpen(true)} showBack={!navMatch} />
<main className="flex-1 p-4 lg:p-7">
<Outlet />
</main>
</div>
</div>
);
}

4
src/lib/cn.ts Normal file
View File

@ -0,0 +1,4 @@
/** Join truthy class names. Tiny helper — no dependency needed. */
export function cn(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(' ');
}

179
src/lib/kyc-match.ts Normal file
View File

@ -0,0 +1,179 @@
// KYC cross-check — pure, client-side. Compares the name + DOB the OCR reads off
// the PAN / Aadhaar against the name + DOB captured for the lead.
//
// Why client-side: the OCR'd name/DOB exist for one moment only — in the browser,
// right after `/ocr-extract` returns. They are never persisted (pan_doc/aadhaar_doc
// hold only file metadata; only the *number* is stored). So the check runs here, at
// collection time. See ai-employee-leadtopolicy/43_doc_ocr_crosscheck_plan.md.
export type Verdict = 'match' | 'mismatch' | 'unknown';
export type DocKey = 'pan' | 'aadhaar';
export interface DocExtract {
name?: string | null;
dob?: string | null; // OCR returns YYYY-MM-DD per the deployed ocr_config
}
export interface FieldVerdict {
doc: DocKey;
field: 'name' | 'dob';
status: Verdict;
captured: string;
ocr: string;
score?: number; // name only
}
export interface CrossCheck {
fields: FieldVerdict[];
hasMismatch: boolean; // any field is a mismatch
anyChecked: boolean; // any field is match|mismatch (i.e. OCR produced something)
}
// ── name matching ────────────────────────────────────────────────────────────
const HONORIFICS = new Set(['mr', 'mrs', 'ms', 'dr', 'shri', 'smt', 'kumari']);
// Lowercase, strip non-letters, drop honorifics, sort tokens (order-insensitive).
export function normName(s?: string | null): string {
if (!s) return '';
return String(s)
.toLowerCase()
.replace(/[^a-z\s]/g, ' ')
.split(/\s+/)
.filter((t) => t && !HONORIFICS.has(t))
.sort()
.join(' ')
.trim();
}
function levenshtein(a: string, b: string): number {
if (a === b) return 0;
if (!a.length) return b.length;
if (!b.length) return a.length;
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
for (let i = 1; i <= a.length; i++) {
const curr = [i];
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
}
prev = curr;
}
return prev[b.length];
}
function ratio(a: string, b: string): number {
const m = Math.max(a.length, b.length);
return m === 0 ? 1 : 1 - levenshtein(a, b) / m;
}
// Two name tokens are compatible if equal, one is an initial prefixing the other,
// or they are within a small edit distance (catches OCR typos like rabi/ravi → no).
function tokenMatch(x: string, y: string): boolean {
if (x === y) return true;
if ((x.length === 1 && y.startsWith(x)) || (y.length === 1 && x.startsWith(y))) return true;
return ratio(x, y) >= 0.85;
}
export const NAME_THRESHOLD = 0.82;
// Min of two-way token coverage: every token on each side must find a partner.
// Penalises both extra and missing tokens, so "Ravi" vs "Ravi Kumar" scores 0.5.
function nameScore(a: string, b: string): number {
const A = a.split(' ').filter(Boolean);
const B = b.split(' ').filter(Boolean);
if (!A.length || !B.length) return 0;
const cover = (P: string[], Q: string[]) => P.filter((p) => Q.some((q) => tokenMatch(p, q))).length / P.length;
return Math.min(cover(A, B), cover(B, A));
}
export function nameVerdict(captured?: string | null, ocr?: string | null): { status: Verdict; score: number } {
const a = normName(captured);
const b = normName(ocr);
if (!a || !b) return { status: 'unknown', score: 0 };
if (a === b) return { status: 'match', score: 1 };
const score = nameScore(a, b);
return { status: score >= NAME_THRESHOLD ? 'match' : 'mismatch', score };
}
// ── DOB matching ─────────────────────────────────────────────────────────────
interface Ymd {
y: number;
m: number;
d: number;
}
// Captured DOB is an IST-midnight instant (the date input runs in IST, so a picked
// "1990-05-15" is stored as "1990-05-14T18:30:00.000Z"). Recover the calendar date
// the user actually picked by reading the instant in Asia/Kolkata — pinned explicitly
// so the verdict is identical regardless of the host/browser timezone.
const IST_FMT = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Kolkata',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
function capturedCalendar(iso?: string | null): Ymd | null {
if (!iso) return null;
const dt = new Date(iso);
if (Number.isNaN(dt.getTime())) return null;
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(IST_FMT.format(dt)); // en-CA → "YYYY-MM-DD"
if (!m) return null;
return { y: +m[1], m: +m[2], d: +m[3] };
}
// Parse the OCR DOB *literally* — never `new Date(ymd)`, which treats a date-only
// string as UTC midnight and tz-shifts the day. Accept YYYY-MM-DD (the config spec)
// and the DD/MM/YYYY a card may print.
function parseOcrDob(s?: string | null): Ymd | null {
if (!s) return null;
const t = String(s).trim();
let m = /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/.exec(t);
if (m) return { y: +m[1], m: +m[2], d: +m[3] };
m = /^(\d{1,2})[-/](\d{1,2})[-/](\d{4})/.exec(t);
if (m) return { y: +m[3], m: +m[2], d: +m[1] };
return null;
}
export function dobVerdict(capturedIso?: string | null, ocrDob?: string | null): Verdict {
const cap = capturedCalendar(capturedIso);
const ocr = parseOcrDob(ocrDob);
if (!cap || !ocr) return 'unknown';
return cap.y === ocr.y && cap.m === ocr.m && cap.d === ocr.d ? 'match' : 'mismatch';
}
// Human-readable captured DOB (YYYY-MM-DD) for banner text.
export function capturedDobLabel(iso?: string | null): string {
const c = capturedCalendar(iso);
if (!c) return '';
return `${c.y}-${String(c.m).padStart(2, '0')}-${String(c.d).padStart(2, '0')}`;
}
// ── aggregate ──────────────────────────────────────────────────────────────
export function crossCheck(
captured: { name?: string | null; dob?: string | null },
docs: Partial<Record<DocKey, DocExtract>>,
): CrossCheck {
const fields: FieldVerdict[] = [];
(Object.keys(docs) as DocKey[]).forEach((doc) => {
const ex = docs[doc];
if (!ex) return;
const nv = nameVerdict(captured.name, ex.name);
if (nv.status !== 'unknown') {
fields.push({ doc, field: 'name', status: nv.status, score: nv.score, captured: captured.name ?? '', ocr: ex.name ?? '' });
}
const dv = dobVerdict(captured.dob, ex.dob);
if (dv !== 'unknown') {
fields.push({ doc, field: 'dob', status: dv, captured: capturedDobLabel(captured.dob), ocr: String(ex.dob ?? '') });
}
});
return {
fields,
hasMismatch: fields.some((f) => f.status === 'mismatch'),
anyChecked: fields.length > 0,
};
}

115
src/lib/recommend.ts Normal file
View File

@ -0,0 +1,115 @@
// Faithful port of the Recommend Product form's designer `custom_js`
// (activity_data_fields → recommended_product_input / sum_assured_input).
// The standard renderer runs that JS on field-change and writes
// `sa_valid_input` + the rate-card `premium_amount_input`. The bespoke
// RecommendBody must replicate it, or the Meeting Scheduled → Product
// Recommended stage gate (rule d44a9000: `sa_valid is_true`) never passes and
// the lead is silently stuck. Verified against workflow e29c3c33 / app 385.
import type { ProductLookup } from '../api/types';
export interface RecommendCalc {
/** Rate-card (or income-pct) premium — what the designer writes to premium_amount_input. */
premium: number;
/** Drives the stage gate. True only when age, sum-assured bounds and income multiple all pass. */
saValid: boolean;
age: number | null;
okAge: boolean;
okBounds: boolean;
okMult: boolean;
/** True once a plan + sum assured are present (so callers can show validity). */
evaluated: boolean;
}
const num = (v: unknown): number => Number(v) || 0;
function ageFromDob(dob?: string | null): number | null {
if (!dob) return null;
const b = new Date(dob);
if (Number.isNaN(b.getTime())) return null;
const n = new Date();
let age = n.getFullYear() - b.getFullYear();
if (n < new Date(n.getFullYear(), b.getMonth(), b.getDate())) age--;
return age;
}
export function recommendCalc(
plan: ProductLookup | null | undefined,
dob: string | null | undefined,
sumAssured: number,
annualIncome: number | null | undefined,
): RecommendCalc {
const age = ageFromDob(dob);
const sa = num(sumAssured);
const inc = num(annualIncome);
const blank: RecommendCalc = { premium: 0, saValid: false, age, okAge: false, okBounds: false, okMult: false, evaluated: false };
if (!plan || typeof plan !== 'object' || age == null || !sa) return blank;
const p = plan as Record<string, unknown>;
let premium = 0;
if (p.premium_basis === 'rate_card') {
let bands: Record<string, number> = {};
try {
bands =
typeof p.term_rate_bands === 'string'
? (JSON.parse(p.term_rate_bands) as Record<string, number>)
: ((p.term_rate_bands as Record<string, number>) ?? {});
} catch {
bands = {};
}
const band = age <= 30 ? '18-30' : age <= 40 ? '31-40' : age <= 50 ? '41-50' : '51-65';
premium = Math.round((sa / 1000) * num(bands[band]));
} else {
premium = Math.round(inc * num(p.premium_income_pct));
}
const okAge =
(p.entry_age_min == null || age >= num(p.entry_age_min)) &&
(p.entry_age_max == null || age <= num(p.entry_age_max));
const okBounds = sa >= num(p.min_sum_assured) && (!p.max_sum_assured || sa <= num(p.max_sum_assured));
const okMult =
!inc || (sa >= inc * num(p.sa_income_multiple_min) && sa <= inc * num(p.sa_income_multiple_max));
return { premium, saValid: okAge && okBounds && okMult, age, okAge, okBounds, okMult, evaluated: true };
}
/**
* Normalize a rider list into clean string tokens. A Postgres text[] can reach
* the client in three shapes: a real JSON array `["critical_illness", …]`, a
* plain CSV string, or an array-literal string `"{critical_illness,accidental_death}"`.
* Strip surrounding braces + per-token quotes, trim, drop empties.
*/
export function normalizeRiderValues(raw: unknown): string[] {
if (raw == null) return [];
const parts = Array.isArray(raw)
? raw.map((v) => String(v))
: String(raw)
.replace(/^\s*\{|\}\s*$/g, '') // strip the surrounding `{ }` of an array literal
.split(',');
return parts
.map((s) => s.trim().replace(/^["']|["']$/g, '').trim()) // drop quotes around tokens
.filter(Boolean);
}
/**
* Derive the rider values a product permits.
* - key ABSENT null: a legacy value saved before allowed_riders existed; the
* caller should fall back to showing ALL rider options (don't hide saved riders).
* - key PRESENT the normalized list (possibly empty = the plan truly allows none).
*/
export function allowedRidersFor(product: Record<string, unknown> | null | undefined): string[] | null {
if (!product || typeof product !== 'object') return null;
if (!('allowed_riders' in product)) return null; // legacy value — no key at all
return normalizeRiderValues(product.allowed_riders);
}
/** Human reasons a recommendation fails validation (for an inline hint). */
export function saValidIssues(c: RecommendCalc): string[] {
if (!c.evaluated || c.saValid) return [];
const out: string[] = [];
if (!c.okAge) out.push('applicant age outside the plans entry-age band');
if (!c.okBounds) out.push('sum assured outside the plans min/max');
if (!c.okMult) out.push('sum assured outside the allowed income multiple');
return out;
}

117
src/lib/underwriting.ts Normal file
View File

@ -0,0 +1,117 @@
// Underwriting decision-support — pure, client-side. No backend call.
// Mirrors the rules described in 40_underwriting_routing_plan.md (Pri 5a):
// financial: sum_assured vs (income × cap) medical: sum_assured vs NML(age)
import type { LeadRecord } from '../api/types';
// Max sum-assured as a multiple of annual income before financial underwriting kicks in.
// Uniform 20× today (matches tbl_products.sa_income_multiple_max seed).
export const CAP = 20;
// Non-medical-limit grid: max SA (₹) allowed without medical tests, by age band.
// TODO: replace with the real ABSLI non-medical-limit grid before go-live (PLACEHOLDER).
export const NML_GRID: ReadonlyArray<{ maxAge: number; nml: number }> = [
{ maxAge: 35, nml: 10_000_000 },
{ maxAge: 45, nml: 5_000_000 },
{ maxAge: 55, nml: 3_000_000 },
{ maxAge: 200, nml: 1_000_000 },
];
export type IncomeSource = 'verified' | 'declared' | 'none';
export interface UwAssessment {
ok: boolean; // enough data to say anything
age: number | null;
sumAssured: number;
income: number;
incomeSource: IncomeSource;
incomeMultiple: number | null;
cap: number;
financialRequired: boolean;
nml: number | null;
medicalRequired: boolean;
route: string[];
financialText: string;
medicalText: string;
}
function ageFrom(dob?: string | null): number | null {
if (!dob) return null;
const b = new Date(dob);
if (Number.isNaN(b.getTime())) return null;
const n = new Date();
let age = n.getFullYear() - b.getFullYear();
if (n < new Date(n.getFullYear(), b.getMonth(), b.getDate())) age--;
return age;
}
function nmlFor(age: number): number {
for (const band of NML_GRID) if (age <= band.maxAge) return band.nml;
return NML_GRID[NML_GRID.length - 1].nml;
}
type AssessInput = Pick<
LeadRecord,
'date_of_birth' | 'annual_income' | 'verified_income' | 'sum_assured'
>;
export function assess(lead: AssessInput): UwAssessment {
const age = ageFrom(lead.date_of_birth);
const sumAssured = Number(lead.sum_assured) || 0;
const verified = Number(lead.verified_income) || 0;
const declared = Number(lead.annual_income) || 0;
const income = verified > 0 ? verified : declared;
const incomeSource: IncomeSource = verified > 0 ? 'verified' : declared > 0 ? 'declared' : 'none';
// financial axis
let incomeMultiple: number | null = null;
let financialRequired = false;
let financialText: string;
if (income <= 0) {
financialText = 'Income unverified — cannot assess affordability';
} else {
incomeMultiple = sumAssured / income;
financialRequired = incomeMultiple > CAP;
const verdict = financialRequired
? 'OVER → financial UW'
: incomeMultiple >= CAP * 0.9
? 'at limit'
: 'within limit';
financialText = `${incomeMultiple.toFixed(1)}× ${incomeSource} income (cap ${CAP}×) — ${verdict}`;
}
// medical axis
let nml: number | null = null;
let medicalRequired = false;
let medicalText: string;
if (age == null) {
medicalText = 'DOB missing — cannot assess medical limit';
} else {
nml = nmlFor(age);
medicalRequired = sumAssured > nml;
medicalText = `age ${age}, NML ₹${nml.toLocaleString('en-IN')}${
medicalRequired ? 'SA over → medical tests required' : 'non-medical'
}`;
}
const route: string[] = [];
if (medicalRequired) route.push('medical');
if (financialRequired) route.push('financial');
if (route.length === 0) route.push('non-medical');
const ok = sumAssured > 0 && (age != null || income > 0);
return {
ok,
age,
sumAssured,
income,
incomeSource,
incomeMultiple,
cap: CAP,
financialRequired,
nml,
medicalRequired,
route,
financialText,
medicalText,
};
}

21
src/main.tsx Normal file
View File

@ -0,0 +1,21 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
import { ZinoProvider } from './api/provider';
import './styles/styles.css';
const baseUrl = import.meta.env.VITE_ZINO_API_URL || 'https://dev.getzino.in';
// Keep client routes under Vite's base (e.g. /lead-to-policy) so a refresh on
// an in-app route hits the preview's per-slug SPA fallback. '/' in dev.
const routerBase = import.meta.env.BASE_URL.replace(/\/$/, '');
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ZinoProvider baseUrl={baseUrl}>
<BrowserRouter basename={routerBase}>
<App />
</BrowserRouter>
</ZinoProvider>
</StrictMode>,
);

166
src/pages/Login.tsx Normal file
View File

@ -0,0 +1,166 @@
import { useState } from 'react';
import type { FormEvent } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { ArrowRight, CalendarClock, Eye, EyeOff, Lock, Mail, ShieldCheck, Sparkles } from 'lucide-react';
import { Button } from '../components';
import { ZinoLogo } from '../components/ZinoLogo';
import { useZino } from '../api/provider';
const HIGHLIGHTS = [
{ Icon: Sparkles, title: 'AI employees do the legwork', body: 'Aria qualifies, engages and schedules leads autonomously.' },
{ Icon: ShieldCheck, title: 'Capture → underwrite → issue', body: 'The whole policy lifecycle in one guided console.' },
{ Icon: CalendarClock, title: 'Live pipeline & agent scheduling', body: 'Every lead, meeting and decision as it happens.' },
];
function Field({
label,
icon,
trailing,
...rest
}: {
label: string;
icon: React.ReactNode;
trailing?: React.ReactNode;
} & React.InputHTMLAttributes<HTMLInputElement>) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-on-navy-muted">{label}</span>
<div className="flex items-center gap-2.5 h-[48px] px-3.5 rounded-xl bg-white/[0.07] border border-white/15 transition-colors duration-150 focus-within:border-sunrise-400/70 focus-within:bg-white/[0.12]">
<span className="text-on-navy-muted shrink-0">{icon}</span>
<input
className="flex-1 w-full bg-transparent border-none outline-none text-base text-on-navy placeholder:text-white/35"
{...rest}
/>
{trailing}
</div>
</label>
);
}
export function Login() {
const { login } = useZino();
const navigate = useNavigate();
const location = useLocation();
const from = (location.state as { from?: string })?.from ?? '/pipeline';
const [email, setEmail] = useState('admin@getzino.com');
const [password, setPassword] = useState('Zino');
const [show, setShow] = useState(false);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function onSubmit(e: FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
await login(email, password);
navigate(from, { replace: true });
} catch (err) {
setError(err instanceof Error ? err.message : ((err as { message?: string })?.message ?? 'Login failed'));
} finally {
setBusy(false);
}
}
return (
<div className="relative min-h-screen overflow-hidden bg-navy-grad font-sans flex items-center justify-center p-6">
{/* subtle grid, faded toward the edges */}
<div
className="absolute inset-0 pointer-events-none"
style={{
backgroundImage:
'linear-gradient(to right, rgba(255,255,255,0.04) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.04) 1px, transparent 1px)',
backgroundSize: '52px 52px',
maskImage: 'radial-gradient(ellipse 70% 60% at 50% 45%, #000 40%, transparent 100%)',
WebkitMaskImage: 'radial-gradient(ellipse 70% 60% at 50% 45%, #000 40%, transparent 100%)',
}}
/>
{/* one soft static glow for warmth */}
<div className="absolute top-[-15%] left-1/2 -translate-x-1/2 w-[640px] h-[440px] rounded-full bg-sunrise-500/12 blur-[140px] pointer-events-none" />
{/* one glass container holding the brand story (left) + the form (right) */}
<div className="relative w-full max-w-[920px] rounded-2xl bg-white/[0.07] backdrop-blur-2xl border border-white/15 shadow-2xl overflow-hidden grid lg:grid-cols-2">
{/* brand story */}
<div className="hidden lg:flex flex-col gap-9 p-10 border-r border-white/10">
<div className="flex items-center gap-3">
<ZinoLogo className="h-9 w-auto" />
<div className="text-xs text-on-navy-muted border-l border-white/15 pl-3">Lead Management</div>
</div>
<h1 className="m-0 text-[34px] leading-[1.12] font-extrabold tracking-[-0.025em] text-on-navy">
Your AI sales floor
<br />
for insurance.
</h1>
<div className="flex flex-col gap-5 mt-auto">
{HIGHLIGHTS.map((h) => (
<div key={h.title} className="flex items-start gap-3.5">
<span className="w-9 h-9 shrink-0 rounded-[10px] bg-white/10 border border-white/10 flex items-center justify-center text-sunrise-300">
<h.Icon size={18} />
</span>
<div className="min-w-0">
<div className="text-sm font-semibold text-on-navy">{h.title}</div>
<div className="text-xs text-on-navy-muted mt-0.5">{h.body}</div>
</div>
</div>
))}
</div>
</div>
{/* form side */}
<div className="p-8 lg:p-10 flex flex-col gap-7 justify-center">
<div className="flex flex-col items-center gap-3 text-center lg:items-start lg:text-left">
<span className="w-12 h-12 rounded-2xl bg-sunrise shadow-sunrise flex items-center justify-center text-white font-extrabold text-2xl font-numeric lg:hidden">
A
</span>
<div>
<div className="text-xl font-extrabold tracking-[-0.02em] text-on-navy">Welcome back</div>
<div className="text-sm text-on-navy-muted mt-0.5">Sign in to your console</div>
</div>
</div>
<form onSubmit={onSubmit} className="flex flex-col gap-4">
<Field
label="Email"
icon={<Mail size={16} />}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="username"
placeholder="you@company.com"
/>
<Field
label="Password"
icon={<Lock size={16} />}
type={show ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
placeholder="••••••••"
trailing={
<button
type="button"
onClick={() => setShow((s) => !s)}
aria-label={show ? 'Hide password' : 'Show password'}
className="flex items-center justify-center text-on-navy-muted hover:text-on-navy cursor-pointer bg-transparent border-none p-0 shrink-0"
>
{show ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
}
/>
{error && (
<div className="text-sm text-amber-200 bg-ruby-600/20 border border-ruby-400/30 rounded-md px-3 py-2">
{error}
</div>
)}
<Button variant="primary" full size="lg" disabled={busy} iconRight={!busy && <ArrowRight size={18} />}>
{busy ? 'Signing in…' : 'Sign in'}
</Button>
</form>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,266 @@
import { useMemo } from 'react';
import type { ReactNode } from 'react';
import { useParams } from 'react-router-dom';
import { Ban, CheckCircle2, CircleDashed, Download } from 'lucide-react';
import {
AIDecisionStream,
Avatar,
Badge,
Card,
ChannelBadge,
formatINR,
SegmentBadge,
TimelineEntry,
WorkflowStepper,
} from '../components';
import { LeadActions, STEP_BY_STATE } from '../components/activities';
import { useQuery, useZino } from '../api/provider';
import { useLeads } from '../api/leads';
import { recordToLead, decisionToCard, auditToTimeline } from '../api/adapters';
import { STAGES, ONBOARD_ACTIVITY_UID } from '../api/config';
import { DISQUALIFY_STATES } from '../api/forms';
// Compact Indian currency for the small stat tiles, so large cover figures
// (₹2,00,00,000) never overflow + get clipped by the card. Crore/lakh short
// form above ₹1L; full grouping below.
function compactINR(n: number): string {
if (!n) return '₹0';
if (n >= 1e7) return `${+(n / 1e7).toFixed(2)} Cr`;
if (n >= 1e5) return `${+(n / 1e5).toFixed(2)} L`;
return `${formatINR(n)}`;
}
/** A compact label/value tile for the Profile grid fills the wide card width
* far better than full-width keyvalue rows. */
function ProfileTile({ label, value, mono }: { label: string; value: ReactNode; mono?: boolean }) {
return (
<div className="min-w-0 rounded-md bg-slate-50 px-3.5 py-3">
<div className="text-2xs text-faint mb-1.5">{label}</div>
<div className={mono ? 'truncate text-sm font-semibold text-strong font-mono' : 'truncate text-sm font-semibold text-strong'}>
{value}
</div>
</div>
);
}
export function Lead360Screen() {
const { id } = useParams();
const { client } = useZino();
// Shared single fetch of all leads; pick the one we're viewing.
const { records, loading: leadsLoading, refetch } = useLeads();
const record = useMemo(
() => records.find((r) => String(r.instance_id) === id) ?? records[0],
[records, id],
);
const instanceId = record?.instance_id;
// No polling — async AI decisions are pulled on demand via the stream's
// refresh button (and after any activity advance), so the view never flickers.
const decisionsQ = useQuery(() => client.aiDecisions(instanceId!), [instanceId], instanceId != null);
const auditQ = useQuery(() => client.audit(instanceId!), [instanceId], instanceId != null);
const onAdvanced = () => {
refetch();
decisionsQ.refetch();
auditQ.refetch();
};
// Manual refresh of the live AI feed + audit (no reload of the whole lead set).
const refreshFeed = () => {
decisionsQ.refetch();
auditQ.refetch();
};
if (leadsLoading) {
return <div className="p-10 text-center text-sm text-faint">Loading lead</div>;
}
if (!record) {
return <div className="p-10 text-center text-sm text-faint">Lead not found.</div>;
}
const lead = recordToLead(record);
const decisions = (decisionsQ.data ?? []).map(decisionToCard);
const audit = auditQ.data ?? [];
const timeline = auditToTimeline(audit);
// 5d onboarding outcome: welcome_email_status + policy_doc are written by the
// Onboard trigger onto the lead record. onboarding_notes is still a `local`
// activity field, so it lives in the audit feed, not the recordview.
const onboard = audit.find((e) => e.activity_id === ONBOARD_ACTIVITY_UID)?.data;
const welcomeStatus =
typeof record.welcome_email_status === 'string' ? record.welcome_email_status : null;
const policyDocUrl = record.policy_doc?.[0]?.url ?? null;
const onboarded = !!onboard || !!welcomeStatus || !!policyDocUrl;
const welcomeSent = welcomeStatus === 'sent';
const welcomeFailed = welcomeStatus === 'failed';
const onboardingNotes =
typeof onboard?.onboarding_notes === 'string' ? onboard.onboarding_notes : '';
const issueDate = record.policy_issue_date ? new Date(record.policy_issue_date) : null;
const issueLabel = issueDate && !Number.isNaN(issueDate.getTime())
? issueDate.toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })
: null;
const cover = record.sum_assured ?? 0;
const premium = record.premium_amount ?? 0;
const meeting = record.meeting_datetime ? new Date(record.meeting_datetime) : null;
const meetingLabel = meeting && !Number.isNaN(meeting.getTime())
? meeting.toLocaleString('en-IN', { weekday: 'short', day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit', hour12: true })
: null;
const issued = lead.stage >= STAGES.indexOf('Policy Issued');
const state = record.current_state_name ?? '';
const hasAction = !!STEP_BY_STATE[state] || DISQUALIFY_STATES.has(state);
return (
<div className="mx-auto flex w-full max-w-[1320px] flex-col gap-[18px]">
{/* HERO identity + lifecycle + headline figures unified into one band so
the page reads as a single record, not a scatter of tiles. */}
<Card bodyClassName="p-0">
<div className="flex flex-col gap-5 p-5">
<div className="flex flex-wrap items-center gap-4">
<Avatar name={lead.name} size={56} className="shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-xl font-bold text-strong truncate">{lead.name}</span>
<SegmentBadge segment={lead.segment} />
<ChannelBadge channel={lead.channel} />
</div>
<div className="text-xs text-faint mt-1">
#{lead.id} · {lead.city} · owned by {lead.owner}
{meetingLabel ? ` · meeting ${meetingLabel}` : ''}
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Badge tone={issued ? 'success' : 'warning'} dot>
{record.current_state_name ?? '—'}
</Badge>
{hasAction && <LeadActions record={record} onDone={onAdvanced} />}
</div>
</div>
{/* lifecycle stepper */}
<div className="overflow-x-auto pb-1">
<WorkflowStepper stages={STAGES} current={Math.max(0, lead.stage)} />
</div>
{/* headline figures */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div className="min-w-0 bg-slate-50 rounded-md px-3.5 py-3">
<div className="text-2xs text-faint mb-1.5">Sum assured</div>
<div className="font-numeric text-2xl font-extrabold leading-none text-strong nums truncate">{compactINR(cover)}</div>
</div>
<div className="min-w-0 bg-slate-50 rounded-md px-3.5 py-3">
<div className="text-2xs text-faint mb-1.5">Premium</div>
<div className="font-numeric text-2xl font-extrabold leading-none text-sunrise-600 nums truncate">{compactINR(premium)}</div>
</div>
<div className="min-w-0 bg-slate-50 rounded-md px-3.5 py-3 col-span-2 sm:col-span-1">
<div className="text-2xs text-faint mb-1.5">Annual income</div>
<div className="font-numeric text-2xl font-extrabold leading-none text-strong nums truncate">{compactINR(lead.income)}</div>
</div>
</div>
</div>
</Card>
{/* BODY — primary work on the left, live AI feed + reference on the right. */}
<div className="grid items-start gap-[18px] grid-cols-1 lg:grid-cols-[1fr_360px]">
{/* MAIN */}
<div className="flex flex-col gap-[18px] min-w-0">
<Card title="Profile">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
<ProfileTile label="Owner" value={lead.owner} />
<ProfileTile label="Phone" value={lead.phone} mono />
<ProfileTile label="Email" value={lead.email} />
<ProfileTile label="Date of birth" value={lead.age ? `${lead.dob} · ${lead.age} yrs` : lead.dob} />
<ProfileTile label="Occupation" value={lead.occupation} />
<ProfileTile label="Annual income" value={compactINR(lead.income)} />
<ProfileTile label="Preferred language" value={lead.language} />
<ProfileTile label="Product interest" value={<Badge tone="accent">{lead.interest}</Badge>} />
</div>
</Card>
{onboarded && (
<Card
title="Policy & onboarding"
action={
<Badge tone={welcomeSent ? 'success' : welcomeFailed ? 'danger' : 'warning'} dot>
{welcomeSent ? 'Welcome sent' : welcomeFailed ? 'Welcome failed' : 'Welcome pending'}
</Badge>
}
>
<div className="grid grid-cols-2 gap-3">
<div className="bg-slate-50 rounded-md px-3.5 py-3">
<div className="text-2xs text-faint mb-1.5">Policy number</div>
<div className="text-sm font-semibold text-strong font-mono">
{record.policy_number || '—'}
</div>
</div>
<div className="bg-slate-50 rounded-md px-3.5 py-3">
<div className="text-2xs text-faint mb-1.5">Issued</div>
<div className="text-sm font-semibold text-strong">{issueLabel || '—'}</div>
</div>
</div>
<div className="mt-3 flex items-center gap-2">
{welcomeSent ? (
<CheckCircle2 size={16} className="text-emerald-600 shrink-0" />
) : welcomeFailed ? (
<Ban size={16} className="text-ruby-600 shrink-0" />
) : (
<CircleDashed size={16} className="text-faint shrink-0" />
)}
<span className="text-sm text-strong">
Welcome email {welcomeSent ? 'sent' : welcomeFailed ? 'failed to send' : 'pending'}
</span>
</div>
{policyDocUrl && (
<div className="mt-3">
<a
href={policyDocUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm font-semibold text-sunrise-600 hover:underline"
>
<Download size={15} className="shrink-0" />
Download policy schedule
</a>
</div>
)}
{onboardingNotes && (
<div className="mt-3 pt-3 border-t border-border-subtle">
<div className="text-2xs text-faint mb-1.5">Onboarding notes</div>
<div className="text-sm text-muted whitespace-pre-wrap">{onboardingNotes}</div>
</div>
)}
</Card>
)}
<Card title="Activity log">
{timeline.length ? (
timeline.map((t, i) => (
<TimelineEntry
key={i}
actor={t.actor}
ai={t.ai}
action={t.action}
time={t.time}
tone={t.tone}
last={i === timeline.length - 1}
/>
))
) : (
<div className="text-sm text-faint">No activity recorded yet.</div>
)}
</Card>
</div>
{/* RAIL — live AI decision feed (manual refresh, no polling). */}
<div className="flex flex-col gap-[18px] min-w-0">
<AIDecisionStream
decisions={decisions}
loading={decisionsQ.loading}
onRefresh={refreshFeed}
/>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,302 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Columns3, Table } from 'lucide-react';
import { cn } from '../lib/cn';
import { Badge, Card, KpiCard, LeadRow, LEAD_GRID, Pagination } from '../components';
import type { Kpi, Lead, SegmentDatum } from '../types';
import { useLeads } from '../api/leads';
import { STAGES } from '../api/config';
import { AddLeadButton } from '../components/activities';
import { LeadFilters } from '../components/LeadFilters';
import { recordToLead } from '../api/adapters';
import { applyFilters, EMPTY_FILTER, type FilterState } from '../api/filters';
const PAGE_SIZE = 10;
// Funnel milestones (subset of STAGES) — count = leads that have *reached* it.
const FUNNEL_STEPS: Array<{ label: string; stage: string }> = [
{ label: 'New Lead', stage: 'New Lead' },
{ label: 'Qualified', stage: 'Qualified' },
{ label: 'Assigned', stage: 'Assigned' },
{ label: 'Contacted', stage: 'Contacted' },
{ label: 'Meeting', stage: 'Meeting Scheduled' },
{ label: 'Recommended', stage: 'Product Recommended' },
{ label: 'Underwriting', stage: 'Underwriting' },
{ label: 'Issued', stage: 'Policy Issued' },
];
const SEGMENT_COLORS: Record<string, string> = {
Hot: 'var(--sunrise-500)',
Warm: 'var(--amber-500)',
Cold: 'var(--slate-400)',
};
function buildKpis(leads: Lead[]): Kpi[] {
const total = leads.length || 1;
const hot = leads.filter((l) => l.segment === 'Hot').length;
const issuedIdx = STAGES.indexOf('Policy Issued');
const issued = leads.filter((l) => l.stage >= issuedIdx && l.stage >= 0).length;
const aiHandled = leads.filter((l) => l.ownerAi).length;
return [
{ label: 'Total leads', value: String(leads.length) },
{ label: 'Hot leads', value: String(Math.round((hot / total) * 100)), unit: '%' },
{ label: 'Conversion rate', value: String(Math.round((issued / total) * 100)), unit: '%' },
{ label: 'AI-handled', value: String(Math.round((aiHandled / total) * 100)), unit: '%', accent: true },
];
}
function buildFunnel(leads: Lead[]) {
const total = leads.length || 1;
return FUNNEL_STEPS.map((step) => {
const idx = STAGES.indexOf(step.stage as (typeof STAGES)[number]);
const count = leads.filter((l) => l.stage >= idx).length;
return { stage: step.label, count, pct: Math.round((count / total) * 100) };
});
}
function buildSegments(leads: Lead[]): SegmentDatum[] {
return (['Hot', 'Warm', 'Cold'] as const).map((label) => ({
label,
count: leads.filter((l) => l.segment === label).length,
color: SEGMENT_COLORS[label],
}));
}
function FunnelChart({ data }: { data: ReturnType<typeof buildFunnel> }) {
return (
<div className="flex flex-col gap-2.5">
{data.map((f) => (
<div key={f.stage} className="flex items-center gap-3">
<span className="w-24 shrink-0 text-xs font-medium text-muted text-right">{f.stage}</span>
<div className="flex-1 h-[26px] bg-slate-100 rounded-sm overflow-hidden">
<div
className="h-full bg-sunrise rounded-sm flex items-center justify-end pr-2.5 transition-[width] duration-700"
style={{ width: `${Math.max(f.pct, 6)}%` }}
>
<span className="text-2xs font-bold text-white nums">{f.count.toLocaleString('en-IN')}</span>
</div>
</div>
</div>
))}
</div>
);
}
function SegmentDonut({ segments }: { segments: SegmentDatum[] }) {
const total = segments.reduce((a, b) => a + b.count, 0) || 1;
let acc = 0;
const stops = segments
.map((s) => {
const start = (acc / total) * 360;
acc += s.count;
const end = (acc / total) * 360;
return `${s.color} ${start}deg ${end}deg`;
})
.join(',');
return (
<div className="flex items-center gap-5">
<div className="w-[120px] h-[120px] rounded-full relative shrink-0" style={{ background: `conic-gradient(${stops})` }}>
<div className="absolute inset-3.5 rounded-full bg-card flex flex-col items-center justify-center">
<span className="font-numeric font-extrabold text-2xl text-strong leading-none">
{segments.reduce((a, b) => a + b.count, 0).toLocaleString('en-IN')}
</span>
<span className="text-[10px] text-faint font-semibold">active</span>
</div>
</div>
<div className="flex flex-col gap-2.5 flex-1">
{segments.map((s) => (
<div key={s.label} className="flex items-center gap-2.5">
<span className="w-2.5 h-2.5 rounded-[3px] shrink-0" style={{ background: s.color }} />
<span className="text-sm font-semibold text-body flex-1">{s.label}</span>
<span className="font-numeric font-bold text-md text-strong nums">{s.count.toLocaleString('en-IN')}</span>
</div>
))}
</div>
</div>
);
}
// Minimal card — monochrome text with a single segment dot as the only accent
// (segment already encodes hot/warm/cold, so the score stays neutral).
function KanbanCard({ lead, onClick }: { lead: Lead; onClick: () => void }) {
return (
<div
onClick={onClick}
className="bg-card border border-border-subtle rounded-md shadow-xs p-3 cursor-pointer flex flex-col gap-1.5 transition-all duration-150 hover:shadow-md hover:-translate-y-px"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-sm font-semibold text-strong truncate">{lead.name}</div>
<div className="text-2xs text-faint truncate">
{[lead.city, lead.channel].filter(Boolean).join(' · ')}
</div>
</div>
<span className="font-numeric font-bold text-sm text-muted nums shrink-0">{lead.score}</span>
</div>
<div className="flex items-center gap-1.5 text-2xs text-faint min-w-0">
<span
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ background: SEGMENT_COLORS[lead.segment] ?? 'var(--slate-400)' }}
title={lead.segment}
/>
<span className="truncate">{lead.lastAction}</span>
</div>
</div>
);
}
function KanbanBoard({ leads, onOpenLead }: { leads: Lead[]; onOpenLead: (l: Lead) => void }) {
const cols = STAGES.slice(0, 8);
return (
<div className="flex gap-3.5 overflow-x-auto pb-1.5 scrollbar-slim">
{cols.map((stage, idx) => {
const items = leads.filter((l) => l.stage === idx);
return (
<div key={stage} className="shrink-0 w-[236px] flex flex-col gap-2.5">
<div className="flex items-center justify-between px-1 py-0.5">
<span className="text-xs font-bold text-strong">{stage}</span>
<span className="text-2xs font-bold text-faint bg-slate-100 rounded-pill min-w-5 text-center px-[7px] py-0.5 nums">
{items.length}
</span>
</div>
{/* Card list caps at viewport height and scrolls per-column so a
busy stage doesn't blow up the whole board's height. */}
<div className="flex flex-col gap-2.5 bg-slate-100 rounded-md p-2.5 min-h-[120px] max-h-[70vh] overflow-y-auto scrollbar-slim">
{items.length ? (
items.map((l) => <KanbanCard key={l.id} lead={l} onClick={() => onOpenLead(l)} />)
) : (
<div className="text-2xs text-faint text-center py-4">No leads</div>
)}
</div>
</div>
);
})}
</div>
);
}
function ViewToggle({ view, onChange }: { view: string; onChange: (v: string) => void }) {
const opts: Array<[string, string, typeof Table]> = [
['table', 'Table', Table],
['kanban', 'Kanban', Columns3],
];
return (
<div className="flex gap-0.5 bg-slate-100 rounded-md p-[3px]">
{opts.map(([id, label, Icon]) => (
<button
key={id}
onClick={() => onChange(id)}
className={cn(
'inline-flex items-center gap-1.5 border-none cursor-pointer font-sans text-xs font-semibold px-3 py-1.5 rounded-sm transition-all duration-150',
view === id ? 'bg-card text-strong shadow-xs' : 'bg-transparent text-muted',
)}
>
<Icon size={14} />
{label}
</button>
))}
</div>
);
}
export function PipelineScreen() {
const [view, setView] = useState('table');
const navigate = useNavigate();
const openLead = (l: Lead) => navigate(`/leads/${l.id}`);
const { leads, records, fields, loading, error, refetch } = useLeads();
const [filters, setFilters] = useState<FilterState>(EMPTY_FILTER);
// KPIs / funnel / segments summarize the whole pipeline; the list below is
// what the filter narrows.
const kpis = useMemo(() => buildKpis(leads), [leads]);
const funnel = useMemo(() => buildFunnel(leads), [leads]);
const segments = useMemo(() => buildSegments(leads), [leads]);
const visibleLeads = useMemo(
() => applyFilters(records, filters, fields).map(recordToLead),
[records, filters, fields],
);
// Table-view pagination (the kanban board shows every stage at once). Page is
// clamped in <Pagination> and reset when the filter set changes.
const [page, setPage] = useState(1);
useEffect(() => setPage(1), [filters]);
const start = (Math.min(page, Math.max(1, Math.ceil(visibleLeads.length / PAGE_SIZE))) - 1) * PAGE_SIZE;
const pageLeads = visibleLeads.slice(start, start + PAGE_SIZE);
return (
<div className="flex flex-col gap-5 max-w-[1240px] mx-auto">
{error && (
<div className="text-sm text-amber-700 bg-escalated-soft border border-amber-300 rounded-md px-4 py-3">
Couldnt load leads: {error}
</div>
)}
{/* KPI row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{kpis.map((k, i) => (
<KpiCard key={i} {...k} />
))}
</div>
{/* charts cards stay equal height (aligned bottoms); the shorter
segments content is vertically centered so there's no dead space. */}
<div className="grid grid-cols-1 lg:grid-cols-[1.7fr_1fr] gap-4">
<Card title="Pipeline funnel" action={<Badge tone="neutral">Live</Badge>}>
<FunnelChart data={funnel} />
</Card>
<Card title="Lead segments" className="flex flex-col" bodyClassName="flex-1 flex items-center">
<SegmentDonut segments={segments} />
</Card>
</div>
{/* leads — table or kanban */}
<Card
title="Leads"
pad={false}
action={
<div className="flex gap-2.5 items-center">
<ViewToggle view={view} onChange={setView} />
<AddLeadButton onDone={refetch} />
</div>
}
>
<LeadFilters records={records} fields={fields} value={filters} onChange={setFilters} />
{loading ? (
<div className="p-10 text-center text-sm text-faint">Loading leads</div>
) : view === 'table' ? (
<div className="overflow-x-auto">
<div className="min-w-[760px]">
<div
className={cn(
'grid gap-3 px-[18px] py-[11px] bg-slate-50 border-b border-border-subtle text-[10px] font-bold uppercase tracking-[0.06em] text-faint',
LEAD_GRID,
)}
>
<span>Lead</span>
<span>Score</span>
<span>Segment</span>
<span>Channel</span>
<span>Owner</span>
<span>Last action</span>
</div>
{pageLeads.length ? (
pageLeads.map((l) => <LeadRow key={l.id} lead={l} onClick={() => openLead(l)} />)
) : (
<div className="p-10 text-center text-sm text-faint">No leads match these filters.</div>
)}
</div>
</div>
) : (
<div className="p-5">
<KanbanBoard leads={visibleLeads} onOpenLead={openLead} />
</div>
)}
{view === 'table' && !loading && (
<Pagination page={page} pageSize={PAGE_SIZE} total={visibleLeads.length} onPage={setPage} />
)}
</Card>
</div>
);
}

View File

@ -0,0 +1,326 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import type { LucideIcon } from 'lucide-react';
import { Building2, CalendarClock, Home, MapPin, Phone, Search, Video } from 'lucide-react';
import { cn } from '../lib/cn';
import { Avatar, Badge } from '../components/core';
import { formatTime } from '../api/adapters';
import { useZino } from '../api/provider';
import { useMeetings, type Meeting } from '../api/meetings';
// Meeting-mode presentation. Falls back to a pin for unknown modes.
const MODE_META: Record<string, { Icon: LucideIcon; label: string; cls: string }> = {
video: { Icon: Video, label: 'Video call', cls: 'text-sky-600 bg-sky-100' },
phone: { Icon: Phone, label: 'Phone call', cls: 'text-emerald-600 bg-emerald-100' },
branch: { Icon: Building2, label: 'Branch visit', cls: 'text-sunrise-600 bg-sunrise-100' },
home_visit: { Icon: Home, label: 'Home visit', cls: 'text-amber-600 bg-amber-100' },
};
function modeMeta(mode: string) {
return MODE_META[mode] ?? { Icon: MapPin, label: mode || 'Meeting', cls: 'text-slate-600 bg-slate-100' };
}
type Range = 'today' | 'week' | 'all';
const RANGES: Array<[Range, string]> = [
['today', 'Today'],
['week', 'This week'],
['all', 'All'],
];
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function startOfDay(d: Date): Date {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}
function dayKey(iso: string): string {
const d = new Date(iso);
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
}
function dayLabel(iso: string, todayKey: string, tomorrowKey: string): string {
const k = dayKey(iso);
if (k === todayKey) return 'Today';
if (k === tomorrowKey) return 'Tomorrow';
const d = new Date(iso);
return `${DAYS[d.getDay()]} ${d.getDate()} ${MONTHS[d.getMonth()]}`;
}
function inRange(iso: string, range: Range, now: Date): boolean {
if (range === 'all') return true;
const t = new Date(iso).getTime();
const start = startOfDay(now).getTime();
if (range === 'today') return t >= start && t < start + 86400000;
return t >= start && t < start + 7 * 86400000; // this week = next 7 days
}
// Short "when is the next one" label for the agent rail.
function nextLabel(iso: string, now: Date, todayKey: string, tomorrowKey: string): string {
const diff = new Date(iso).getTime() - now.getTime();
if (diff >= 0 && diff < 3600000) return `in ${Math.max(1, Math.round(diff / 60000))}m`;
const k = dayKey(iso);
if (k === todayKey) return formatTime(iso);
if (k === tomorrowKey) return `Tom ${formatTime(iso)}`;
const d = new Date(iso);
return `${d.getDate()} ${MONTHS[d.getMonth()]}`;
}
interface RosterEntry {
agent: string;
upcoming: number;
total: number;
next: string | null; // ISO of next upcoming meeting
}
function MeetingRow({ m, past, onClick }: { m: Meeting; past: boolean; onClick: () => void }) {
const meta = modeMeta(m.mode);
return (
<button
onClick={onClick}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-md border border-border-subtle bg-card text-left',
'cursor-pointer transition-all duration-150 hover:shadow-sm hover:-translate-y-px',
past && 'opacity-60',
)}
>
<span className="font-numeric font-bold text-sm text-strong nums w-[68px] shrink-0">
{formatTime(m.datetime) || '—'}
</span>
<span className={cn('w-7 h-7 rounded-md flex items-center justify-center shrink-0', meta.cls)} title={meta.label}>
<meta.Icon size={15} />
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-strong truncate">{m.leadName}</div>
<div className="text-2xs text-faint truncate">
{meta.label}
{m.address ? ` · ${m.address}` : ''}
</div>
</div>
<Badge tone="neutral" size="sm" className="shrink-0">
{m.stage}
</Badge>
</button>
);
}
/** Left rail: searchable agent roster, sorted by upcoming load. */
function AgentRail({
roster,
selected,
onSelect,
now,
todayKey,
tomorrowKey,
}: {
roster: RosterEntry[];
selected: string | null;
onSelect: (a: string) => void;
now: Date;
todayKey: string;
tomorrowKey: string;
}) {
const [q, setQ] = useState('');
const items = useMemo(() => {
const s = q.trim().toLowerCase();
return s ? roster.filter((r) => r.agent.toLowerCase().includes(s)) : roster;
}, [roster, q]);
return (
<div className="flex flex-col rounded-lg border border-border-subtle bg-card overflow-hidden md:h-[calc(100vh-180px)]">
<div className="px-3 py-2.5 border-b border-border-subtle">
<div className="flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-slate-100">
<Search size={14} className="text-faint shrink-0" />
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search agents"
className="bg-transparent border-none outline-none text-sm text-body w-full placeholder:text-faint"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto p-1.5 flex flex-col gap-0.5 scrollbar-slim">
{items.length === 0 ? (
<div className="text-2xs text-faint text-center py-6">No agents match.</div>
) : (
items.map((r) => {
const on = r.agent === selected;
return (
<button
key={r.agent}
onClick={() => onSelect(r.agent)}
className={cn(
'w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md border-none cursor-pointer text-left transition-colors duration-150',
on ? 'bg-[rgba(251,169,76,0.14)]' : 'bg-transparent hover:bg-slate-50',
)}
>
<Avatar name={r.agent} size={30} />
<div className="min-w-0 flex-1">
<div className={cn('text-sm truncate', on ? 'font-bold text-strong' : 'font-semibold text-body')}>
{r.agent}
</div>
<div className="text-2xs text-faint truncate">
{r.next ? `Next · ${nextLabel(r.next, now, todayKey, tomorrowKey)}` : 'No upcoming'}
</div>
</div>
<span
className={cn(
'text-2xs font-bold rounded-pill min-w-5 text-center px-2 py-0.5 nums shrink-0',
r.upcoming > 0 ? 'bg-sunrise-100 text-sunrise-600' : 'bg-slate-100 text-faint',
)}
>
{r.upcoming}
</span>
</button>
);
})
)}
</div>
</div>
);
}
export function ScheduleScreen() {
const navigate = useNavigate();
const { user } = useZino();
const { meetings, loading, error } = useMeetings();
const [range, setRange] = useState<Range>('week');
const [selected, setSelected] = useState<string | null>(null);
const now = useMemo(() => new Date(), []);
const todayKey = dayKey(now.toISOString());
const tomorrowKey = dayKey(new Date(startOfDay(now).getTime() + 86400000).toISOString());
// Roster: one entry per agent, with upcoming load + next-meeting, load-sorted.
const roster = useMemo<RosterEntry[]>(() => {
const m = new Map<string, RosterEntry>();
for (const mt of meetings) {
const e = m.get(mt.agent) ?? { agent: mt.agent, upcoming: 0, total: 0, next: null };
e.total += 1;
if (new Date(mt.datetime).getTime() >= now.getTime()) {
e.upcoming += 1;
if (!e.next || new Date(mt.datetime).getTime() < new Date(e.next).getTime()) e.next = mt.datetime;
}
m.set(mt.agent, e);
}
return [...m.values()].sort((a, b) => {
if (a.agent === 'Unassigned') return 1;
if (b.agent === 'Unassigned') return -1;
return b.upcoming - a.upcoming || b.total - a.total;
});
}, [meetings, now]);
// Default selection: the logged-in user if they're an agent, else top of roster.
useEffect(() => {
if (selected || roster.length === 0) return;
const mine = user?.name ? roster.find((r) => r.agent.toLowerCase() === user.name.toLowerCase()) : undefined;
setSelected(mine?.agent ?? roster[0].agent);
}, [roster, selected, user]);
const detail = useMemo(() => {
if (!selected) return [];
return meetings
.filter((m) => m.agent === selected && inRange(m.datetime, range, now))
.sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime());
}, [meetings, selected, range, now]);
// Group the selected agent's meetings by day.
const days = useMemo(() => {
const byDay = new Map<string, Meeting[]>();
for (const m of detail) {
const k = dayKey(m.datetime);
byDay.set(k, [...(byDay.get(k) ?? []), m]);
}
return [...byDay.entries()].sort((a, b) => new Date(a[1][0].datetime).getTime() - new Date(b[1][0].datetime).getTime());
}, [detail]);
const sel = roster.find((r) => r.agent === selected);
return (
<div className="flex flex-col gap-4 max-w-[1180px] mx-auto">
{error && (
<div className="text-sm text-amber-700 bg-escalated-soft border border-amber-300 rounded-md px-4 py-3">
Couldnt load meetings: {error}
</div>
)}
{loading ? (
<div className="p-10 text-center text-sm text-faint">Loading meetings</div>
) : roster.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-16 text-center">
<CalendarClock size={28} className="text-faint" />
<div className="text-sm font-semibold text-muted">No meetings scheduled yet</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-[270px_1fr] gap-4">
<AgentRail
roster={roster}
selected={selected}
onSelect={setSelected}
now={now}
todayKey={todayKey}
tomorrowKey={tomorrowKey}
/>
{/* Detail: selected agent's agenda capped to the viewport so a long
agenda scrolls internally instead of growing the whole page. */}
<div className="flex flex-col gap-4 min-w-0 md:h-[calc(100vh-180px)]">
<div className="flex flex-wrap items-center gap-3 justify-between shrink-0">
<div className="flex items-center gap-3 min-w-0">
<Avatar name={selected ?? ''} size={40} />
<div className="min-w-0">
<h2 className="m-0 text-md font-bold text-strong truncate">{selected}</h2>
<div className="text-2xs text-faint">
{sel?.upcoming ?? 0} upcoming · {sel?.total ?? 0} total
</div>
</div>
</div>
<div className="flex gap-0.5 bg-slate-100 rounded-md p-[3px]">
{RANGES.map(([id, label]) => (
<button
key={id}
onClick={() => setRange(id)}
className={cn(
'border-none cursor-pointer font-sans text-xs font-semibold px-3.5 py-1.5 rounded-sm transition-all duration-150',
range === id ? 'bg-card text-strong shadow-xs' : 'bg-transparent text-muted',
)}
>
{label}
</button>
))}
</div>
</div>
{days.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-14 text-center rounded-lg border border-border-subtle bg-card">
<CalendarClock size={26} className="text-faint" />
<div className="text-sm font-semibold text-muted">No meetings in this range</div>
<div className="text-2xs text-faint">Try All, or pick another agent.</div>
</div>
) : (
<div className="flex flex-col gap-5 md:flex-1 md:min-h-0 md:overflow-y-auto md:pr-1 scrollbar-slim">
{days.map(([k, ms]) => (
<div key={k} className="flex flex-col gap-2.5">
<div className="flex items-center gap-3">
<h3 className="m-0 text-sm font-bold text-strong">{dayLabel(ms[0].datetime, todayKey, tomorrowKey)}</h3>
<div className="flex-1 h-px bg-border-subtle" />
<span className="text-2xs text-faint font-semibold nums">{ms.length} meetings</span>
</div>
<div className="flex flex-col gap-2">
{ms.map((m) => (
<MeetingRow
key={m.leadId}
m={m}
past={new Date(m.datetime).getTime() < now.getTime()}
onClick={() => navigate(`/leads/${m.leadId}`)}
/>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
);
}

83
src/screens/worklists.tsx Normal file
View File

@ -0,0 +1,83 @@
// The four operational worklist screens. Each lists the leads at the lifecycle
// states it owns; every row carries its one primary action (+ Disqualify),
// launched in a modal. Each screen shows a few compact stat tiles over its rows.
// (The Lead Pipeline screen is the read-only all-leads dashboard.)
import { Worklist, type TileSpec } from '../components/activities';
import { formatINR } from '../components';
import type { LeadRecord } from '../api/types';
const count = (rows: LeadRecord[]) => String(rows.length);
const countWhere = (pred: (r: LeadRecord) => boolean) => (rows: LeadRecord[]) => String(rows.filter(pred).length);
const sumINR = (col: keyof LeadRecord) => (rows: LeadRecord[]) =>
`${formatINR(rows.reduce((s, r) => s + (Number(r[col]) || 0), 0))}`;
const avg = (col: keyof LeadRecord) => (rows: LeadRecord[]) => {
const vals = rows.map((r) => Number(r[col])).filter((n) => !Number.isNaN(n));
return vals.length ? String(Math.round(vals.reduce((a, b) => a + b, 0) / vals.length)) : '—';
};
/** Qualify (New Lead) + Assign (Qualified). */
const QUALIFY_TILES: TileSpec[] = [
{ label: 'In queue', value: count },
{ label: 'Avg lead score', value: avg('lead_score') },
];
export function QualificationScreen() {
return (
<Worklist
states={['New Lead', 'Qualified']}
tiles={QUALIFY_TILES}
emptyHint="No new or qualified leads in the queue."
/>
);
}
/** Log Contact (Assigned) · Schedule (Contacted) · Recommend (Meeting Scheduled). */
const ENGAGE_TILES: TileSpec[] = [
{ label: 'In queue', value: count },
{ label: 'Meetings booked', value: countWhere((r) => !!r.meeting_mode) },
{ label: 'Total premium', value: sumINR('premium_amount') },
];
export function InteractionScreen() {
return (
<Worklist
states={['Assigned', 'Contacted', 'Meeting Scheduled']}
tiles={ENGAGE_TILES}
emptyHint="No leads awaiting contact, scheduling, or a recommendation."
/>
);
}
/** Collect Documents (Product Recommended) · Assess Eligibility (Documents Collected). */
const DOCS_TILES: TileSpec[] = [
{ label: 'In queue', value: count },
{ label: 'Docs collected', value: countWhere((r) => r.current_state_name === 'Documents Collected') },
];
export function DocAssessmentScreen() {
return (
<Worklist
states={['Product Recommended', 'Documents Collected']}
tiles={DOCS_TILES}
emptyHint="No leads awaiting document collection or assessment."
/>
);
}
/** Underwrite (Eligibility Assessed) · Issue (Underwriting) · Onboard (Policy Issued). */
const UW_TILES: TileSpec[] = [
{ label: 'In queue', value: count },
{ label: 'Policies issued', value: countWhere((r) => r.current_state_name === 'Policy Issued') },
{ label: 'Total premium', value: sumINR('premium_amount') },
];
export function UnderwritingPolicyScreen() {
return (
<Worklist
states={['Eligibility Assessed', 'Underwriting', 'Policy Issued']}
tiles={UW_TILES}
emptyHint="No leads in underwriting, issuance, or onboarding."
/>
);
}

185
src/styles/styles.css Normal file
View File

@ -0,0 +1,185 @@
/* ============================================================
ARIA global stylesheet
Tailwind v4 + the Aria design tokens. The @theme inline block
wires Tailwind's utility namespaces to the existing CSS-variable
tokens, so `bg-navy-900`, `rounded-lg`, `shadow-md`, `font-numeric`
etc. all resolve to the design system, with zero duplication.
============================================================ */
@import url('https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400;14..32,500;14..32,600;14..32,700;14..32,800&family=Inter+Tight:wght@500;600;700;800&display=swap');
@import 'tailwindcss';
@import './tokens/colors.css';
@import './tokens/typography.css';
@import './tokens/spacing.css';
@theme inline {
/* ---- Brand: Navy ---- */
--color-navy-950: var(--navy-950);
--color-navy-900: var(--navy-900);
--color-navy-800: var(--navy-800);
--color-navy-700: var(--navy-700);
--color-navy-600: var(--navy-600);
--color-navy-500: var(--navy-500);
--color-navy-400: var(--navy-400);
/* ---- Brand: Sunrise ---- */
--color-sunrise-600: var(--sunrise-600);
--color-sunrise-500: var(--sunrise-500);
--color-sunrise-400: var(--sunrise-400);
--color-sunrise-300: var(--sunrise-300);
--color-sunrise-200: var(--sunrise-200);
--color-sunrise-100: var(--sunrise-100);
/* ---- Semantic: Emerald / Amber / Ruby / Sky ---- */
--color-emerald-700: var(--emerald-700);
--color-emerald-600: var(--emerald-600);
--color-emerald-500: var(--emerald-500);
--color-emerald-300: var(--emerald-300);
--color-emerald-100: var(--emerald-100);
--color-amber-700: var(--amber-700);
--color-amber-600: var(--amber-600);
--color-amber-500: var(--amber-500);
--color-amber-300: var(--amber-300);
--color-amber-100: var(--amber-100);
--color-ruby-700: var(--ruby-700);
--color-ruby-600: var(--ruby-600);
--color-ruby-500: var(--ruby-500);
--color-ruby-100: var(--ruby-100);
--color-sky-700: var(--sky-700);
--color-sky-600: var(--sky-600);
--color-sky-500: var(--sky-500);
--color-sky-100: var(--sky-100);
/* ---- Neutral: Slate ---- */
--color-slate-950: var(--slate-950);
--color-slate-900: var(--slate-900);
--color-slate-800: var(--slate-800);
--color-slate-700: var(--slate-700);
--color-slate-600: var(--slate-600);
--color-slate-500: var(--slate-500);
--color-slate-400: var(--slate-400);
--color-slate-300: var(--slate-300);
--color-slate-200: var(--slate-200);
--color-slate-150: var(--slate-150);
--color-slate-100: var(--slate-100);
--color-slate-50: var(--slate-50);
/* ---- Semantic aliases (short, readable utility names) ---- */
--color-app: var(--surface-app);
--color-card: var(--surface-card);
--color-sunk: var(--surface-sunk);
--color-strong: var(--text-strong);
--color-body: var(--text-body);
--color-muted: var(--text-muted);
--color-faint: var(--text-faint);
--color-on-navy: var(--text-on-navy);
--color-on-navy-muted: var(--text-on-navy-muted);
--color-accent: var(--text-accent);
--color-link: var(--text-link);
--color-border-subtle: var(--border-subtle);
--color-border-default: var(--border-default);
/* Aria status semantics */
--color-autonomous: var(--status-autonomous);
--color-autonomous-soft: var(--status-autonomous-soft);
--color-escalated: var(--status-escalated);
--color-escalated-soft: var(--status-escalated-soft);
--color-info-soft: var(--status-info-soft);
/* ---- Radii ---- */
--radius-xs: var(--radius-xs);
--radius-sm: var(--radius-sm);
--radius-md: var(--radius-md);
--radius-lg: var(--radius-lg);
--radius-xl: var(--radius-xl);
--radius-2xl: var(--radius-2xl);
--radius-pill: var(--radius-pill);
/* ---- Elevation ---- */
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-sunrise: var(--shadow-sunrise);
/* ---- Fonts ---- */
--font-sans: var(--font-sans);
--font-numeric: var(--font-numeric);
--font-mono: var(--font-mono);
/* ---- Type scale ---- */
--text-2xs: var(--text-2xs);
--text-xs: var(--text-xs);
--text-sm: var(--text-sm);
--text-base: var(--text-base);
--text-md: var(--text-md);
--text-lg: var(--text-lg);
--text-xl: var(--text-xl);
--text-2xl: var(--text-2xl);
--text-3xl: var(--text-3xl);
--text-4xl: var(--text-4xl);
--text-5xl: var(--text-5xl);
--text-6xl: var(--text-6xl);
}
@layer base {
html {
height: 100%;
}
body,
#root {
min-height: 100%;
}
body {
margin: 0;
font-family: var(--font-sans);
background: var(--surface-app);
color: var(--text-body);
-webkit-font-smoothing: antialiased;
}
}
@layer components {
/* Brand gradients — not expressible as plain utilities. */
.bg-sunrise {
background-image: var(--gradient-sunrise);
}
.bg-sunrise-soft {
background-image: var(--gradient-sunrise-soft);
}
.bg-navy-grad {
background-image: var(--gradient-navy);
}
/* Tabular figures for aligning currency & scores. */
.nums {
font-variant-numeric: tabular-nums;
}
/* Sunrise focus ring on inputs/selects. */
.focus-ring:focus-within {
border-color: var(--sunrise-500);
box-shadow: var(--shadow-focus);
}
/* Slim, theme-tinted scrollbar for in-panel scroll areas (agenda, rail,
modal body). The 2px transparent border + padding-box clip makes the thumb
read as a thin rounded pill with breathing room; darkens on hover. */
.scrollbar-slim {
scrollbar-width: thin;
scrollbar-color: var(--border-default) transparent;
}
.scrollbar-slim::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.scrollbar-slim::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-slim::-webkit-scrollbar-thumb {
background-color: var(--border-default);
background-clip: padding-box;
border: 3px solid transparent;
border-radius: 9999px;
}
.scrollbar-slim:hover::-webkit-scrollbar-thumb {
background-color: var(--text-faint);
}
}

View File

@ -0,0 +1,146 @@
/* ============================================================
ARIA COLOR TOKENS
Trustworthy Indian fintech meets a calm AI-operations console.
Base deep navy, sunrise accent gradient, emerald success,
amber for escalation, slate neutrals.
============================================================ */
:root {
/* ---- Brand: Navy (primary surface + ink) ---- */
--navy-950: #060F24;
--navy-900: #0B1B3B; /* base deep navy */
--navy-800: #122549;
--navy-700: #1B3260;
--navy-600: #294780;
--navy-500: #3A5DA0;
--navy-400: #6987BF;
/* ---- Brand: Sunrise (accent gradient) ---- */
--sunrise-600: #E2552A;
--sunrise-500: #F26B3A; /* gradient start */
--sunrise-400: #FB8A4A;
--sunrise-300: #FBA94C; /* gradient end */
--sunrise-200: #FDD09A;
--sunrise-100: #FEF0E0;
--gradient-sunrise: linear-gradient(100deg, #F26B3A 0%, #FBA94C 100%); /* @kind color */
--gradient-sunrise-soft: linear-gradient(135deg, #FEF0E0 0%, #FDE4D2 100%); /* @kind color */
--gradient-navy: linear-gradient(160deg, #0B1B3B 0%, #122549 60%, #1B3260 100%); /* @kind color */
/* ---- Semantic: Emerald (success / autonomous) ---- */
--emerald-700: #0A7A52;
--emerald-600: #0E9466;
--emerald-500: #16B17C;
--emerald-300: #6FD7B0;
--emerald-100: #DBF5EB;
/* ---- Semantic: Amber (escalated / needs-human) ---- */
--amber-700: #B26B05;
--amber-600: #D98512;
--amber-500: #F0A529;
--amber-300: #FBCF7E;
--amber-100: #FCF1DB;
/* ---- Semantic: Ruby (error / cold / declined) ---- */
--ruby-700: #B4243B;
--ruby-600: #D63A52;
--ruby-500: #E85A70;
--ruby-100: #FBE2E6;
/* ---- Semantic: Sky (info / web channel) ---- */
--sky-700: #1565B8;
--sky-600: #2480DB;
--sky-500: #4AA0EF;
--sky-100: #DCEEFC;
/* ---- Neutral: Slate ---- */
--slate-950: #0E1726;
--slate-900: #1B2638;
--slate-800: #2E3B52;
--slate-700: #45536E;
--slate-600: #5E6E8C;
--slate-500: #7C8AA6;
--slate-400: #9DA9C2;
--slate-300: #C2CADB;
--slate-200: #DDE3EE;
--slate-150: #E8ECF4;
--slate-100: #F0F3F8;
--slate-50: #F7F9FC;
--white: #FFFFFF;
/* ============================================================
SEMANTIC ALIASES (light mode primary surface)
============================================================ */
/* Surfaces */
--surface-app: var(--slate-50);
--surface-card: var(--white);
--surface-sunk: var(--slate-100);
--surface-raised: var(--white);
--surface-navy: var(--navy-900);
--surface-navy-soft: var(--navy-800);
/* Text */
--text-strong: var(--navy-900);
--text-body: var(--slate-800);
--text-muted: var(--slate-600);
--text-faint: var(--slate-500);
--text-on-navy: #EAF0FA;
--text-on-navy-muted: #9DB0D2;
--text-on-accent: #FFFFFF;
--text-accent: var(--sunrise-600);
--text-link: var(--sky-700);
/* Borders / hairlines */
--border-subtle: var(--slate-200);
--border-default: var(--slate-300);
--border-strong: var(--slate-400);
--border-navy: rgba(255,255,255,0.12);
/* Brand action */
--action-primary: var(--sunrise-500);
--action-primary-hover: var(--sunrise-600);
--action-navy: var(--navy-900);
--action-navy-hover: var(--navy-800);
--focus-ring: rgba(242,107,58,0.45);
/* Status mapping (Aria semantics) */
--status-autonomous: var(--emerald-600);
--status-autonomous-soft: var(--emerald-100);
--status-escalated: var(--amber-600);
--status-escalated-soft: var(--amber-100);
--status-error: var(--ruby-600);
--status-error-soft: var(--ruby-100);
--status-info: var(--sky-600);
--status-info-soft: var(--sky-100);
/* Lead segments */
--segment-hot: var(--sunrise-600);
--segment-hot-soft: var(--sunrise-100);
--segment-warm: var(--amber-600);
--segment-warm-soft: var(--amber-100);
--segment-cold: var(--slate-500);
--segment-cold-soft: var(--slate-100);
}
/* ============================================================
DARK SCOPE for the dark AI Decision Card variant.
Apply class="aria-dark" on a container to flip surface/text.
============================================================ */
.aria-dark {
--surface-app: var(--navy-950);
--surface-card: var(--navy-900);
--surface-sunk: var(--navy-800);
--surface-raised: var(--navy-800);
--text-strong: #F4F7FC;
--text-body: #D4DEF0;
--text-muted: #9DB0D2;
--text-faint: #6E83A8;
--border-subtle: rgba(255,255,255,0.10);
--border-default: rgba(255,255,255,0.16);
--border-strong: rgba(255,255,255,0.24);
--status-autonomous: var(--emerald-500);
--status-autonomous-soft: rgba(22,177,124,0.16);
--status-escalated: var(--amber-500);
--status-escalated-soft: rgba(240,165,41,0.16);
}

View File

@ -0,0 +1,5 @@
/* Aria typeface Inter (humanist sans), loaded from Google Fonts CDN.
SUBSTITUTION NOTE: the brief specifies Inter and we load the official
Google Fonts build. If you have a self-hosted/licensed Inter, drop the
.woff2 files in assets/fonts/ and replace this @import with @font-face. */
@import url('https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400;14..32,500;14..32,600;14..32,700;14..32,800&family=Inter+Tight:wght@500;600;700;800&display=swap');

View File

@ -0,0 +1,43 @@
/* ============================================================
ARIA SPACING, RADII & ELEVATION
Airy spacing, 1216px radii, soft elevation, minimal borders.
============================================================ */
:root {
/* ---- Spacing scale (4px base) ---- */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-7: 32px;
--space-8: 40px;
--space-9: 48px;
--space-10: 64px;
--space-11: 80px;
/* ---- Corner radii ---- */
--radius-xs: 6px;
--radius-sm: 8px;
--radius-md: 12px; /* default control radius */
--radius-lg: 16px; /* default card radius */
--radius-xl: 20px;
--radius-2xl: 28px;
--radius-pill: 999px;
/* ---- Soft elevation (navy-tinted, low spread) ---- */
--shadow-xs: 0 1px 2px rgba(11,27,59,0.06);
--shadow-sm: 0 1px 3px rgba(11,27,59,0.08), 0 1px 2px rgba(11,27,59,0.04);
--shadow-md: 0 4px 12px rgba(11,27,59,0.08), 0 2px 4px rgba(11,27,59,0.04);
--shadow-lg: 0 12px 28px rgba(11,27,59,0.12), 0 4px 8px rgba(11,27,59,0.06);
--shadow-xl: 0 24px 56px rgba(11,27,59,0.18), 0 8px 16px rgba(11,27,59,0.08);
--shadow-focus: 0 0 0 3px var(--focus-ring);
/* Glow used by the AI Decision Card accent */
--shadow-sunrise: 0 8px 24px rgba(242,107,58,0.22);
/* ---- Layout ---- */
--container-max: 1280px;
--sidebar-w: 248px;
--topbar-h: 60px;
}

View File

@ -0,0 +1,58 @@
/* ============================================================
ARIA TYPOGRAPHY TOKENS
Inter for UI & body. Inter Tight for oversized confident
numerals (lead scores, premium figures).
============================================================ */
:root {
--font-sans: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-numeric: 'Inter Tight', 'Inter', system-ui, sans-serif;
--font-mono: 'SF Mono', ui-monospace, 'Menlo', monospace;
/* Tabular figures for aligning currency & scores */
--numeric-feat: "tnum" 1, "lnum" 1, "cv11" 1; /* @kind other */
/* ---- Type scale (px) ---- */
--text-2xs: 11px;
--text-xs: 12px;
--text-sm: 13px;
--text-base: 14px;
--text-md: 15px;
--text-lg: 17px;
--text-xl: 20px;
--text-2xl: 24px;
--text-3xl: 30px;
--text-4xl: 38px;
--text-5xl: 48px;
--text-6xl: 64px; /* oversized premium / score figures */
/* ---- Weights ---- */
--weight-regular: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
--weight-extrabold: 800;
/* ---- Line heights ---- */
--leading-tight: 1.1;
--leading-snug: 1.3;
--leading-normal: 1.5;
--leading-relaxed: 1.65;
/* ---- Letter spacing ---- */
--tracking-tight: -0.02em;
--tracking-snug: -0.01em;
--tracking-normal: 0;
--tracking-wide: 0.04em;
--tracking-caps: 0.08em; /* eyebrows / overlines */
/* ---- Semantic roles ---- */
--role-display-size: var(--text-4xl);
--role-display-weight: var(--weight-extrabold);
--role-title-size: var(--text-2xl);
--role-title-weight: var(--weight-bold);
--role-heading-size: var(--text-lg);
--role-heading-weight: var(--weight-semibold);
--role-body-size: var(--text-base);
--role-label-size: var(--text-sm);
--role-eyebrow-size: var(--text-2xs);
}

77
src/types.ts Normal file
View File

@ -0,0 +1,77 @@
// Shared domain types for the Aria lead-to-policy console.
export type Segment = 'Hot' | 'Warm' | 'Cold';
export type Channel =
| 'WhatsApp'
| 'Web'
| 'Referral'
| 'Agency'
| 'Bancassurance'
| 'Direct'
| 'Event';
/** Tone used by timeline entries and the last-action dot on a lead. */
export type Tone = 'neutral' | 'success' | 'warning' | 'accent' | 'info';
export interface Lead {
id: string;
name: string;
city: string;
phone: string;
email: string;
dob: string;
age: number;
income: number;
occupation: string;
language: string;
interest: string;
score: number;
segment: Segment;
channel: Channel;
owner: string;
ownerAi: boolean;
/** Index into the workflow stages array. */
stage: number;
lastAction: string;
lastTone: Tone;
}
export interface Kpi {
label: string;
value: string;
unit?: string;
delta?: string;
deltaDir?: 'up' | 'down';
accent?: boolean;
}
export interface FunnelStage {
stage: string;
count: number;
pct: number;
}
export interface SegmentDatum {
label: string;
count: number;
color: string;
}
/** One AI-employee decision in a lead's decision stream. */
export interface Decision {
employee: string;
role: string;
activity: string;
time: string;
confidence: number;
summary: string;
reasoning: string;
citations: string[];
}
export interface AiEmployee {
name: string;
role: string;
active: number;
}

9
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_ZINO_API_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

23
tsconfig.app.json Normal file
View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

17
tsconfig.node.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["vite.config.ts"]
}

11
vite.config.ts Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
// https://vite.dev/config/
// Preview is served under /lead-to-policy/ (path-based frontgen preview);
// local dev stays at / so the running dev server's URL is unaffected.
export default defineConfig(({ command }) => ({
plugins: [react(), tailwindcss()],
base: command === 'build' ? '/lead-to-policy/' : '/',
}));