initial commit

This commit is contained in:
Bhanu Prakash Sai Potteri 2026-06-22 10:36:16 +05:30
commit 714f8ab8ff
60 changed files with 7857 additions and 0 deletions

19
.gitignore vendored Normal file
View 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
View File

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

12
index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

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

46
src/App.tsx Normal file
View 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
View 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
View 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
View 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
View 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: '0100' },
{
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
View 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
View 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
View 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;
}

View 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>
);
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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>
);
}

View 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>
);
}

View File

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

View File

@ -0,0 +1,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';

View 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)}
/>
);
}
}
}

View 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>
);
}

View File

@ -0,0 +1,2 @@
export * from './ActivityForm';
export * from './LookupField';

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

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

View File

@ -0,0 +1,140 @@
import { useState } from 'react';
import type { CSSProperties } from 'react';
import { ChevronRight, FileText } from 'lucide-react';
import { cn } from '../../lib/cn';
import { Avatar } from '../core/Avatar';
import { ConfidenceRing } from './ConfidenceRing';
export type Citation = string | { title: string };
export interface AIDecisionCardProps {
employee?: string;
role?: string;
activity?: string;
/** Confidence 01. @default 0.85 */
confidence?: number;
summary?: string;
reasoning?: string | null;
citations?: Citation[];
time?: string | null;
defaultExpanded?: boolean;
/** Autonomy threshold. @default 0.7 */
threshold?: number;
className?: string;
style?: CSSProperties;
}
/**
* AI DECISION CARD Aria's signature component.
* Which AI employee acted, the activity, a confidence ring, a one-line
* reasoning summary, expandable full reasoning, and cited-knowledge chips.
*/
export function AIDecisionCard({
employee = 'Aria',
role = 'Lead Qualifier',
activity,
confidence = 0.85,
summary,
reasoning = null,
citations = [],
time = null,
defaultExpanded = false,
threshold = 0.7,
className,
style,
}: AIDecisionCardProps) {
const [open, setOpen] = useState(defaultExpanded);
const autonomous = confidence >= threshold;
const accent = autonomous ? 'var(--status-autonomous)' : 'var(--status-escalated)';
const accentSoft = autonomous ? 'var(--status-autonomous-soft)' : 'var(--status-escalated-soft)';
return (
<article
className={cn('relative bg-card rounded-lg border border-border-subtle shadow-md overflow-hidden font-sans', className)}
style={style}
>
{/* accent rail */}
<span
className={cn('absolute top-0 left-0 bottom-0 w-1', autonomous && 'bg-sunrise')}
style={autonomous ? undefined : { background: 'var(--status-escalated)' }}
/>
<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>
);
}

View File

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

View File

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

View File

@ -0,0 +1,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 &amp; verify
</button>
)}
</div>
)}
</div>
</div>
);
}

View File

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

View File

@ -0,0 +1,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>
);
}

View 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>
);
}

View File

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

View File

@ -0,0 +1,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>
);
}

View 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>
);
}

View 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
View 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 customers stated preference.',
reasoning:
'WhatsApp reply indicated availability after 6 PM on weekdays. Cross-checked the assigned agents 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
View 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
View File

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

18
src/main.tsx Normal file
View 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
View 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>
);
}

View 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 />
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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">
Couldnt 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>
);
}

View 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
View 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);
}
}

View File

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

View File

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

View File

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

View File

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

77
src/types.ts Normal file
View File

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

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

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

23
tsconfig.app.json Normal file
View File

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

1
tsconfig.app.tsbuildinfo Normal file
View 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
View File

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

17
tsconfig.node.json Normal file
View File

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

View File

@ -0,0 +1 @@
{"root":["./vite.config.ts"],"version":"5.9.3"}

8
vite.config.ts Normal file
View 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()],
});