initial commit
This commit is contained in:
commit
714f8ab8ff
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
@ -0,0 +1,19 @@
|
||||
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
|
||||
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.
|
||||
12
index.html
Normal file
12
index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Aria — Lead-to-policy console</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"
|
||||
}
|
||||
}
|
||||
46
src/App.tsx
Normal file
46
src/App.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
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 { Lead360Screen } from './screens/Lead360Screen';
|
||||
import { AssignScreen } from './screens/AssignScreen';
|
||||
import { RecommendScreen } from './screens/RecommendScreen';
|
||||
import { DocumentsScreen } from './screens/DocumentsScreen';
|
||||
import { NewLeadScreen } from './screens/NewLeadScreen';
|
||||
import { Login } from './pages/Login';
|
||||
import { useZino } from './api/provider';
|
||||
import { LeadsProvider } from './api/leads';
|
||||
|
||||
function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const { user } = useZino();
|
||||
const location = useLocation();
|
||||
if (!user) return <Navigate to="/login" replace state={{ from: location.pathname }} />;
|
||||
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="new" element={<NewLeadScreen />} />
|
||||
<Route path="leads" element={<Lead360Screen />} />
|
||||
<Route path="leads/:id" element={<Lead360Screen />} />
|
||||
<Route path="assign" element={<AssignScreen />} />
|
||||
<Route path="recommend" element={<RecommendScreen />} />
|
||||
<Route path="documents" element={<DocumentsScreen />} />
|
||||
<Route path="*" element={<Navigate to="/pipeline" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
183
src/api/adapters.ts
Normal file
183
src/api/adapters.ts
Normal file
@ -0,0 +1,183 @@
|
||||
// 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,
|
||||
STATE_NAME_BY_UID,
|
||||
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';
|
||||
}
|
||||
|
||||
// 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 ?? r.assigned_region ?? '—',
|
||||
phone: 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 emp = AI_EMPLOYEES[d.ai_user_id] ?? { name: `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),
|
||||
};
|
||||
}
|
||||
|
||||
// --- audit timeline ---
|
||||
|
||||
export interface TimelineItem {
|
||||
actor: string;
|
||||
ai: boolean;
|
||||
action: string;
|
||||
time: string;
|
||||
tone: Tone;
|
||||
}
|
||||
|
||||
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 ?? (e.user_id === '1' ? 'Admin' : `User ${e.user_id}`),
|
||||
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,
|
||||
);
|
||||
}
|
||||
241
src/api/client.ts
Normal file
241
src/api/client.ts
Normal file
@ -0,0 +1,241 @@
|
||||
import type {
|
||||
ActivityResult,
|
||||
AiDecision,
|
||||
ApiError,
|
||||
AuditEntry,
|
||||
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 ?? []);
|
||||
}
|
||||
|
||||
// --- 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. */
|
||||
lookupRecords(
|
||||
templateUid: string,
|
||||
opts: { activityId: string; fieldId: string; search?: string; instanceId?: number | string; limit?: number },
|
||||
): 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: {},
|
||||
});
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
}
|
||||
161
src/api/config.ts
Normal file
161
src/api/config.ts
Normal file
@ -0,0 +1,161 @@
|
||||
// Aria Lead-to-Policy — sandbox app 385, org 53.
|
||||
// Workflow `e29c3c33` (v1). All ids verified against postgres-sandbox.
|
||||
|
||||
export const ORG_ID = '53';
|
||||
export const APP_ID = '385';
|
||||
export const WORKFLOW_UUID = 'e29c3c33-e294-4051-8325-a20de9ed99fc';
|
||||
|
||||
// Record views (POST /app/385/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/385/view/detailview/{uid}).
|
||||
export const DETAIL_VIEW_IDS = {
|
||||
LEAD: 'aa96f572-b480-4623-ae9c-9ce0c44b8626',
|
||||
} as const;
|
||||
|
||||
// Onboard activity uid — its submitted fields (welcome_pack_sent, onboarding_notes)
|
||||
// are `local`, so they never reach the lead recordview. They DO persist in the
|
||||
// activity submission, surfaced via the audit feed; Customer 360 reads them there.
|
||||
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', // select
|
||||
sumAssured: 'sum_assured_input', // number
|
||||
premiumAmount: 'premium_amount_input', // number
|
||||
premiumFrequency: 'premium_frequency_input', // select
|
||||
riders: 'riders_input', // multiselect
|
||||
notes: 'recommendation_notes', // longtext
|
||||
},
|
||||
},
|
||||
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)
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Allowed select/multiselect values (label -> stored value).
|
||||
export const PRODUCT_OPTIONS = [
|
||||
{ label: 'Term', value: 'term' },
|
||||
{ label: 'Whole Life', value: 'whole_life' },
|
||||
{ label: 'ULIP', value: 'ulip' },
|
||||
{ label: 'Endowment', value: 'endowment' },
|
||||
{ label: 'Child', value: 'child' },
|
||||
{ label: 'Pension', value: 'pension' },
|
||||
] as const;
|
||||
|
||||
export const FREQUENCY_OPTIONS = [
|
||||
{ label: 'Monthly', value: 'monthly' },
|
||||
{ label: 'Quarterly', value: 'quarterly' },
|
||||
{ label: 'Half-Yearly', value: 'half_yearly' },
|
||||
{ label: 'Annual', value: 'annual' },
|
||||
] as const;
|
||||
|
||||
export const RIDER_OPTIONS = [
|
||||
{ label: 'Critical Illness', value: 'critical_illness' },
|
||||
{ label: 'Accidental Death', value: 'accidental_death' },
|
||||
{ label: 'Waiver of Premium', value: 'waiver_premium' },
|
||||
{ label: 'Income Benefit', value: 'income_benefit' },
|
||||
] as const;
|
||||
|
||||
export const DOC_STATUS_OPTIONS = [
|
||||
{ label: 'Pending', value: 'pending' },
|
||||
{ label: 'Verified', value: 'verified' },
|
||||
{ label: 'Mismatch', value: 'mismatch' },
|
||||
{ label: 'Incomplete', value: 'incomplete' },
|
||||
] as const;
|
||||
|
||||
// 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' },
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
330
src/api/forms.ts
Normal file
330
src/api/forms.ts
Normal file
@ -0,0 +1,330 @@
|
||||
// Declarative activity-form specs for app 385, mirrored 1:1 from the deployed
|
||||
// workflow config (`activity_data_fields`). Drives the generic <ActivityForm>
|
||||
// and the contextual "Next action" panel on Lead 360.
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface FieldDef {
|
||||
id: string;
|
||||
label: string;
|
||||
type: FieldType;
|
||||
required?: boolean;
|
||||
options?: FieldOption[];
|
||||
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[];
|
||||
}
|
||||
|
||||
export interface ActivityDef {
|
||||
uid: string;
|
||||
name: string;
|
||||
submitLabel: string;
|
||||
/** Past-tense confirmation, e.g. "advanced to Qualified". */
|
||||
done: string;
|
||||
fields: FieldDef[];
|
||||
}
|
||||
|
||||
const CHANNEL_OPTIONS: FieldOption[] = [
|
||||
{ value: 'whatsapp', label: 'WhatsApp' },
|
||||
{ value: 'web', label: 'Web' },
|
||||
{ value: 'referral', label: 'Referral' },
|
||||
{ value: 'agency', label: 'Agency' },
|
||||
{ value: 'bancassurance', label: 'Bancassurance' },
|
||||
{ value: 'direct', label: 'Direct' },
|
||||
{ value: 'event', label: 'Event' },
|
||||
];
|
||||
|
||||
const LANGUAGE_OPTIONS: FieldOption[] = [
|
||||
{ value: 'english', label: 'English' },
|
||||
{ value: 'hindi', label: 'Hindi' },
|
||||
{ value: 'tamil', label: 'Tamil' },
|
||||
{ value: 'telugu', label: 'Telugu' },
|
||||
{ value: 'kannada', label: 'Kannada' },
|
||||
{ value: 'marathi', label: 'Marathi' },
|
||||
{ value: 'bengali', label: 'Bengali' },
|
||||
];
|
||||
|
||||
const PRODUCT_OPTIONS: FieldOption[] = [
|
||||
{ value: 'term', label: 'Term' },
|
||||
{ value: 'whole_life', label: 'Whole Life' },
|
||||
{ value: 'ulip', label: 'ULIP' },
|
||||
{ value: 'endowment', label: 'Endowment' },
|
||||
{ value: 'child', label: 'Child' },
|
||||
{ value: 'pension', label: 'Pension' },
|
||||
];
|
||||
|
||||
// Activity keys are stable, human-readable handles used by STATE_NEXT.
|
||||
export const ACTIVITY_DEFS = {
|
||||
capture: {
|
||||
uid: 'cc1a73d5-61e1-4416-af30-2b1eeb623a58',
|
||||
name: 'Capture Lead',
|
||||
submitLabel: 'Create lead',
|
||||
done: 'created — added to the pipeline as a New Lead',
|
||||
fields: [
|
||||
{ id: 'lead_name_input', label: 'Lead name', type: 'text', required: true, placeholder: 'Full name' },
|
||||
{ id: 'phone_input', label: 'Phone', type: 'phone', required: true, placeholder: '+91 …' },
|
||||
{ id: 'email_input', label: 'Email', type: 'email', placeholder: 'name@example.com' },
|
||||
{ id: 'date_of_birth_input', label: 'Date of birth', type: 'date', required: true },
|
||||
{ id: 'annual_income_input', label: 'Annual income', type: 'number', required: true, prefix: '₹' },
|
||||
{ id: 'occupation_input', label: 'Occupation', type: 'text' },
|
||||
{ id: 'city_input', label: 'City', type: 'text' },
|
||||
{ id: 'state_region_input', label: 'State / region', type: 'text' },
|
||||
{ id: 'preferred_language_input', label: 'Preferred language', type: 'select', options: LANGUAGE_OPTIONS },
|
||||
{ id: 'product_interest_input', label: 'Product interest', type: 'select', options: PRODUCT_OPTIONS },
|
||||
{ id: 'channel_source_input', label: 'Channel source', type: 'select', required: true, options: CHANNEL_OPTIONS },
|
||||
],
|
||||
},
|
||||
qualify: {
|
||||
uid: '6b741e44-37f2-4bb0-90ab-ed139f03b7b3',
|
||||
name: 'Qualify Lead',
|
||||
submitLabel: 'Qualify lead',
|
||||
done: 'advanced to Qualified',
|
||||
fields: [
|
||||
{ id: 'lead_score_input', label: 'Lead score', type: 'number', required: true, placeholder: '0–100' },
|
||||
{
|
||||
id: 'lead_segment_input',
|
||||
label: 'Lead segment',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'hot', label: 'Hot' },
|
||||
{ value: 'warm', label: 'Warm' },
|
||||
{ value: 'cold', label: 'Cold' },
|
||||
],
|
||||
},
|
||||
{ id: 'annual_income_input', label: 'Annual income', type: 'number', required: true, prefix: '₹' },
|
||||
{ id: 'date_of_birth_input', label: 'Date of birth', type: 'date', required: true },
|
||||
{ id: 'qualify_notes', label: 'Qualification notes', type: 'longtext' },
|
||||
],
|
||||
},
|
||||
assign: {
|
||||
uid: 'da61aaa1-003b-4601-9f42-f93ff230497f',
|
||||
name: 'Assign Lead',
|
||||
submitLabel: 'Assign lead',
|
||||
done: 'advanced to Assigned',
|
||||
fields: [
|
||||
{ id: 'assigned_region_input', label: 'Assigned region', type: 'text', required: true, placeholder: 'e.g. West / Maharashtra' },
|
||||
{
|
||||
id: 'owner_agent_input',
|
||||
label: 'Owner agent',
|
||||
type: 'lookup',
|
||||
seedKey: 'owner_agent',
|
||||
lookupTemplate: '6ad51dbe-f0b7-4b78-a5f0-00555fac1597',
|
||||
lookupDisplay: ['name', 'region'],
|
||||
lookupStorage: ['id', 'name'],
|
||||
},
|
||||
],
|
||||
},
|
||||
contact: {
|
||||
uid: 'fea6b3a8-846d-4f6f-8e92-033c4cf4b6e2',
|
||||
name: 'Log First Contact',
|
||||
submitLabel: 'Log contact',
|
||||
done: 'advanced to Contacted',
|
||||
fields: [
|
||||
{
|
||||
id: 'contact_outcome_input',
|
||||
label: 'Contact outcome',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'connected', label: 'Connected' },
|
||||
{ value: 'no_answer', label: 'No Answer' },
|
||||
{ value: 'call_back', label: 'Call Back' },
|
||||
{ value: 'not_interested', label: 'Not Interested' },
|
||||
{ value: 'wrong_number', label: 'Wrong Number' },
|
||||
],
|
||||
},
|
||||
{ id: 'next_followup_at_input', label: 'Next follow-up at', type: 'datetime' },
|
||||
{ id: 'contact_notes_input', label: 'Contact notes', type: 'longtext' },
|
||||
],
|
||||
},
|
||||
schedule: {
|
||||
uid: 'c444d32f-ed6a-4e36-b8b7-b82e73a141d4',
|
||||
name: 'Schedule Meeting',
|
||||
submitLabel: 'Schedule meeting',
|
||||
done: 'advanced to Meeting Scheduled',
|
||||
fields: [
|
||||
{ id: 'meeting_datetime_input', label: 'Meeting date & time', type: 'datetime', required: true },
|
||||
{
|
||||
id: 'meeting_mode_input',
|
||||
label: 'Meeting mode',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'branch', label: 'Branch' },
|
||||
{ value: 'video', label: 'Video' },
|
||||
{ value: 'home_visit', label: 'Home Visit' },
|
||||
{ value: 'phone', label: 'Phone' },
|
||||
],
|
||||
},
|
||||
{ id: 'meeting_address_input', label: 'Meeting address', type: 'text', placeholder: 'Branch / link / address' },
|
||||
],
|
||||
},
|
||||
assess: {
|
||||
uid: 'ba3d3e7f-0d3f-49f2-a156-6cace33c228a',
|
||||
name: 'Assess Eligibility',
|
||||
submitLabel: 'Submit assessment',
|
||||
done: 'advanced to Eligibility Assessed',
|
||||
fields: [
|
||||
{
|
||||
id: 'eligibility_status_input',
|
||||
label: 'Eligibility status',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'Eligible', label: 'Eligible' },
|
||||
{ value: 'Refer', label: 'Refer' },
|
||||
{ value: 'Ineligible', label: 'Ineligible' },
|
||||
],
|
||||
},
|
||||
{ id: 'eligibility_notes_input', label: 'Eligibility notes', type: 'longtext' },
|
||||
],
|
||||
},
|
||||
underwrite: {
|
||||
uid: 'ed8ec0dc-4c2c-4d9e-b798-757f6a9d4730',
|
||||
name: 'Submit Underwriting',
|
||||
submitLabel: 'Submit underwriting',
|
||||
done: 'advanced to Underwriting',
|
||||
fields: [
|
||||
{ id: 'underwriting_ref_input', label: 'Underwriting reference', type: 'text', required: true, placeholder: 'UW-…' },
|
||||
{
|
||||
id: 'underwriting_status_input',
|
||||
label: 'Underwriting status',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'submitted', label: 'Submitted' },
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
{ value: 'counter_offer', label: 'Counter-Offer' },
|
||||
{ value: 'declined', label: 'Declined' },
|
||||
{ value: 'referred', label: 'Referred' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
issue: {
|
||||
uid: '7b8fb734-a780-4c6a-837b-c5e7ca28e489',
|
||||
name: 'Issue Policy',
|
||||
submitLabel: 'Issue policy',
|
||||
done: 'advanced to Policy Issued',
|
||||
fields: [
|
||||
{ id: 'policy_number_input', label: 'Policy number', type: 'text', required: true, placeholder: 'POL-…' },
|
||||
{ id: 'policy_issue_date_input', label: 'Policy issue date', type: 'date', required: true },
|
||||
{ id: 'premium_amount_confirm', label: 'Premium amount', type: 'number', prefix: '₹', seedKey: 'premium_amount' },
|
||||
],
|
||||
},
|
||||
onboard: {
|
||||
uid: 'ae415f4e-a33e-432e-882f-733238de8bcc',
|
||||
name: 'Onboard Customer',
|
||||
submitLabel: 'Complete onboarding',
|
||||
done: 'advanced to Customer 360',
|
||||
fields: [
|
||||
{ id: 'welcome_pack_sent', label: 'Welcome pack sent', type: 'boolean' },
|
||||
{ id: 'onboarding_notes', label: 'Onboarding notes', type: 'longtext' },
|
||||
],
|
||||
},
|
||||
disqualify: {
|
||||
uid: 'ea99fc60-caba-45c1-afc1-7386a2fa9220',
|
||||
name: 'Disqualify',
|
||||
submitLabel: 'Disqualify lead',
|
||||
done: 'moved to Disqualified',
|
||||
fields: [
|
||||
{
|
||||
id: 'disqualify_reason_input',
|
||||
label: 'Disqualify reason',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'budget', label: 'Budget' },
|
||||
{ value: 'not_interested', label: 'Not Interested' },
|
||||
{ value: 'ineligible', label: 'Ineligible' },
|
||||
{ value: 'unreachable', label: 'Unreachable' },
|
||||
{ value: 'duplicate', label: 'Duplicate' },
|
||||
{ value: 'bought_elsewhere', label: 'Bought Elsewhere' },
|
||||
],
|
||||
},
|
||||
{ id: 'disqualify_notes_input', label: 'Disqualify notes', type: 'longtext' },
|
||||
],
|
||||
},
|
||||
} satisfies Record<string, ActivityDef>;
|
||||
|
||||
export type ActivityKey = keyof typeof ACTIVITY_DEFS;
|
||||
|
||||
// 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.
|
||||
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': { activity: 'qualify', route: '/recommend', label: 'Recommend product' },
|
||||
'Product Recommended': { activity: 'qualify', route: '/documents', label: 'Collect documents' },
|
||||
'Documents Collected': { activity: 'assess' },
|
||||
'Eligibility Assessed': { activity: 'underwrite' },
|
||||
Underwriting: { activity: 'issue' },
|
||||
'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',
|
||||
]);
|
||||
47
src/api/leads.tsx
Normal file
47
src/api/leads.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
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 } 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[];
|
||||
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: 'instance_id', sortDir: 'desc' }),
|
||||
[],
|
||||
);
|
||||
|
||||
const records = useMemo(() => (data?.data ?? []) as LeadRecord[], [data]);
|
||||
const leads = useMemo(() => records.map(recordToLead), [records]);
|
||||
const value = useMemo<LeadsContextValue>(
|
||||
() => ({ leads, records, loading, error, refetch }),
|
||||
[leads, records, 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;
|
||||
}
|
||||
115
src/api/provider.tsx
Normal file
115
src/api/provider.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
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.
|
||||
*/
|
||||
export function useQuery<T>(
|
||||
fn: () => Promise<T>,
|
||||
deps: unknown[],
|
||||
enabled = true,
|
||||
): 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]);
|
||||
|
||||
return { data, loading, error, refetch };
|
||||
}
|
||||
122
src/api/types.ts
Normal file
122
src/api/types.ts
Normal file
@ -0,0 +1,122 @@
|
||||
// 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 }>;
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
reasoning: string;
|
||||
confidence: number;
|
||||
status: string;
|
||||
instance_state?: string;
|
||||
knowledge_sources?: unknown;
|
||||
tool_calls?: 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;
|
||||
}
|
||||
|
||||
// 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 | 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 | null;
|
||||
sum_assured?: number | null;
|
||||
premium_amount?: number | null;
|
||||
premium_frequency?: string | null;
|
||||
recommended_product?: string | 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;
|
||||
pan_number?: string | null;
|
||||
aadhaar_number?: string | null;
|
||||
policy_number?: string | null;
|
||||
policy_issue_date?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
56
src/components/core/Avatar.tsx
Normal file
56
src/components/core/Avatar.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { Sparkles } 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();
|
||||
}
|
||||
|
||||
/** 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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full inline-flex items-center justify-center font-sans font-bold text-white shrink-0',
|
||||
ai && 'bg-sunrise shadow-sunrise',
|
||||
className,
|
||||
)}
|
||||
style={{ ...dim, background: ai ? undefined : COLORS[hashIdx(name)], ...style }}
|
||||
>
|
||||
{ai ? <Sparkles 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>
|
||||
);
|
||||
}
|
||||
54
src/components/core/Input.tsx
Normal file
54
src/components/core/Input.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
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,
|
||||
...rest
|
||||
}: InputProps) {
|
||||
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={cn(
|
||||
'flex items-center gap-2 bg-card rounded-md px-3 h-[42px] border transition-[border-color,box-shadow] duration-150',
|
||||
error ? 'border-ruby-600' : 'border-border-default focus-ring',
|
||||
)}
|
||||
>
|
||||
{prefix && <span className="text-faint text-base font-semibold">{prefix}</span>}
|
||||
<input
|
||||
required={required}
|
||||
className="flex-1 w-full border-none outline-none bg-transparent font-sans text-base 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>
|
||||
);
|
||||
}
|
||||
70
src/components/core/MultiSelect.tsx
Normal file
70
src/components/core/MultiSelect.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/** Chip-based multiselect. Click options to toggle; selected render as filled chips. */
|
||||
export function MultiSelect({
|
||||
label,
|
||||
options = [],
|
||||
value,
|
||||
defaultValue = [],
|
||||
onChange,
|
||||
className,
|
||||
}: 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>}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
14
src/components/core/index.ts
Normal file
14
src/components/core/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
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 } 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 { MultiSelect } from './MultiSelect';
|
||||
export type { MultiSelectProps, MultiSelectOption } from './MultiSelect';
|
||||
259
src/components/form/ActivityForm.tsx
Normal file
259
src/components/form/ActivityForm.tsx
Normal file
@ -0,0 +1,259 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
import { Button } from '../core/Button';
|
||||
import { Input } from '../core/Input';
|
||||
import { Select } from '../core/Select';
|
||||
import { MultiSelect } from '../core/MultiSelect';
|
||||
import { LookupField } from './LookupField';
|
||||
import type { LookupValue } from './LookupField';
|
||||
import { useZino } from '../../api/provider';
|
||||
import type { ActivityDef, FieldDef } from '../../api/forms';
|
||||
import type { LeadRecord } from '../../api/types';
|
||||
import type { ActivityResult } from '../../api/types';
|
||||
|
||||
export interface ActivityFormProps {
|
||||
def: ActivityDef;
|
||||
/** 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;
|
||||
switch (field.type) {
|
||||
case 'multiselect':
|
||||
return [];
|
||||
case 'boolean':
|
||||
return false;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Renders an activity's fields from its declarative spec and submits via
|
||||
* performActivity (or startInstance when no instanceId is given). */
|
||||
export function ActivityForm({ def, instanceId, record, onSuccess, successMessage }: ActivityFormProps) {
|
||||
const { client } = useZino();
|
||||
const [values, setValues] = useState<Record<string, unknown>>(() => {
|
||||
const init: Record<string, unknown> = {};
|
||||
def.fields.forEach((f) => (init[f.id] = seedFor(f, record)));
|
||||
return init;
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
|
||||
const set = (id: string, v: unknown) => setValues((p) => ({ ...p, [id]: v }));
|
||||
|
||||
const missing = useMemo(
|
||||
() =>
|
||||
def.fields.filter((f) => {
|
||||
if (!f.required) return false;
|
||||
const v = values[f.id];
|
||||
if (Array.isArray(v)) return v.length === 0;
|
||||
return v === '' || v === null || v === undefined;
|
||||
}),
|
||||
[def.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);
|
||||
|
||||
// Drop empty optional values so we don't overwrite with blanks.
|
||||
const data: Record<string, unknown> = {};
|
||||
for (const f of def.fields) {
|
||||
const v = values[f.id];
|
||||
if (v === '' || v === null || v === undefined) continue;
|
||||
if (Array.isArray(v) && v.length === 0) continue;
|
||||
data[f.id] =
|
||||
f.type === 'number' ? Number(v) || 0 : f.type === 'datetime' ? toRfc3339(String(v)) : v;
|
||||
}
|
||||
|
||||
try {
|
||||
const res =
|
||||
instanceId != null
|
||||
? await client.performActivity(instanceId, def.uid, data)
|
||||
: await client.startInstance(def.uid, data);
|
||||
setResult({ ok: true, msg: successMessage ?? `${def.name} ${def.done}.` });
|
||||
onSuccess?.(res);
|
||||
} catch (e) {
|
||||
setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{def.fields.map((f) => (
|
||||
<Field
|
||||
key={f.id}
|
||||
field={f}
|
||||
value={values[f.id]}
|
||||
onChange={(v) => set(f.id, v)}
|
||||
activityId={def.uid}
|
||||
instanceId={instanceId}
|
||||
/>
|
||||
))}
|
||||
</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' : def.submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
activityId,
|
||||
instanceId,
|
||||
}: {
|
||||
field: FieldDef;
|
||||
value: unknown;
|
||||
onChange: (v: unknown) => void;
|
||||
activityId: string;
|
||||
instanceId?: number | string;
|
||||
}) {
|
||||
const full = field.type === 'longtext' || field.type === 'multiselect' || field.type === 'lookup';
|
||||
const wrap = full ? 'col-span-full' : '';
|
||||
|
||||
switch (field.type) {
|
||||
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':
|
||||
return (
|
||||
<Select
|
||||
label={field.label}
|
||||
required={field.required}
|
||||
value={String(value ?? '')}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
options={[{ value: '', label: field.required ? 'Select…' : '— None —' }, ...(field.options ?? [])]}
|
||||
/>
|
||||
);
|
||||
|
||||
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}
|
||||
/>
|
||||
</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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
165
src/components/form/LookupField.tsx
Normal file
165
src/components/form/LookupField.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronDown, X } from 'lucide-react';
|
||||
import { cn } from '../../lib/cn';
|
||||
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;
|
||||
}
|
||||
|
||||
/** Searchable RDBMS-lookup picker (owner agent etc.). Mirrors the renderer's
|
||||
* FFLookupField: projects the picked row onto storage + display columns. */
|
||||
export function LookupField({
|
||||
label,
|
||||
required,
|
||||
templateUid,
|
||||
displayCols,
|
||||
storageCols,
|
||||
activityId,
|
||||
fieldId,
|
||||
instanceId,
|
||||
value,
|
||||
onChange,
|
||||
}: LookupFieldProps) {
|
||||
const { client } = useZino();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [rows, setRows] = useState<Array<Record<string, unknown>>>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const boxRef = useRef<HTMLLabelElement>(null);
|
||||
|
||||
const labelOf = (src: LookupValue | Record<string, unknown> | null) =>
|
||||
src
|
||||
? displayCols
|
||||
.map((c) => src[c])
|
||||
.filter((v) => v !== null && v !== undefined && v !== '')
|
||||
.join(', ')
|
||||
: '';
|
||||
|
||||
// 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 })
|
||||
.then((r) => {
|
||||
if (live) setRows(r.records ?? []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (live) setRows([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (live) setLoading(false);
|
||||
});
|
||||
}, 220);
|
||||
return () => {
|
||||
live = false;
|
||||
clearTimeout(t);
|
||||
};
|
||||
}, [open, search, templateUid, activityId, fieldId, instanceId, client]);
|
||||
|
||||
// Close on outside click.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
return () => document.removeEventListener('mousedown', onDoc);
|
||||
}, [open]);
|
||||
|
||||
function pick(row: Record<string, unknown>) {
|
||||
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);
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
}
|
||||
|
||||
const current = labelOf(value);
|
||||
|
||||
return (
|
||||
<label className="flex flex-col gap-1.5 font-sans relative" ref={boxRef}>
|
||||
{label && (
|
||||
<span className="text-sm font-medium text-muted">
|
||||
{label}
|
||||
{required && <span className="text-ruby-600"> *</span>}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className="flex items-center gap-2 bg-card rounded-md px-3 h-[42px] border border-border-default focus-ring cursor-pointer"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<span className={cn('flex-1 truncate text-base', current ? 'text-strong' : 'text-faint')}>
|
||||
{current || 'Select…'}
|
||||
</span>
|
||||
{value ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange(null);
|
||||
}}
|
||||
className="text-faint hover:text-strong"
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
) : (
|
||||
<ChevronDown size={15} className="text-faint" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 z-20 bg-card border border-border-default rounded-md shadow-lg overflow-hidden">
|
||||
<div className="p-2 border-b border-border-subtle">
|
||||
<input
|
||||
autoFocus
|
||||
value={search}
|
||||
onChange={(e) => setSearch(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>
|
||||
) : rows.length ? (
|
||||
rows.map((row, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={i}
|
||||
onClick={() => pick(row)}
|
||||
className="w-full text-left px-3 py-2.5 text-sm text-body hover:bg-slate-50 border-none bg-transparent cursor-pointer"
|
||||
>
|
||||
{labelOf(row) || `Row ${i + 1}`}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-3 text-sm text-faint">No matches.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
2
src/components/form/index.ts
Normal file
2
src/components/form/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './ActivityForm';
|
||||
export * from './LookupField';
|
||||
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';
|
||||
|
||||
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)' }}
|
||||
/>
|
||||
|
||||
<div className="flex gap-4 pl-[22px] pr-5 py-[18px]">
|
||||
{/* left: identity + content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<header className="flex items-center gap-2.5 mb-3">
|
||||
<Avatar name={employee} ai size={36} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base font-bold text-strong">{employee}</span>
|
||||
<span className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint bg-sunk px-[7px] py-[3px] rounded-pill">
|
||||
{role}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted mt-0.5">
|
||||
{activity}
|
||||
{time && <span className="text-faint"> · {time}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 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 p-3.5 bg-sunk rounded-md text-sm leading-relaxed text-body">
|
||||
{reasoning}
|
||||
</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>
|
||||
|
||||
{/* right: confidence ring */}
|
||||
<div className="shrink-0 flex flex-col items-center pt-0.5">
|
||||
<ConfidenceRing value={confidence} size={76} threshold={threshold} />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
136
src/components/insurance/DocumentUploadCard.tsx
Normal file
136
src/components/insurance/DocumentUploadCard.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
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;
|
||||
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,
|
||||
className,
|
||||
}: DocumentUploadCardProps) {
|
||||
const isEmpty = status === 'empty';
|
||||
|
||||
return (
|
||||
<div className={cn('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="p-[18px]">
|
||||
{isEmpty ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUpload}
|
||||
className="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 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 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>
|
||||
))}
|
||||
{status === 'extracted' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className="mt-3.5 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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
74
src/components/insurance/LeadRow.tsx
Normal file
74
src/components/insurance/LeadRow.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function LeadRow({ lead, onClick, className }: 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 */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
58
src/components/insurance/RupeeAmount.tsx
Normal file
58
src/components/insurance/RupeeAmount.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
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)',
|
||||
};
|
||||
|
||||
/** Oversized confident rupee numeral via Inter Tight. */
|
||||
export function RupeeAmount({ value, size = 'md', tone = 'default', sub, className, style }: RupeeAmountProps) {
|
||||
const fs = SIZES[size];
|
||||
return (
|
||||
<span className={cn('inline-flex flex-col leading-none font-numeric', className)} style={style}>
|
||||
<span
|
||||
className="font-extrabold tracking-[-0.02em] nums inline-flex items-baseline gap-0.5"
|
||||
style={{ fontSize: fs, color: TONES[tone] }}
|
||||
>
|
||||
<span className="font-bold" style={{ fontSize: fs * 0.62 }}>
|
||||
₹
|
||||
</span>
|
||||
{formatINR(value)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
60
src/components/insurance/TimelineEntry.tsx
Normal file
60
src/components/insurance/TimelineEntry.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '../../lib/cn';
|
||||
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) {
|
||||
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 ? 'bg-sunrise' : DOT[tone],
|
||||
)}
|
||||
style={{ boxShadow: '0 0 0 1px var(--border-subtle)' }}
|
||||
/>
|
||||
{!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={cn('text-sm font-bold', ai ? 'text-sunrise-600' : '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>
|
||||
);
|
||||
}
|
||||
70
src/components/insurance/WorkflowStepper.tsx
Normal file
70
src/components/insurance/WorkflowStepper.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import { Fragment } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { cn } from '../../lib/cn';
|
||||
import { WORKFLOW_STAGES } from '../../data/mockData';
|
||||
|
||||
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 = WORKFLOW_STAGES,
|
||||
current = 0,
|
||||
orientation = 'horizontal',
|
||||
className,
|
||||
}: WorkflowStepperProps) {
|
||||
const vertical = orientation === 'vertical';
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex font-sans',
|
||||
vertical ? 'flex-col items-stretch' : 'flex-row items-center',
|
||||
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 relative', vertical ? 'flex-row items-center gap-3' : 'flex-col items-center gap-2')}>
|
||||
<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
|
||||
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-[11px]',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
src/components/insurance/index.ts
Normal file
20
src/components/insurance/index.ts
Normal file
@ -0,0 +1,20 @@
|
||||
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 { LeadRow, LEAD_GRID } from './LeadRow';
|
||||
export type { LeadRowProps } from './LeadRow';
|
||||
export { DocumentUploadCard } from './DocumentUploadCard';
|
||||
export type { DocumentUploadCardProps, DocStatus, OcrField } from './DocumentUploadCard';
|
||||
104
src/data/mockData.ts
Normal file
104
src/data/mockData.ts
Normal file
@ -0,0 +1,104 @@
|
||||
// Shared mock data for the Aria console. Indian context throughout.
|
||||
import type {
|
||||
AiEmployee,
|
||||
Decision,
|
||||
FunnelStage,
|
||||
Kpi,
|
||||
Lead,
|
||||
SegmentDatum,
|
||||
} from '../types';
|
||||
|
||||
/** Lead-to-policy lifecycle stages, indexed by `Lead.stage`. */
|
||||
export const WORKFLOW_STAGES = [
|
||||
'New Lead',
|
||||
'Qualified',
|
||||
'Assigned',
|
||||
'Contacted',
|
||||
'Meeting Scheduled',
|
||||
'Recommended',
|
||||
'Underwriting',
|
||||
'Policy Issued',
|
||||
] as const;
|
||||
|
||||
export const LEADS: Lead[] = [
|
||||
{ id: 'L-2041', name: 'Priya Sharma', city: 'Pune', phone: '+91 98220 41xxx', email: 'priya.sharma@gmail.com', dob: '14 Aug 1990', age: 34, income: 2400000, occupation: 'Product Manager', language: 'Hindi', interest: 'Term Life', score: 87, segment: 'Hot', channel: 'WhatsApp', owner: 'Aria Engage', ownerAi: true, stage: 4, lastAction: 'Scheduled meeting · Thu 6:30 PM', lastTone: 'accent' },
|
||||
{ id: 'L-2042', name: 'Arjun Reddy', city: 'Bengaluru', phone: '+91 99860 12xxx', email: 'arjun.reddy@outlook.com', dob: '02 Mar 1986', age: 39, income: 3600000, occupation: 'Software Architect', language: 'English', interest: 'ULIP', score: 64, segment: 'Warm', channel: 'Web', owner: 'Aria', ownerAi: true, stage: 1, lastAction: 'Escalated — income mismatch', lastTone: 'warning' },
|
||||
{ id: 'L-2043', name: 'Meera Iyer', city: 'Mumbai', phone: '+91 98190 77xxx', email: 'meera.iyer@gmail.com', dob: '29 Nov 1994', age: 30, income: 1200000, occupation: 'Architect', language: 'Marathi', interest: 'Endowment', score: 41, segment: 'Cold', channel: 'Referral', owner: 'Rohan Mehta', ownerAi: false, stage: 0, lastAction: 'Awaiting first contact', lastTone: 'neutral' },
|
||||
{ id: 'L-2044', name: 'Vikram Nair', city: 'Kochi', phone: '+91 94470 30xxx', email: 'vikram.nair@yahoo.com', dob: '17 Jun 1982', age: 42, income: 5200000, occupation: 'Business Owner', language: 'Malayalam', interest: 'Whole Life', score: 79, segment: 'Hot', channel: 'Agency', owner: 'Aria Underwrite', ownerAi: true, stage: 6, lastAction: 'Underwriting in progress', lastTone: 'accent' },
|
||||
{ id: 'L-2045', name: 'Sneha Patel', city: 'Ahmedabad', phone: '+91 90990 55xxx', email: 'sneha.patel@gmail.com', dob: '08 Jan 1992', age: 33, income: 1800000, occupation: 'CA', language: 'Gujarati', interest: 'Child Plan', score: 72, segment: 'Warm', channel: 'Bancassurance', owner: 'Aria Engage', ownerAi: true, stage: 3, lastAction: 'Sent product brochure', lastTone: 'success' },
|
||||
{ id: 'L-2046', name: 'Rahul Verma', city: 'Delhi', phone: '+91 98110 22xxx', email: 'rahul.verma@gmail.com', dob: '23 Sep 1979', age: 45, income: 4100000, occupation: 'Consultant', language: 'Hindi', interest: 'Pension', score: 68, segment: 'Warm', channel: 'Direct', owner: 'Aria', ownerAi: true, stage: 2, lastAction: 'Qualified — assigned to agent', lastTone: 'success' },
|
||||
{ id: 'L-2047', name: 'Ananya Das', city: 'Kolkata', phone: '+91 98300 64xxx', email: 'ananya.das@gmail.com', dob: '11 Dec 1996', age: 28, income: 950000, occupation: 'Designer', language: 'Bengali', interest: 'Term Life', score: 38, segment: 'Cold', channel: 'Event', owner: 'Aria', ownerAi: true, stage: 0, lastAction: 'Captured at expo booth', lastTone: 'neutral' },
|
||||
{ id: 'L-2048', name: 'Karthik Rao', city: 'Hyderabad', phone: '+91 99490 18xxx', email: 'karthik.rao@gmail.com', dob: '05 May 1988', age: 37, income: 2800000, occupation: 'Doctor', language: 'Telugu', interest: 'Term Life', score: 91, segment: 'Hot', channel: 'WhatsApp', owner: 'Aria Engage', ownerAi: true, stage: 7, lastAction: 'Policy issued · ₹1.5 Cr', lastTone: 'success' },
|
||||
{ id: 'L-2049', name: 'Divya Menon', city: 'Chennai', phone: '+91 98410 73xxx', email: 'divya.menon@gmail.com', dob: '19 Feb 1991', age: 34, income: 2100000, occupation: 'HR Lead', language: 'Tamil', interest: 'ULIP', score: 81, segment: 'Hot', channel: 'Web', owner: 'Aria Engage', ownerAi: true, stage: 5, lastAction: 'Recommended Aria Smart Shield', lastTone: 'accent' },
|
||||
{ id: 'L-2050', name: 'Imran Sheikh', city: 'Lucknow', phone: '+91 94150 26xxx', email: 'imran.sheikh@gmail.com', dob: '30 Jul 1984', age: 40, income: 3300000, occupation: 'Civil Engineer', language: 'Hindi', interest: 'Whole Life', score: 76, segment: 'Hot', channel: 'Referral', owner: 'Aria Underwrite', ownerAi: true, stage: 6, lastAction: 'Underwriting in progress', lastTone: 'accent' },
|
||||
{ id: 'L-2051', name: 'Neha Joshi', city: 'Indore', phone: '+91 90390 41xxx', email: 'neha.joshi@gmail.com', dob: '12 Oct 1993', age: 31, income: 1500000, occupation: 'Teacher', language: 'Hindi', interest: 'Child Plan', score: 59, segment: 'Warm', channel: 'Bancassurance', owner: 'Aria', ownerAi: true, stage: 1, lastAction: 'Qualified — score 59', lastTone: 'success' },
|
||||
{ id: 'L-2052', name: 'Sanjay Gupta', city: 'Jaipur', phone: '+91 99280 67xxx', email: 'sanjay.gupta@gmail.com', dob: '08 Apr 1977', age: 48, income: 6200000, occupation: 'Business Owner', language: 'Hindi', interest: 'Pension', score: 83, segment: 'Hot', channel: 'Agency', owner: 'Rohan Mehta', ownerAi: false, stage: 3, lastAction: 'Brochure shared via email', lastTone: 'success' },
|
||||
{ id: 'L-2053', name: 'Pooja Reddy', city: 'Visakhapatnam', phone: '+91 89850 12xxx', email: 'pooja.reddy@gmail.com', dob: '25 Jun 1995', age: 29, income: 1100000, occupation: 'Analyst', language: 'Telugu', interest: 'Term Life', score: 48, segment: 'Cold', channel: 'Web', owner: 'Aria', ownerAi: true, stage: 2, lastAction: 'Assigned to agent', lastTone: 'neutral' },
|
||||
];
|
||||
|
||||
export const KPIS: Kpi[] = [
|
||||
{ label: 'Total leads', value: '2,847', delta: '12%', deltaDir: 'up' },
|
||||
{ label: 'Hot leads', value: '24', unit: '%', delta: '4%', deltaDir: 'up' },
|
||||
{ label: 'Conversion rate', value: '18.4', unit: '%', delta: '2.1%', deltaDir: 'up' },
|
||||
{ label: 'AI-handled', value: '78', unit: '%', accent: true },
|
||||
];
|
||||
|
||||
export const FUNNEL: FunnelStage[] = [
|
||||
{ stage: 'New Lead', count: 2847, pct: 100 },
|
||||
{ stage: 'Qualified', count: 1980, pct: 70 },
|
||||
{ stage: 'Assigned', count: 1540, pct: 54 },
|
||||
{ stage: 'Contacted', count: 1120, pct: 39 },
|
||||
{ stage: 'Meeting', count: 680, pct: 24 },
|
||||
{ stage: 'Recommended', count: 520, pct: 18 },
|
||||
{ stage: 'Underwriting', count: 410, pct: 14 },
|
||||
{ stage: 'Issued', count: 312, pct: 11 },
|
||||
];
|
||||
|
||||
export const SEGMENTS: SegmentDatum[] = [
|
||||
{ label: 'Hot', count: 684, color: 'var(--sunrise-500)' },
|
||||
{ label: 'Warm', count: 1138, color: 'var(--amber-500)' },
|
||||
{ label: 'Cold', count: 1025, color: 'var(--slate-400)' },
|
||||
];
|
||||
|
||||
/** Decision stream for the Lead 360 screen (Priya Sharma). */
|
||||
export const DECISIONS: Decision[] = [
|
||||
{
|
||||
employee: 'Aria Engage',
|
||||
role: 'Calls & Scheduling',
|
||||
activity: 'Scheduled meeting',
|
||||
time: '9:02 AM',
|
||||
confidence: 0.88,
|
||||
summary: 'Booked a video consultation for Thu 6:30 PM — the customer’s stated preference.',
|
||||
reasoning:
|
||||
'WhatsApp reply indicated availability after 6 PM on weekdays. Cross-checked the assigned agent’s calendar and booked the first open slot, then sent an ICS invite and a WhatsApp confirmation.',
|
||||
citations: ['Customer Preferences', 'Agent Calendar'],
|
||||
},
|
||||
{
|
||||
employee: 'Aria',
|
||||
role: 'Lead Qualifier',
|
||||
activity: 'Qualified lead',
|
||||
time: '8:47 AM',
|
||||
confidence: 0.91,
|
||||
summary: 'High-intent Term Life lead. Score 87 — routed to Aria Engage for outreach.',
|
||||
reasoning:
|
||||
'WhatsApp enquiry mentioned a new home loan of ₹80,00,000 and a dependent child — strong protection need. Declared income ₹24,00,000 supports a sum assured up to ₹2 Cr. Matched the Term Life ICP at 0.91 confidence.',
|
||||
citations: ['Lead Scoring Model v3', 'Term Life ICP', 'Income Multiplier Matrix'],
|
||||
},
|
||||
{
|
||||
employee: 'Aria Underwrite',
|
||||
role: 'Underwriting',
|
||||
activity: 'Pre-eligibility check',
|
||||
time: '8:49 AM',
|
||||
confidence: 0.58,
|
||||
summary: 'Pre-eligibility looks favourable, but medical history is incomplete. Escalated to a human agent.',
|
||||
reasoning:
|
||||
'Age 34 and non-smoker declaration support standard rates. However, the proposal lacks a recent health declaration and no medical reports are on file — confidence below the 0.70 autonomy threshold. Flagged for agent-led tele-underwriting.',
|
||||
citations: ['Underwriting Guidelines v4.2'],
|
||||
},
|
||||
];
|
||||
|
||||
export const AI_EMPLOYEES: AiEmployee[] = [
|
||||
{ name: 'Aria', role: 'Qualifier', active: 42 },
|
||||
{ name: 'Aria Engage', role: 'Calls & schedule', active: 31 },
|
||||
{ name: 'Aria Underwrite', role: 'Underwriting', active: 18 },
|
||||
];
|
||||
184
src/layout/Shell.tsx
Normal file
184
src/layout/Shell.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Bell, FileText, LogOut, Search, Sparkles, UserCheck, Users } from 'lucide-react';
|
||||
import { cn } from '../lib/cn';
|
||||
import { Avatar } from '../components/core';
|
||||
import { AI_EMPLOYEES } 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: '/assign', label: 'Assign', Icon: UserCheck, title: 'Assign leads', subtitle: 'Route qualified leads to a sales agent' },
|
||||
{ to: '/recommend', label: 'Recommend', Icon: Sparkles, title: 'Recommend product', subtitle: 'Build and send a product recommendation' },
|
||||
{ to: '/documents', label: 'Documents', Icon: FileText, title: 'Document collection', subtitle: 'KYC capture with OCR auto-extraction' },
|
||||
];
|
||||
|
||||
interface RosterEntry {
|
||||
name: string;
|
||||
role: string;
|
||||
active: number;
|
||||
}
|
||||
|
||||
// Live AI-employee roster: each config employee + count of in-flight leads it
|
||||
// currently owns (`ownerAi` is false on terminal states, so those drop off).
|
||||
function buildRoster(leads: Lead[]): RosterEntry[] {
|
||||
return Object.values(AI_EMPLOYEES).map((e) => ({
|
||||
name: e.name,
|
||||
role: e.role,
|
||||
active: leads.filter((l) => l.ownerAi && l.owner === e.name).length,
|
||||
}));
|
||||
}
|
||||
|
||||
// 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 AriaLogo() {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="w-8 h-8 rounded-[9px] bg-sunrise shadow-sunrise flex items-center justify-center text-white font-extrabold text-lg font-numeric">
|
||||
A
|
||||
</span>
|
||||
<span className="text-lg font-extrabold tracking-[-0.02em] text-on-navy">aria</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({ activeTo, onNav, roster }: { activeTo: string; onNav: (to: string) => void; roster: RosterEntry[] }) {
|
||||
return (
|
||||
<aside className="w-[248px] shrink-0 bg-navy-grad flex flex-col p-4 gap-6 font-sans">
|
||||
<div className="px-2 py-1">
|
||||
<AriaLogo />
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-1">
|
||||
{NAV.map((n) => {
|
||||
const on = activeTo === n.to;
|
||||
return (
|
||||
<button
|
||||
key={n.to}
|
||||
onClick={() => onNav(n.to)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-[11px] rounded-md border-none cursor-pointer text-left text-base transition-all duration-150',
|
||||
on
|
||||
? 'bg-[rgba(251,169,76,0.14)] text-white font-semibold'
|
||||
: 'bg-transparent text-on-navy-muted font-medium hover:bg-[rgba(251,169,76,0.07)]',
|
||||
)}
|
||||
>
|
||||
<n.Icon size={18} className={cn('shrink-0', on && 'text-sunrise-300')} />
|
||||
{n.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-2.5">
|
||||
<div className="text-[10px] font-bold uppercase tracking-[0.08em] text-on-navy-muted px-2">
|
||||
AI Employees
|
||||
</div>
|
||||
{roster.map((e) => (
|
||||
<div key={e.name} className="flex items-center gap-2.5 p-2 rounded-md bg-[rgba(255,255,255,0.04)]">
|
||||
<Avatar name={e.name} ai size={28} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-semibold text-on-navy truncate">{e.name}</div>
|
||||
<div className="text-[10px] text-on-navy-muted">{e.role}</div>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-emerald-300 flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
{e.active}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function Topbar({ title, subtitle }: { title: string; subtitle: string }) {
|
||||
const { user, logout } = useZino();
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<header className="h-16 shrink-0 bg-card border-b border-border-subtle flex items-center px-7 gap-5 font-sans">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="m-0 text-lg font-bold text-strong whitespace-nowrap">{title}</h1>
|
||||
{subtitle && <div className="text-xs text-faint mt-px">{subtitle}</div>}
|
||||
</div>
|
||||
<div className="flex items-center gap-3.5">
|
||||
<button className="w-[38px] h-[38px] rounded-md border border-border-subtle bg-card cursor-pointer text-muted flex items-center justify-center">
|
||||
<Search size={18} />
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button className="w-[38px] h-[38px] rounded-md border border-border-subtle bg-card cursor-pointer text-muted flex items-center justify-center">
|
||||
<Bell size={18} />
|
||||
</button>
|
||||
<span className="absolute -top-0.5 -right-0.5 w-[9px] h-[9px] rounded-full bg-sunrise-500 border-2 border-card" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pl-1.5">
|
||||
<Avatar name={user?.name || 'User'} size={36} />
|
||||
<div className="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 + AI-employee roster) + topbar, with routed content. */
|
||||
export function Shell() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { leads } = useLeads();
|
||||
const path = location.pathname;
|
||||
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 roster = useMemo(() => buildRoster(leads), [leads]);
|
||||
const subtitle = active.to === '/pipeline' ? pipelineSubtitle(leads) : active.subtitle;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-app font-sans">
|
||||
<Sidebar activeTo={navMatch?.to ?? ''} onNav={(to) => navigate(to)} roster={roster} />
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<Topbar title={active.title} subtitle={subtitle} />
|
||||
<main className="flex-1 overflow-auto 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(' ');
|
||||
}
|
||||
18
src/main.tsx
Normal file
18
src/main.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
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://sandbox.getzino.in';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ZinoProvider baseUrl={baseUrl}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ZinoProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
72
src/pages/Login.tsx
Normal file
72
src/pages/Login.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Button, Input } from '../components';
|
||||
import { useZino } from '../api/provider';
|
||||
|
||||
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 [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="min-h-screen flex items-center justify-center bg-navy-grad font-sans p-6">
|
||||
<div className="w-full max-w-[400px] bg-card rounded-lg shadow-lg p-8 flex flex-col gap-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="w-9 h-9 rounded-[10px] bg-sunrise shadow-sunrise flex items-center justify-center text-white font-extrabold text-xl font-numeric">
|
||||
A
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-lg font-extrabold tracking-[-0.02em] text-strong">aria</div>
|
||||
<div className="text-xs text-faint">Lead-to-policy console</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="username"
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
{error && (
|
||||
<div className="text-sm text-amber-700 bg-escalated-soft border border-amber-300 rounded-md px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<Button variant="primary" full disabled={busy}>
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
194
src/screens/AssignScreen.tsx
Normal file
194
src/screens/AssignScreen.tsx
Normal file
@ -0,0 +1,194 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { CheckCircle2, MapPin, UserCheck } from 'lucide-react';
|
||||
import { AIDecisionCard, Avatar, Badge, Button, Card, formatINR, Input, Select } from '../components';
|
||||
import { LookupField } from '../components/form';
|
||||
import type { LookupValue } from '../components/form';
|
||||
import { useQuery, useZino } from '../api/provider';
|
||||
import { useLeads } from '../api/leads';
|
||||
import { decisionToCard } from '../api/adapters';
|
||||
import { ACTIVITY_DEFS } from '../api/forms';
|
||||
|
||||
const DEF = ACTIVITY_DEFS.assign;
|
||||
const OWNER = DEF.fields.find((f) => f.id === 'owner_agent_input')!;
|
||||
|
||||
export function AssignScreen() {
|
||||
const { client } = useZino();
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const instanceParam = params.get('instance');
|
||||
|
||||
const { records, refetch } = useLeads();
|
||||
|
||||
// Assign Lead is valid at the Qualified state.
|
||||
const eligible = useMemo(() => records.filter((r) => r.current_state_name === 'Qualified'), [records]);
|
||||
|
||||
const [selectedId, setSelectedId] = useState('');
|
||||
useEffect(() => {
|
||||
if (instanceParam) setSelectedId(instanceParam);
|
||||
else if (!selectedId && eligible[0]) setSelectedId(String(eligible[0].instance_id));
|
||||
}, [instanceParam, eligible, selectedId]);
|
||||
|
||||
const record = useMemo(() => records.find((r) => String(r.instance_id) === selectedId), [records, selectedId]);
|
||||
|
||||
const [region, setRegion] = useState('');
|
||||
const [agent, setAgent] = useState<LookupValue | null>(null);
|
||||
useEffect(() => {
|
||||
if (!record) return;
|
||||
setRegion(record.assigned_region || record.state_region || '');
|
||||
setAgent((record.owner_agent as LookupValue) ?? null);
|
||||
}, [record]);
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
|
||||
async function submit() {
|
||||
if (!record) return;
|
||||
if (!region.trim()) {
|
||||
setResult({ ok: false, msg: 'Assigned region is required.' });
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setResult(null);
|
||||
try {
|
||||
await client.performActivity(record.instance_id, DEF.uid, {
|
||||
assigned_region_input: region,
|
||||
...(agent ? { owner_agent_input: agent } : {}),
|
||||
});
|
||||
setResult({ ok: true, msg: 'Lead assigned — advanced to Assigned.' });
|
||||
refetch();
|
||||
} catch (e) {
|
||||
setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_340px] gap-[18px] max-w-[1140px] mx-auto items-start">
|
||||
<div className="flex flex-col gap-[18px]">
|
||||
{/* context strip + lead picker */}
|
||||
<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">
|
||||
{record.lead_name ?? `Lead ${record.instance_id}`} · #{record.instance_id}
|
||||
</div>
|
||||
<div className="text-xs text-faint">
|
||||
{record.city ?? record.state_region ?? '—'} · {record.product_interest ?? 'No stated interest'} · income ₹
|
||||
{formatINR(record.annual_income ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
{record.lead_segment && <Badge tone="accent">{record.lead_segment}</Badge>}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 text-sm text-faint">Select a lead at the Qualified stage.</div>
|
||||
)}
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: eligible.length ? 'Choose lead…' : 'No qualified leads' },
|
||||
...records.map((r) => ({
|
||||
value: String(r.instance_id),
|
||||
label: `#${r.instance_id} ${r.lead_name ?? ''} · ${r.current_state_name ?? ''}`,
|
||||
})),
|
||||
]}
|
||||
value={selectedId}
|
||||
onChange={(e) => {
|
||||
setSelectedId(e.target.value);
|
||||
setResult(null);
|
||||
}}
|
||||
className="w-[260px]"
|
||||
/>
|
||||
</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}
|
||||
{result.ok && record && (
|
||||
<button
|
||||
className="ml-auto text-sm font-semibold text-link underline"
|
||||
onClick={() => navigate(`/leads/${record.instance_id}`)}
|
||||
>
|
||||
View lead
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card title="Assign to agent">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Assigned region"
|
||||
required
|
||||
prefix={<MapPin size={15} />}
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value)}
|
||||
placeholder="e.g. West / Maharashtra"
|
||||
/>
|
||||
<LookupField
|
||||
label="Owner agent"
|
||||
templateUid={OWNER.lookupTemplate!}
|
||||
displayCols={OWNER.lookupDisplay ?? []}
|
||||
storageCols={OWNER.lookupStorage ?? []}
|
||||
activityId={DEF.uid}
|
||||
fieldId={OWNER.id}
|
||||
instanceId={record?.instance_id}
|
||||
value={agent}
|
||||
onChange={setAgent}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2.5 mt-5">
|
||||
<Button variant="primary" disabled={!record || submitting} onClick={submit}>
|
||||
{submitting ? 'Assigning…' : 'Assign lead'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* RIGHT — assignment summary + AI suggestion */}
|
||||
<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 || 'Pick a region & agent'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AssignAiSuggestion instanceId={record?.instance_id} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssignAiSuggestion({ instanceId }: { instanceId?: number }) {
|
||||
const { client } = useZino();
|
||||
const q = useQuery(() => client.aiDecisions(instanceId!), [instanceId], instanceId != null);
|
||||
const latest = (q.data ?? [])[0];
|
||||
if (!latest) return null;
|
||||
const card = decisionToCard(latest);
|
||||
return (
|
||||
<>
|
||||
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-faint">AI suggestion</div>
|
||||
<AIDecisionCard {...card} defaultExpanded />
|
||||
</>
|
||||
);
|
||||
}
|
||||
379
src/screens/DocumentsScreen.tsx
Normal file
379
src/screens/DocumentsScreen.tsx
Normal file
@ -0,0 +1,379 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { CheckCircle2, FileText, Info, UploadCloud } from 'lucide-react';
|
||||
import { Avatar, Badge, Button, Card, DocumentUploadCard, formatINR, Input, Select } from '../components';
|
||||
import type { BadgeTone, DocStatus, OcrField } from '../components';
|
||||
import { useZino } from '../api/provider';
|
||||
import { useLeads } from '../api/leads';
|
||||
import { ACTIVITIES, DOC_STATUS_OPTIONS } from '../api/config';
|
||||
import { OCR_FIELDS } from '../api/forms';
|
||||
|
||||
const ACT = ACTIVITIES.COLLECT_DOCUMENTS;
|
||||
const F = ACT.fields;
|
||||
|
||||
const STATUS_TONE: Record<string, BadgeTone> = {
|
||||
pending: 'neutral',
|
||||
verified: 'success',
|
||||
mismatch: 'warning',
|
||||
incomplete: 'info',
|
||||
};
|
||||
|
||||
// One OCR document: file picker → /ocr-extract → autofills its mapped target
|
||||
// field(s). The picked File is held and uploaded at submit time.
|
||||
function OcrCapture({
|
||||
ocr,
|
||||
activityId,
|
||||
instanceId,
|
||||
onAutofill,
|
||||
onFile,
|
||||
}: {
|
||||
ocr: (typeof OCR_FIELDS)[keyof typeof OCR_FIELDS];
|
||||
activityId: string;
|
||||
instanceId?: number | string;
|
||||
onAutofill: (targetField: string, value: unknown) => void;
|
||||
onFile: (file: File | null) => 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);
|
||||
|
||||
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 ?? {};
|
||||
// Autofill mapped target inputs.
|
||||
for (const m of ocr.mappings) {
|
||||
if (Object.prototype.hasOwnProperty.call(extracted, m.extraction_key)) {
|
||||
onAutofill(m.target_field, extracted[m.extraction_key]);
|
||||
}
|
||||
}
|
||||
// Show every extracted key in the card for confirmation.
|
||||
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>
|
||||
<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')}
|
||||
/>
|
||||
{error && <div className="text-2xs text-amber-700 mt-1.5">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentsScreen() {
|
||||
const { client } = useZino();
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const instanceParam = params.get('instance');
|
||||
|
||||
const { records, refetch } = useLeads();
|
||||
|
||||
// Collect Documents is valid at Product Recommended (and re-collectable at
|
||||
// Documents Collected).
|
||||
const eligible = useMemo(
|
||||
() => records.filter((r) => r.current_state_name === 'Product Recommended' || r.current_state_name === 'Documents Collected'),
|
||||
[records],
|
||||
);
|
||||
|
||||
const [selectedId, setSelectedId] = useState('');
|
||||
useEffect(() => {
|
||||
if (instanceParam) setSelectedId(instanceParam);
|
||||
else if (!selectedId && eligible[0]) setSelectedId(String(eligible[0].instance_id));
|
||||
}, [instanceParam, eligible, selectedId]);
|
||||
|
||||
const record = useMemo(() => records.find((r) => String(r.instance_id) === selectedId), [records, selectedId]);
|
||||
|
||||
const [pan, setPan] = useState('');
|
||||
const [aadhaar, setAadhaar] = useState('');
|
||||
const [verifiedIncome, setVerifiedIncome] = useState(0);
|
||||
const [docStatus, setDocStatus] = useState('verified');
|
||||
// Picked-but-not-yet-uploaded files, keyed by field id.
|
||||
const [files, setFiles] = useState<Record<string, File | null>>({});
|
||||
|
||||
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({});
|
||||
}, [record]);
|
||||
|
||||
const setFile = (fieldId: string) => (f: File | null) => setFiles((p) => ({ ...p, [fieldId]: f }));
|
||||
|
||||
// OCR autofills its mapped target text field.
|
||||
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() {
|
||||
if (!record) return;
|
||||
setSubmitting(true);
|
||||
setResult(null);
|
||||
const activityId = ACT.uid;
|
||||
const instanceId = record.instance_id;
|
||||
try {
|
||||
// Upload any picked files first; store the returned meta as a 1-item array.
|
||||
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];
|
||||
}
|
||||
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.' });
|
||||
refetch();
|
||||
} 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 (
|
||||
<div className="grid grid-cols-[1fr_320px] gap-[18px] max-w-[1180px] mx-auto items-start">
|
||||
<div className="flex flex-col gap-[18px]">
|
||||
<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">Select a lead at the Product Recommended stage.</div>
|
||||
)}
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: eligible.length ? 'Choose lead…' : 'No eligible leads' },
|
||||
...records.map((r) => ({
|
||||
value: String(r.instance_id),
|
||||
label: `#${r.instance_id} ${r.lead_name ?? ''} · ${r.current_state_name ?? ''}`,
|
||||
})),
|
||||
]}
|
||||
value={selectedId}
|
||||
onChange={(e) => {
|
||||
setSelectedId(e.target.value);
|
||||
setResult(null);
|
||||
}}
|
||||
className="w-[260px]"
|
||||
/>
|
||||
</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}
|
||||
{result.ok && record && (
|
||||
<button
|
||||
className="ml-auto text-sm font-semibold text-link underline"
|
||||
onClick={() => navigate(`/leads/${record.instance_id}`)}
|
||||
>
|
||||
View lead
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OCR document capture */}
|
||||
<div className="grid grid-cols-2 gap-[18px]">
|
||||
<OcrCapture
|
||||
ocr={OCR_FIELDS.pan}
|
||||
activityId={ACT.uid}
|
||||
instanceId={record?.instance_id}
|
||||
onAutofill={applyAutofill}
|
||||
onFile={setFile(OCR_FIELDS.pan.id)}
|
||||
/>
|
||||
<OcrCapture
|
||||
ocr={OCR_FIELDS.aadhaar}
|
||||
activityId={ACT.uid}
|
||||
instanceId={record?.instance_id}
|
||||
onAutofill={applyAutofill}
|
||||
onFile={setFile(OCR_FIELDS.aadhaar.id)}
|
||||
/>
|
||||
</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)}
|
||||
/>
|
||||
<Select
|
||||
label="Document status"
|
||||
required
|
||||
options={DOC_STATUS_OPTIONS as unknown as Array<{ value: string; label: string }>}
|
||||
value={docStatus}
|
||||
onChange={(e) => setDocStatus(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Salary slip file upload */}
|
||||
<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={!record || submitting} onClick={submit}>
|
||||
{submitting ? 'Submitting…' : 'Submit documents'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* RIGHT — checklist */}
|
||||
<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-1">
|
||||
<span className="text-sm text-muted">Current status</span>
|
||||
<Badge tone={STATUS_TONE[docStatus] ?? 'neutral'} dot>
|
||||
{DOC_STATUS_OPTIONS.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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
313
src/screens/Lead360Screen.tsx
Normal file
313
src/screens/Lead360Screen.tsx
Normal file
@ -0,0 +1,313 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Ban, TriangleAlert, CheckCircle2, CircleDashed } from 'lucide-react';
|
||||
import {
|
||||
AIDecisionCard,
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
ChannelBadge,
|
||||
RupeeAmount,
|
||||
SegmentBadge,
|
||||
TimelineEntry,
|
||||
WorkflowStepper,
|
||||
} from '../components';
|
||||
import { ActivityForm } from '../components/form';
|
||||
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 { ACTIVITY_DEFS, DISQUALIFY_STATES, STATE_NEXT } from '../api/forms';
|
||||
import type { LeadRecord } from '../api/types';
|
||||
|
||||
/** Contextual next-step action for a lead, driven by its current state.
|
||||
* Routed steps (Assign / Recommend / Documents) deep-link to their rich
|
||||
* screens; everything else renders its activity form inline. Disqualify is a
|
||||
* secondary action wherever it's valid. */
|
||||
function NextActionPanel({ record, onAdvanced }: { record: LeadRecord; onAdvanced: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
const [showDisqualify, setShowDisqualify] = useState(false);
|
||||
const state = record.current_state_name ?? '';
|
||||
const next = STATE_NEXT[state];
|
||||
const inlineDef = next && !next.route ? ACTIVITY_DEFS[next.activity] : undefined;
|
||||
const canDisqualify = DISQUALIFY_STATES.has(state);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-2xs font-bold uppercase tracking-[0.06em] text-faint">Next action</span>
|
||||
{canDisqualify && !next?.route && (
|
||||
<button
|
||||
onClick={() => setShowDisqualify((s) => !s)}
|
||||
className="text-2xs font-semibold text-ruby-600 inline-flex items-center gap-1 bg-transparent border-none cursor-pointer"
|
||||
>
|
||||
<Ban size={12} /> {showDisqualify ? 'Cancel' : 'Disqualify'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDisqualify ? (
|
||||
<ActivityForm
|
||||
def={ACTIVITY_DEFS.disqualify}
|
||||
instanceId={record.instance_id}
|
||||
onSuccess={onAdvanced}
|
||||
/>
|
||||
) : next?.route ? (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="primary" size="sm" onClick={() => navigate(`${next.route}?instance=${record.instance_id}`)}>
|
||||
{next.label ?? 'Continue'}
|
||||
</Button>
|
||||
</div>
|
||||
) : inlineDef ? (
|
||||
<ActivityForm def={inlineDef} instanceId={record.instance_id} record={record} onSuccess={onAdvanced} />
|
||||
) : (
|
||||
<div className="text-sm text-faint">
|
||||
{state === 'Disqualified' ? 'This lead was disqualified.' : 'Lifecycle complete — no further action.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileRow({ label, value, mono }: { label: string; value: ReactNode; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2.5 border-b border-border-subtle last:border-b-0">
|
||||
<span className="text-xs text-faint">{label}</span>
|
||||
<span className={mono ? 'text-sm font-semibold text-strong font-mono' : 'text-sm font-semibold text-strong'}>
|
||||
{value}
|
||||
</span>
|
||||
</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;
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
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);
|
||||
const escalations = decisions.filter((d) => d.confidence < 0.7);
|
||||
|
||||
// welcome_pack_sent / onboarding_notes are `local` fields — they persist in the
|
||||
// Onboard activity submission (audit feed), not the lead recordview. Pull them
|
||||
// from the audit entry so Customer 360 can show the onboarding outcome.
|
||||
const onboard = audit.find((e) => e.activity_id === ONBOARD_ACTIVITY_UID)?.data;
|
||||
const onboarded = !!onboard;
|
||||
const welcomePackSent = onboard?.welcome_pack_sent === true;
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[300px_1fr_380px] gap-[18px] max-w-[1320px] mx-auto items-start">
|
||||
{/* LEFT — profile */}
|
||||
<div className="flex flex-col gap-[18px]">
|
||||
<Card>
|
||||
<div className="flex flex-col items-center text-center gap-2.5 pb-1.5">
|
||||
<Avatar name={lead.name} size={64} />
|
||||
<div>
|
||||
<div className="text-lg font-bold text-strong">{lead.name}</div>
|
||||
<div className="text-xs text-faint mt-0.5">
|
||||
#{lead.id} · {lead.city}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<SegmentBadge segment={lead.segment} />
|
||||
<ChannelBadge channel={lead.channel} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center gap-2 mt-1.5">
|
||||
<Button variant="primary" size="sm">
|
||||
Call
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm">
|
||||
WhatsApp
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Profile">
|
||||
<ProfileRow label="Phone" value={lead.phone} />
|
||||
<ProfileRow label="Email" value={lead.email} />
|
||||
<ProfileRow label="Date of birth" value={lead.age ? `${lead.dob} · ${lead.age} yrs` : lead.dob} />
|
||||
<ProfileRow label="Occupation" value={lead.occupation} />
|
||||
<ProfileRow label="Annual income" value={<RupeeAmount value={lead.income} size="sm" />} />
|
||||
<ProfileRow label="Preferred language" value={lead.language} />
|
||||
<div className="flex items-center justify-between pt-3">
|
||||
<span className="text-xs text-faint">Product interest</span>
|
||||
<Badge tone="accent">{lead.interest}</Badge>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* CENTER — workflow + current activity */}
|
||||
<div className="flex flex-col gap-[18px]">
|
||||
<Card title="Workflow">
|
||||
<div className="overflow-x-auto pb-1">
|
||||
<WorkflowStepper stages={STAGES} current={Math.max(0, lead.stage)} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Current activity"
|
||||
action={<Badge tone={lead.stage >= STAGES.indexOf('Policy Issued') ? 'success' : 'warning'} dot>{record.current_state_name ?? '—'}</Badge>}
|
||||
>
|
||||
<div className="flex items-center gap-3.5 mb-4">
|
||||
<Avatar name={lead.owner} ai={lead.ownerAi} size={44} />
|
||||
<div className="flex-1">
|
||||
<div className="text-md font-bold text-strong">{record.current_state_name ?? 'In progress'}</div>
|
||||
<div className="text-xs text-muted mt-0.5">
|
||||
Owned by {lead.owner}
|
||||
{meetingLabel ? ` · meeting ${meetingLabel}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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">Sum assured</div>
|
||||
<RupeeAmount value={cover} size="lg" tone="navy" />
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-md px-3.5 py-3">
|
||||
<div className="text-2xs text-faint mb-1.5">Premium</div>
|
||||
<RupeeAmount value={premium} size="lg" tone="accent" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-border-subtle">
|
||||
<NextActionPanel record={record} onAdvanced={onAdvanced} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{onboarded && (
|
||||
<Card
|
||||
title="Policy & onboarding"
|
||||
action={
|
||||
<Badge tone={welcomePackSent ? 'success' : 'warning'} dot>
|
||||
{welcomePackSent ? 'Onboarded' : 'Pending pack'}
|
||||
</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">
|
||||
{welcomePackSent ? (
|
||||
<CheckCircle2 size={16} className="text-emerald-600 shrink-0" />
|
||||
) : (
|
||||
<CircleDashed size={16} className="text-faint shrink-0" />
|
||||
)}
|
||||
<span className="text-sm text-strong">
|
||||
Welcome pack {welcomePackSent ? 'sent' : 'not sent yet'}
|
||||
</span>
|
||||
</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>
|
||||
|
||||
{/* RIGHT — AI decision stream + escalations */}
|
||||
<div className="flex flex-col gap-3.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="m-0 text-sm font-bold text-strong">AI decision stream</h3>
|
||||
<Badge tone="navy">{decisions.length} actions</Badge>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{decisionsQ.loading && <div className="text-sm text-faint">Loading decisions…</div>}
|
||||
{!decisionsQ.loading && decisions.length === 0 && (
|
||||
<div className="text-sm text-faint">No AI decisions for this lead yet.</div>
|
||||
)}
|
||||
{decisions.map((d, i) => (
|
||||
<AIDecisionCard key={i} {...d} defaultExpanded={i === 0} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/screens/NewLeadScreen.tsx
Normal file
27
src/screens/NewLeadScreen.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card } from '../components';
|
||||
import { ActivityForm } from '../components/form';
|
||||
import { useLeads } from '../api/leads';
|
||||
import { ACTIVITY_DEFS } from '../api/forms';
|
||||
|
||||
/** Capture Lead — the INIT activity. Creates a new instance via /start. */
|
||||
export function NewLeadScreen() {
|
||||
const navigate = useNavigate();
|
||||
const { refetch } = useLeads();
|
||||
|
||||
return (
|
||||
<div className="max-w-[820px] mx-auto">
|
||||
<Card title="New lead">
|
||||
<ActivityForm
|
||||
def={ACTIVITY_DEFS.capture}
|
||||
successMessage="Lead created — opening the lead…"
|
||||
onSuccess={(res) => {
|
||||
refetch();
|
||||
const id = res.instance_id;
|
||||
if (id != null) navigate(`/leads/${id}`);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
295
src/screens/PipelineScreen.tsx
Normal file
295
src/screens/PipelineScreen.tsx
Normal file
@ -0,0 +1,295 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Columns3, Plus, Table } from 'lucide-react';
|
||||
import { cn } from '../lib/cn';
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
ChannelBadge,
|
||||
KpiCard,
|
||||
LeadRow,
|
||||
LEAD_GRID,
|
||||
SegmentBadge,
|
||||
} from '../components';
|
||||
import type { Kpi, Lead, SegmentDatum } from '../types';
|
||||
import { useLeads } from '../api/leads';
|
||||
import { STAGES } from '../api/config';
|
||||
|
||||
function scoreColor(score: number): string {
|
||||
if (score >= 75) return 'var(--sunrise-600)';
|
||||
if (score >= 50) return 'var(--amber-600)';
|
||||
return 'var(--slate-500)';
|
||||
}
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
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-2.5 transition-all duration-150 hover:shadow-md hover:-translate-y-px"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Avatar name={lead.name} size={30} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-strong truncate">{lead.name}</div>
|
||||
<div className="text-2xs text-faint">{lead.city}</div>
|
||||
</div>
|
||||
<span className="font-numeric font-extrabold text-lg leading-none nums" style={{ color: scoreColor(lead.score) }}>
|
||||
{lead.score}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<SegmentBadge segment={lead.segment} size="sm" />
|
||||
<ChannelBadge channel={lead.channel} size="sm" />
|
||||
</div>
|
||||
<div className="flex items-center gap-[7px] pt-2.5 border-t border-border-subtle">
|
||||
<Avatar name={lead.owner} ai={lead.ownerAi} size={20} />
|
||||
<span className="text-2xs text-muted 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">
|
||||
{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>
|
||||
<div className="flex flex-col gap-2.5 bg-slate-100 rounded-md p-2.5 min-h-[120px]">
|
||||
{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, loading, error } = useLeads();
|
||||
const kpis = useMemo(() => buildKpis(leads), [leads]);
|
||||
const funnel = useMemo(() => buildFunnel(leads), [leads]);
|
||||
const segments = useMemo(() => buildSegments(leads), [leads]);
|
||||
|
||||
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-4 gap-4">
|
||||
{kpis.map((k, i) => (
|
||||
<KpiCard key={i} {...k} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* charts */}
|
||||
<div className="grid 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">
|
||||
<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} />
|
||||
<Button variant="secondary" size="sm">
|
||||
Filter
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={() => navigate('/new')}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Plus size={15} />
|
||||
New lead
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="p-10 text-center text-sm text-faint">Loading leads…</div>
|
||||
) : view === 'table' ? (
|
||||
<div>
|
||||
<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>
|
||||
{leads.length ? (
|
||||
leads.map((l) => <LeadRow key={l.id} lead={l} onClick={() => openLead(l)} />)
|
||||
) : (
|
||||
<div className="p-10 text-center text-sm text-faint">No leads yet.</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-5">
|
||||
<KanbanBoard leads={leads} onOpenLead={openLead} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
286
src/screens/RecommendScreen.tsx
Normal file
286
src/screens/RecommendScreen.tsx
Normal file
@ -0,0 +1,286 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
import {
|
||||
AIDecisionCard,
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
formatINR,
|
||||
Input,
|
||||
MultiSelect,
|
||||
RupeeAmount,
|
||||
Select,
|
||||
} from '../components';
|
||||
import { useQuery, useZino } from '../api/provider';
|
||||
import { useLeads } from '../api/leads';
|
||||
import { decisionToCard } from '../api/adapters';
|
||||
import { ACTIVITIES, FREQUENCY_OPTIONS, PRODUCT_OPTIONS, RIDER_OPTIONS } from '../api/config';
|
||||
import type { LeadRecord } from '../api/types';
|
||||
|
||||
const F = ACTIVITIES.RECOMMEND_PRODUCT.fields;
|
||||
|
||||
// Crude indicative premium — rate per sum assured, adjusted by frequency.
|
||||
function indicativePremium(sum: number, freq: string): number {
|
||||
const base = Math.round(sum * 0.00142);
|
||||
switch (freq) {
|
||||
case 'monthly':
|
||||
return Math.round((base / 12) * 1.04);
|
||||
case 'half_yearly':
|
||||
return Math.round((base / 2) * 1.02);
|
||||
case 'quarterly':
|
||||
return Math.round((base / 4) * 1.03);
|
||||
default:
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
function toRiderValues(raw: LeadRecord['riders']): string[] {
|
||||
if (!raw) return [];
|
||||
if (Array.isArray(raw)) return raw as string[];
|
||||
return String(raw)
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function RecommendScreen() {
|
||||
const { client } = useZino();
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const instanceParam = params.get('instance');
|
||||
|
||||
const { records, refetch } = useLeads();
|
||||
|
||||
// Leads where Recommend Product is valid (state = Meeting Scheduled).
|
||||
const eligible = useMemo(
|
||||
() => records.filter((r) => r.current_state_name === 'Meeting Scheduled'),
|
||||
[records],
|
||||
);
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string>('');
|
||||
useEffect(() => {
|
||||
if (instanceParam) setSelectedId(instanceParam);
|
||||
else if (!selectedId && eligible[0]) setSelectedId(String(eligible[0].instance_id));
|
||||
}, [instanceParam, eligible, selectedId]);
|
||||
|
||||
const record = useMemo(
|
||||
() => records.find((r) => String(r.instance_id) === selectedId),
|
||||
[records, selectedId],
|
||||
);
|
||||
|
||||
// Form state — seeded from the instance's existing values when present.
|
||||
const [product, setProduct] = useState('term');
|
||||
const [sum, setSum] = useState(2000000);
|
||||
const [freq, setFreq] = useState('annual');
|
||||
const [premium, setPremium] = useState(0);
|
||||
const [premiumTouched, setPremiumTouched] = useState(false);
|
||||
const [riders, setRiders] = useState<string[]>(['critical_illness', 'accidental_death']);
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!record) return;
|
||||
setProduct(record.recommended_product || 'term');
|
||||
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]);
|
||||
|
||||
const computed = indicativePremium(sum, freq);
|
||||
const effectivePremium = premiumTouched ? premium : computed;
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
|
||||
async function submit() {
|
||||
if (!record) return;
|
||||
setSubmitting(true);
|
||||
setResult(null);
|
||||
try {
|
||||
await client.performActivity(record.instance_id, ACTIVITIES.RECOMMEND_PRODUCT.uid, {
|
||||
[F.product]: product,
|
||||
[F.sumAssured]: sum,
|
||||
[F.premiumAmount]: effectivePremium,
|
||||
[F.premiumFrequency]: freq,
|
||||
[F.riders]: riders,
|
||||
[F.notes]: notes,
|
||||
});
|
||||
setResult({ ok: true, msg: 'Recommendation submitted — lead advanced to Product Recommended.' });
|
||||
refetch();
|
||||
} catch (e) {
|
||||
setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const lead = record;
|
||||
const incomeMultiple = lead?.annual_income ? (sum / lead.annual_income).toFixed(1) : '—';
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_360px] gap-[18px] max-w-[1140px] mx-auto items-start">
|
||||
<div className="flex flex-col gap-[18px]">
|
||||
{/* context strip + lead picker */}
|
||||
<div className="flex items-center gap-3 bg-card border border-border-subtle rounded-lg px-[18px] py-3.5 shadow-sm">
|
||||
{lead ? (
|
||||
<>
|
||||
<Avatar name={lead.lead_name ?? `Lead ${lead.instance_id}`} size={40} />
|
||||
<div className="flex-1">
|
||||
<div className="text-base font-bold text-strong">
|
||||
{lead.lead_name ?? `Lead ${lead.instance_id}`} · #{lead.instance_id}
|
||||
</div>
|
||||
<div className="text-xs text-faint">
|
||||
{lead.assigned_region ?? lead.city ?? '—'} · {lead.product_interest ?? 'No stated interest'} · income ₹
|
||||
{formatINR(lead.annual_income ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 text-sm text-faint">Select a lead at the Meeting Scheduled stage.</div>
|
||||
)}
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: eligible.length ? 'Choose lead…' : 'No eligible leads' },
|
||||
...records.map((r) => ({
|
||||
value: String(r.instance_id),
|
||||
label: `#${r.instance_id} ${r.lead_name ?? ''} · ${r.current_state_name ?? ''}`,
|
||||
})),
|
||||
]}
|
||||
value={selectedId}
|
||||
onChange={(e) => {
|
||||
setSelectedId(e.target.value);
|
||||
setResult(null);
|
||||
}}
|
||||
className="w-[260px]"
|
||||
/>
|
||||
</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}
|
||||
{result.ok && lead && (
|
||||
<button
|
||||
className="ml-auto text-sm font-semibold text-link underline"
|
||||
onClick={() => navigate(`/leads/${lead.instance_id}`)}
|
||||
>
|
||||
View lead
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card title="Recommend product">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Select
|
||||
label="Product type"
|
||||
options={PRODUCT_OPTIONS as unknown as Array<{ value: string; label: string }>}
|
||||
value={product}
|
||||
onChange={(e) => setProduct(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Sum assured"
|
||||
prefix="₹"
|
||||
type="number"
|
||||
value={sum}
|
||||
onChange={(e) => setSum(Number(e.target.value) || 0)}
|
||||
/>
|
||||
<Select
|
||||
label="Premium frequency"
|
||||
options={FREQUENCY_OPTIONS as unknown as Array<{ value: string; label: string }>}
|
||||
value={freq}
|
||||
onChange={(e) => setFreq(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Premium amount"
|
||||
prefix="₹"
|
||||
type="number"
|
||||
value={effectivePremium}
|
||||
hint={premiumTouched ? undefined : `Indicative — ₹${formatINR(computed)}`}
|
||||
onChange={(e) => {
|
||||
setPremium(Number(e.target.value) || 0);
|
||||
setPremiumTouched(true);
|
||||
}}
|
||||
/>
|
||||
<div className="col-span-full">
|
||||
<MultiSelect
|
||||
label="Riders"
|
||||
options={RIDER_OPTIONS as unknown as Array<{ value: string; label: string }>}
|
||||
value={riders}
|
||||
onChange={setRiders}
|
||||
/>
|
||||
</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>
|
||||
<div className="flex gap-2.5 mt-5">
|
||||
<Button variant="primary" disabled={!record || submitting} onClick={submit}>
|
||||
{submitting ? 'Submitting…' : 'Send recommendation'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* RIGHT — indicative premium helper + AI suggestion */}
|
||||
<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">
|
||||
{(FREQUENCY_OPTIONS.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={record?.instance_id} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Pull the latest AI decision for this lead, if any, as the suggestion card.
|
||||
function RecommendAiSuggestion({ instanceId }: { instanceId?: number }) {
|
||||
const { client } = useZino();
|
||||
const q = useQuery(() => client.aiDecisions(instanceId!), [instanceId], instanceId != null);
|
||||
const latest = (q.data ?? [])[0];
|
||||
if (!latest) return null;
|
||||
const card = decisionToCard(latest);
|
||||
return (
|
||||
<>
|
||||
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-faint">AI suggestion</div>
|
||||
<AIDecisionCard {...card} defaultExpanded />
|
||||
</>
|
||||
);
|
||||
}
|
||||
160
src/styles/styles.css
Normal file
160
src/styles/styles.css
Normal file
@ -0,0 +1,160 @@
|
||||
/* ============================================================
|
||||
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,
|
||||
body,
|
||||
#root {
|
||||
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);
|
||||
}
|
||||
}
|
||||
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"]
|
||||
}
|
||||
1
tsconfig.app.tsbuildinfo
Normal file
1
tsconfig.app.tsbuildinfo
Normal file
@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/api/adapters.ts","./src/api/client.ts","./src/api/config.ts","./src/api/forms.ts","./src/api/leads.tsx","./src/api/provider.tsx","./src/api/types.ts","./src/components/index.ts","./src/components/core/avatar.tsx","./src/components/core/badge.tsx","./src/components/core/button.tsx","./src/components/core/card.tsx","./src/components/core/input.tsx","./src/components/core/multiselect.tsx","./src/components/core/select.tsx","./src/components/core/index.ts","./src/components/form/activityform.tsx","./src/components/form/lookupfield.tsx","./src/components/form/index.ts","./src/components/insurance/aidecisioncard.tsx","./src/components/insurance/channelbadge.tsx","./src/components/insurance/confidencering.tsx","./src/components/insurance/documentuploadcard.tsx","./src/components/insurance/kpicard.tsx","./src/components/insurance/leadrow.tsx","./src/components/insurance/rupeeamount.tsx","./src/components/insurance/segmentbadge.tsx","./src/components/insurance/timelineentry.tsx","./src/components/insurance/workflowstepper.tsx","./src/components/insurance/index.ts","./src/data/mockdata.ts","./src/layout/shell.tsx","./src/lib/cn.ts","./src/pages/login.tsx","./src/screens/assignscreen.tsx","./src/screens/documentsscreen.tsx","./src/screens/lead360screen.tsx","./src/screens/newleadscreen.tsx","./src/screens/pipelinescreen.tsx","./src/screens/recommendscreen.tsx"],"version":"5.9.3"}
|
||||
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"]
|
||||
}
|
||||
1
tsconfig.node.tsbuildinfo
Normal file
1
tsconfig.node.tsbuildinfo
Normal file
@ -0,0 +1 @@
|
||||
{"root":["./vite.config.ts"],"version":"5.9.3"}
|
||||
8
vite.config.ts
Normal file
8
vite.config.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user