initial commit
This commit is contained in:
commit
89d64e6196
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
93
README.md
Normal 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
44
cascade_city_by_state.sql
Normal 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;
|
||||||
42
cleanup-phantom-agents.sql
Normal file
42
cleanup-phantom-agents.sql
Normal 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
85
flow-findings.md
Normal 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.76–3.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
13
index.html
Normal 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
2391
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal 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
8
public/zino.svg
Normal 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 |
65
rebuild_city_state_from_branches.sql
Normal file
65
rebuild_city_state_from_branches.sql
Normal 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;
|
||||||
173
scripts/build_policy_docx.py
Normal file
173
scripts/build_policy_docx.py
Normal 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))
|
||||||
64
scripts/kyc-match.check.ts
Normal file
64
scripts/kyc-match.check.ts
Normal 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
59
src/App.tsx
Normal 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
286
src/api/adapters.ts
Normal 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;
|
||||||
|
/** 0–1 (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
290
src/api/client.ts
Normal 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
194
src/api/config.ts
Normal 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', // 0–100
|
||||||
|
},
|
||||||
|
} 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
118
src/api/filters.ts
Normal 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
189
src/api/forms.ts
Normal 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
50
src/api/leads.tsx
Normal 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
79
src/api/meetings.ts
Normal 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
127
src/api/provider.tsx
Normal 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
104
src/api/schema.ts
Normal 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
261
src/api/types.ts
Normal 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;
|
||||||
|
}
|
||||||
209
src/components/LeadFilters.tsx
Normal file
209
src/components/LeadFilters.tsx
Normal 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 ?? '…'}`;
|
||||||
|
}
|
||||||
22
src/components/ZinoLogo.tsx
Normal file
22
src/components/ZinoLogo.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
src/components/activities/ActivityActions.tsx
Normal file
102
src/components/activities/ActivityActions.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
src/components/activities/AssignBody.tsx
Normal file
202
src/components/activities/AssignBody.tsx
Normal 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)} />;
|
||||||
|
}
|
||||||
414
src/components/activities/DocumentsBody.tsx
Normal file
414
src/components/activities/DocumentsBody.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
src/components/activities/IssuePolicyBody.tsx
Normal file
159
src/components/activities/IssuePolicyBody.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
498
src/components/activities/RecommendBody.tsx
Normal file
498
src/components/activities/RecommendBody.tsx
Normal 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(`Couldn’t match “${rec.product}” to the catalogue — pick the product manually.`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setApplyNote('Couldn’t 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 plan’s 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 plan’s 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 won’t 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">Aria’s 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
221
src/components/activities/UnderwritingBody.tsx
Normal file
221
src/components/activities/UnderwritingBody.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
src/components/activities/Worklist.tsx
Normal file
132
src/components/activities/Worklist.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
src/components/activities/index.ts
Normal file
6
src/components/activities/index.ts
Normal 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';
|
||||||
55
src/components/activities/registry.ts
Normal file
55
src/components/activities/registry.ts
Normal 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,
|
||||||
|
};
|
||||||
14
src/components/activities/types.ts
Normal file
14
src/components/activities/types.ts
Normal 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;
|
||||||
|
}
|
||||||
83
src/components/core/Avatar.tsx
Normal file
83
src/components/core/Avatar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/components/core/Badge.tsx
Normal file
51
src/components/core/Badge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
src/components/core/Button.tsx
Normal file
65
src/components/core/Button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/components/core/Card.tsx
Normal file
58
src/components/core/Card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src/components/core/Input.tsx
Normal file
60
src/components/core/Input.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/components/core/Modal.tsx
Normal file
81
src/components/core/Modal.tsx
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
109
src/components/core/MultiSelect.tsx
Normal file
109
src/components/core/MultiSelect.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/components/core/Pagination.tsx
Normal file
41
src/components/core/Pagination.tsx
Normal 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 a–b 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
src/components/core/PickerShell.tsx
Normal file
182
src/components/core/PickerShell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/components/core/Select.tsx
Normal file
50
src/components/core/Select.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/components/core/SelectField.tsx
Normal file
91
src/components/core/SelectField.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/components/core/index.ts
Normal file
22
src/components/core/index.ts
Normal 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';
|
||||||
404
src/components/form/ActivityForm.tsx
Normal file
404
src/components/form/ActivityForm.tsx
Normal 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 don’t 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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/components/form/DatasetSelectField.tsx
Normal file
110
src/components/form/DatasetSelectField.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
src/components/form/LookupField.tsx
Normal file
134
src/components/form/LookupField.tsx
Normal 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('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/components/form/SchemaGuard.tsx
Normal file
51
src/components/form/SchemaGuard.tsx
Normal 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 don’t have permission for this action</div>
|
||||||
|
<div className="mt-0.5 text-xs text-amber-700">
|
||||||
|
Your role can’t {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 ? `Couldn’t load the form: ${error}` : 'This activity has no input fields.'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
4
src/components/form/index.ts
Normal file
4
src/components/form/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from './ActivityForm';
|
||||||
|
export * from './DatasetSelectField';
|
||||||
|
export * from './LookupField';
|
||||||
|
export * from './SchemaGuard';
|
||||||
4
src/components/index.ts
Normal file
4
src/components/index.ts
Normal 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';
|
||||||
140
src/components/insurance/AIDecisionCard.tsx
Normal file
140
src/components/insurance/AIDecisionCard.tsx
Normal 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 0–1. @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>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
src/components/insurance/AIDecisionStream.tsx
Normal file
192
src/components/insurance/AIDecisionStream.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/components/insurance/AiSuggestionPanel.tsx
Normal file
92
src/components/insurance/AiSuggestionPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/components/insurance/ChannelBadge.tsx
Normal file
41
src/components/insurance/ChannelBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
src/components/insurance/ConfidenceRing.tsx
Normal file
80
src/components/insurance/ConfidenceRing.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
import { cn } from '../../lib/cn';
|
||||||
|
|
||||||
|
export interface ConfidenceRingProps {
|
||||||
|
/** Confidence 0–1. */
|
||||||
|
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 (0–1). ≥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>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
src/components/insurance/DocumentUploadCard.tsx
Normal file
161
src/components/insurance/DocumentUploadCard.tsx
Normal 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
src/components/insurance/KpiCard.tsx
Normal file
76
src/components/insurance/KpiCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
src/components/insurance/LeadRow.tsx
Normal file
85
src/components/insurance/LeadRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
src/components/insurance/ReasoningBody.tsx
Normal file
123
src/components/insurance/ReasoningBody.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
src/components/insurance/RupeeAmount.tsx
Normal file
69
src/components/insurance/RupeeAmount.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
src/components/insurance/SegmentBadge.tsx
Normal file
35
src/components/insurance/SegmentBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/components/insurance/TimelineEntry.tsx
Normal file
64
src/components/insurance/TimelineEntry.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
src/components/insurance/WorkflowStepper.tsx
Normal file
75
src/components/insurance/WorkflowStepper.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/components/insurance/index.ts
Normal file
26
src/components/insurance/index.ts
Normal 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
218
src/layout/Shell.tsx
Normal 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
4
src/lib/cn.ts
Normal 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
179
src/lib/kyc-match.ts
Normal 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
115
src/lib/recommend.ts
Normal 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 plan’s entry-age band');
|
||||||
|
if (!c.okBounds) out.push('sum assured outside the plan’s min/max');
|
||||||
|
if (!c.okMult) out.push('sum assured outside the allowed income multiple');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
117
src/lib/underwriting.ts
Normal file
117
src/lib/underwriting.ts
Normal 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
21
src/main.tsx
Normal 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
166
src/pages/Login.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
266
src/screens/Lead360Screen.tsx
Normal file
266
src/screens/Lead360Screen.tsx
Normal 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 key→value 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
302
src/screens/PipelineScreen.tsx
Normal file
302
src/screens/PipelineScreen.tsx
Normal 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">
|
||||||
|
Couldn’t 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
326
src/screens/ScheduleScreen.tsx
Normal file
326
src/screens/ScheduleScreen.tsx
Normal 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">
|
||||||
|
Couldn’t 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
83
src/screens/worklists.tsx
Normal 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
185
src/styles/styles.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
146
src/styles/tokens/colors.css
Normal file
146
src/styles/tokens/colors.css
Normal 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);
|
||||||
|
}
|
||||||
5
src/styles/tokens/fonts.css
Normal file
5
src/styles/tokens/fonts.css
Normal 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');
|
||||||
43
src/styles/tokens/spacing.css
Normal file
43
src/styles/tokens/spacing.css
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
/* ============================================================
|
||||||
|
ARIA — SPACING, RADII & ELEVATION
|
||||||
|
Airy spacing, 12–16px 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;
|
||||||
|
}
|
||||||
58
src/styles/tokens/typography.css
Normal file
58
src/styles/tokens/typography.css
Normal 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
77
src/types.ts
Normal 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
9
src/vite-env.d.ts
vendored
Normal 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
23
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
17
tsconfig.node.json
Normal file
17
tsconfig.node.json
Normal 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
11
vite.config.ts
Normal 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/' : '/',
|
||||||
|
}));
|
||||||
Loading…
Reference in New Issue
Block a user