feat: worklist screens + modal-driven activity forms

- state-scoped lead worklists (list → row action → form modal) replace per-activity screens
- shared Modal / ActivityActions / registry; bespoke bodies + generic ActivityForm
- screen RBAC (canSeeScreen + RequireScreen), client-side pagination, lead filters
- fixes: document scroll, PickerShell z-index above modal, friendly RBAC message, Lead360 compact INR, segments-card alignment
This commit is contained in:
Bhanu Prakash Sai Potteri 2026-06-24 17:04:26 +05:30
parent 8f419919bb
commit 1a622b1267
32 changed files with 1752 additions and 690 deletions

View File

@ -2,16 +2,18 @@ import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import type { ReactNode } from 'react';
import { Shell } from './layout/Shell';
import { PipelineScreen } from './screens/PipelineScreen';
import { ScheduleScreen } from './screens/ScheduleScreen';
import { Lead360Screen } from './screens/Lead360Screen';
import { AssignScreen } from './screens/AssignScreen';
import { RecommendScreen } from './screens/RecommendScreen';
import { DocumentsScreen } from './screens/DocumentsScreen';
import { UnderwritingScreen } from './screens/UnderwritingScreen';
import { IssuePolicyScreen } from './screens/IssuePolicyScreen';
import { NewLeadScreen } from './screens/NewLeadScreen';
import {
QualificationScreen,
InteractionScreen,
DocAssessmentScreen,
UnderwritingPolicyScreen,
} from './screens/worklists';
import { Login } from './pages/Login';
import { useZino } from './api/provider';
import { LeadsProvider } from './api/leads';
import { canSeeScreen } from './api/config';
function RequireAuth({ children }: { children: ReactNode }) {
const { user } = useZino();
@ -20,6 +22,14 @@ function RequireAuth({ children }: { children: ReactNode }) {
return <>{children}</>;
}
// Deep-link guard — a screen the user's roles can't see redirects to the
// dashboard (which is open to every operational role).
function RequireScreen({ path, children }: { path: string; children: ReactNode }) {
const { user } = useZino();
if (!canSeeScreen(user?.roles, path)) return <Navigate to="/pipeline" replace />;
return <>{children}</>;
}
export function App() {
return (
<Routes>
@ -35,14 +45,13 @@ export function App() {
>
<Route index element={<Navigate to="/pipeline" replace />} />
<Route path="pipeline" element={<PipelineScreen />} />
<Route path="new" element={<NewLeadScreen />} />
<Route path="schedule" element={<RequireScreen path="/schedule"><ScheduleScreen /></RequireScreen>} />
<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="underwriting" element={<UnderwritingScreen />} />
<Route path="issue" element={<IssuePolicyScreen />} />
<Route path="qualify" element={<RequireScreen path="/qualify"><QualificationScreen /></RequireScreen>} />
<Route path="engage" element={<RequireScreen path="/engage"><InteractionScreen /></RequireScreen>} />
<Route path="documents" element={<RequireScreen path="/documents"><DocAssessmentScreen /></RequireScreen>} />
<Route path="underwriting" element={<RequireScreen path="/underwriting"><UnderwritingPolicyScreen /></RequireScreen>} />
<Route path="*" element={<Navigate to="/pipeline" replace />} />
</Route>
</Routes>

View File

@ -149,3 +149,29 @@ export function aiEmployeeForState(stateName?: string): { name: string; role: st
return null;
}
}
// --- Screen RBAC (mirrors tbl_appconfig_screens.permissions for app 385) ---
// Roles that bypass screen gating entirely (platform admins see every screen).
export const SUPER_ROLES = ['Super Admin', 'Admin'];
// Route path → roles allowed to see the screen. A path absent here is open to
// all authenticated users (e.g. the contextual Lead 360 detail). `/schedule`
// is an Aria-only screen — managers oversee agent meetings, agents see theirs.
export const SCREEN_ACCESS: Record<string, string[]> = {
'/pipeline': ['Sales Agent', 'Underwriter', 'Branch Manager'],
'/qualify': ['Sales Agent', 'Branch Manager'],
'/engage': ['Sales Agent'],
'/documents': ['Underwriter'],
'/underwriting': ['Underwriter', 'Branch Manager'],
'/schedule': ['Sales Agent', 'Branch Manager'],
};
/** Whether `roles` may see the screen at `path`. Super-admins always can;
* unlisted paths are open. */
export function canSeeScreen(roles: string[] | undefined, path: string): boolean {
const allowed = SCREEN_ACCESS[path];
if (!allowed) return true;
const r = roles ?? [];
if (r.some((role) => SUPER_ROLES.includes(role))) return true;
return r.some((role) => allowed.includes(role));
}

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

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

View File

@ -4,13 +4,15 @@ 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';
import type { LeadRecord, RecordViewField } from './types';
interface LeadsContextValue {
/** Domain-mapped leads (pipeline list, roster, KPIs). */
leads: Lead[];
/** Raw recordview rows — action screens read region/income/etc. from these. */
records: LeadRecord[];
/** Recordview field config — drives the filter/search controls. */
fields: RecordViewField[];
loading: boolean;
error: string | null;
refetch: () => void;
@ -31,10 +33,11 @@ export function LeadsProvider({ children }: { children: ReactNode }) {
);
const records = useMemo(() => (data?.data ?? []) as LeadRecord[], [data]);
const fields = useMemo(() => data?.config?.fields ?? [], [data]);
const leads = useMemo(() => records.map(recordToLead), [records]);
const value = useMemo<LeadsContextValue>(
() => ({ leads, records, loading, error, refetch }),
[leads, records, loading, error, refetch],
() => ({ leads, records, fields, loading, error, refetch }),
[leads, records, fields, loading, error, refetch],
);
return <LeadsContext.Provider value={value}>{children}</LeadsContext.Provider>;

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

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

View File

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

View File

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

View File

@ -1,42 +1,23 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { CheckCircle2, UserCheck } from 'lucide-react';
import { AIDecisionCard, Avatar, Badge, Button, Card, formatINR, SelectField } 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 { fieldFromSchema } from '../api/schema';
import { ACTIVITY_META } from '../api/forms';
import { AIDecisionCard, Button, Card } from '../';
import { LookupField } from '../form';
import type { LookupValue } from '../form';
import { useQuery, useZino } from '../../api/provider';
import { decisionToCard } from '../../api/adapters';
import { fieldFromSchema } from '../../api/schema';
import { ACTIVITY_META } from '../../api/forms';
import type { ActivityBodyProps } from './types';
const DEF = ACTIVITY_META.assign;
export function AssignScreen() {
export function AssignBody({ instanceId, record, onSuccess }: ActivityBodyProps) {
const { client } = useZino();
const navigate = useNavigate();
const [params] = useSearchParams();
const instanceParam = params.get('instance');
const { records, refetch } = useLeads();
// Lookup field config (templates / display+storage cols) comes from the live
// form schema, not hardcoded.
const schemaQ = useQuery(() => client.formSchema(DEF.uid), []);
const schemaQ = useQuery(() => client.formSchema(DEF.uid, instanceId), [instanceId]);
const REGION = useMemo(() => fieldFromSchema(schemaQ.data, 'assigned_region_input'), [schemaQ.data]);
const OWNER = useMemo(() => fieldFromSchema(schemaQ.data, 'owner_agent_input'), [schemaQ.data]);
// 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<LookupValue | null>(null);
const [agent, setAgent] = useState<LookupValue | null>(null);
useEffect(() => {
@ -49,7 +30,6 @@ export function AssignScreen() {
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
async function submit() {
if (!record) return;
if (!region) {
setResult({ ok: false, msg: 'Assigned region is required.' });
return;
@ -57,12 +37,12 @@ export function AssignScreen() {
setSubmitting(true);
setResult(null);
try {
await client.performActivity(record.instance_id, DEF.uid, {
const res = await client.performActivity(instanceId, DEF.uid, {
assigned_region_input: region,
...(agent ? { owner_agent_input: agent } : {}),
});
setResult({ ok: true, msg: 'Lead assigned — advanced to Assigned.' });
refetch();
onSuccess?.(res);
} catch (e) {
setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') });
} finally {
@ -71,44 +51,8 @@ export function AssignScreen() {
}
return (
<div className="grid gap-[18px] max-w-[1140px] mx-auto items-start grid-cols-1 lg:grid-cols-[1fr_340px]">
<div className="grid gap-[18px] items-start grid-cols-1 lg:grid-cols-[1fr_340px]">
<div className="flex flex-col gap-[18px] min-w-0">
{/* 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>
)}
<SelectField
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={(v) => {
setSelectedId(v);
setResult(null);
}}
className="w-[260px]"
/>
</div>
{result && (
<div
className={
@ -119,14 +63,6 @@ export function AssignScreen() {
>
{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>
)}
@ -141,7 +77,7 @@ export function AssignScreen() {
storageCols={REGION.lookupStorage ?? []}
activityId={DEF.uid}
fieldId={REGION.id}
instanceId={record?.instance_id}
instanceId={instanceId}
value={region}
onChange={setRegion}
/>
@ -154,7 +90,7 @@ export function AssignScreen() {
storageCols={OWNER.lookupStorage ?? []}
activityId={DEF.uid}
fieldId={OWNER.id}
instanceId={record?.instance_id}
instanceId={instanceId}
value={agent}
onChange={setAgent}
/>
@ -162,14 +98,13 @@ export function AssignScreen() {
{schemaQ.loading && <div className="col-span-full text-sm text-faint">Loading form</div>}
</div>
<div className="flex gap-2.5 mt-5">
<Button variant="primary" disabled={!record || submitting} onClick={submit}>
<Button variant="primary" disabled={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>
@ -188,15 +123,15 @@ export function AssignScreen() {
</div>
</div>
<AssignAiSuggestion instanceId={record?.instance_id} />
<AssignAiSuggestion instanceId={instanceId} />
</div>
</div>
);
}
function AssignAiSuggestion({ instanceId }: { instanceId?: number }) {
function AssignAiSuggestion({ instanceId }: { instanceId: number | string }) {
const { client } = useZino();
const q = useQuery(() => client.aiDecisions(instanceId!), [instanceId], instanceId != null);
const q = useQuery(() => client.aiDecisions(instanceId as number), [instanceId]);
const latest = (q.data ?? [])[0];
if (!latest) return null;
const card = decisionToCard(latest);

View File

@ -1,14 +1,13 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { AlertTriangle, CheckCircle2, FileText, Info, ShieldCheck, UploadCloud } from 'lucide-react';
import { Avatar, Badge, Button, Card, DocumentUploadCard, formatINR, Input, SelectField } from '../components';
import type { BadgeTone, DocStatus, OcrField } from '../components';
import { useQuery, useZino } from '../api/provider';
import { useLeads } from '../api/leads';
import { fieldFromSchema } from '../api/schema';
import { ACTIVITIES } from '../api/config';
import { OCR_FIELDS } from '../api/forms';
import { crossCheck, type DocExtract, type DocKey } from '../lib/kyc-match';
import { Avatar, Badge, Button, Card, DocumentUploadCard, formatINR, Input, SelectField } from '../';
import type { BadgeTone, DocStatus, OcrField } from '../';
import { useQuery, useZino } from '../../api/provider';
import { fieldFromSchema } from '../../api/schema';
import { ACTIVITIES } from '../../api/config';
import { OCR_FIELDS } from '../../api/forms';
import { crossCheck, type DocExtract, type DocKey } from '../../lib/kyc-match';
import type { ActivityBodyProps } from './types';
const ACT = ACTIVITIES.COLLECT_DOCUMENTS;
const F = ACT.fields;
@ -22,15 +21,12 @@ const STATUS_TONE: Record<string, BadgeTone> = {
const DOC_LABEL: Record<DocKey, string> = { pan: 'PAN', aadhaar: 'Aadhaar' };
// One human-readable line per mismatched field for the banner.
function mismatchLine(f: { doc: DocKey; field: 'name' | 'dob'; captured: string; ocr: string }): string {
const what = f.field === 'name' ? 'name' : 'DOB';
const fmt = (v: string) => (f.field === 'name' ? `${v || '—'}` : v || '—');
const fmt = (v: string) => (f.field === 'name' ? `"${v || '—'}"` : v || '—');
return `${DOC_LABEL[f.doc]} ${what} ${fmt(f.ocr)} ≠ captured ${fmt(f.captured)}`;
}
// 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,
docKey,
@ -63,17 +59,13 @@ function OcrCapture({
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]);
}
}
// Surface the OCR'd identity (name + DOB) for the cross-check. These are
// extracted but never persisted, so this is the only point they exist.
const asStr = (v: unknown) => (v == null ? null : String(v));
onExtract(docKey, { name: asStr(extracted.full_name), dob: asStr(extracted.date_of_birth) });
// Show every extracted key in the card for confirmation.
setFields(
Object.entries(extracted)
.filter(([, v]) => v !== null && v !== undefined && v !== '')
@ -114,42 +106,18 @@ function OcrCapture({
);
}
export function DocumentsScreen() {
export function DocumentsBody({ instanceId, record, onSuccess }: ActivityBodyProps) {
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]);
// doc_status options from the live form schema, not hardcoded.
const schemaQ = useQuery(() => client.formSchema(ACT.uid), []);
const schemaQ = useQuery(() => client.formSchema(ACT.uid, instanceId), [instanceId]);
const docStatusOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.docStatus)?.options ?? [], [schemaQ.data]);
const [pan, setPan] = useState('');
const [aadhaar, setAadhaar] = useState('');
const [verifiedIncome, setVerifiedIncome] = useState(0);
const [docStatus, setDocStatus] = useState('verified');
// Picked-but-not-yet-uploaded files, keyed by field id.
const [files, setFiles] = useState<Record<string, File | null>>({});
// OCR'd identity per doc — held only in memory for the cross-check (never persisted).
const [ocrExtracts, setOcrExtracts] = useState<Partial<Record<DocKey, DocExtract>>>({});
// True once the agent edits the status by hand — stops the auto-mismatch nudge.
const statusTouched = useRef(false);
useEffect(() => {
@ -166,20 +134,15 @@ export function DocumentsScreen() {
const setFile = (fieldId: string) => (f: File | null) => setFiles((p) => ({ ...p, [fieldId]: f }));
const applyExtract = (doc: DocKey, ex: DocExtract) => setOcrExtracts((p) => ({ ...p, [doc]: ex }));
// Cross-check the OCR'd name + DOB against the captured lead identity.
const kyc = useMemo(
() => crossCheck({ name: record?.lead_name, dob: record?.date_of_birth }, ocrExtracts),
[record, ocrExtracts],
);
// Advisory nudge: first time the identity disagrees, flag the status as Mismatch.
// The agent can still override (statusTouched). No hard block — matches the goal of
// surfacing it rather than trusting the docs blindly.
useEffect(() => {
if (kyc.hasMismatch && !statusTouched.current) setDocStatus('mismatch');
}, [kyc.hasMismatch]);
// 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());
@ -190,20 +153,17 @@ export function DocumentsScreen() {
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, {
const res = await client.performActivity(instanceId, activityId, {
[F.panNumber]: pan,
[F.aadhaarNumber]: aadhaar,
[F.verifiedIncome]: verifiedIncome,
@ -211,7 +171,7 @@ export function DocumentsScreen() {
...fileData,
});
setResult({ ok: true, msg: 'Documents submitted — lead advanced to Documents Collected.' });
refetch();
onSuccess?.(res);
} catch (e) {
setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') });
} finally {
@ -228,7 +188,7 @@ export function DocumentsScreen() {
const completion = Math.round((checklist.filter((c) => c.done).length / checklist.length) * 100);
return (
<div className="grid gap-[18px] max-w-[1180px] mx-auto items-start grid-cols-1 lg:grid-cols-[1fr_320px]">
<div className="grid gap-[18px] items-start grid-cols-1 lg:grid-cols-[1fr_320px]">
<div className="flex flex-col gap-[18px] min-w-0">
<div className="flex items-center gap-3 bg-card border border-border-subtle rounded-lg px-[18px] py-3.5 shadow-sm">
{record ? (
@ -242,23 +202,8 @@ export function DocumentsScreen() {
</div>
</>
) : (
<div className="flex-1 text-sm text-faint">Select a lead at the Product Recommended stage.</div>
<div className="flex-1 text-sm text-faint">No lead record provided.</div>
)}
<SelectField
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={(v) => {
setSelectedId(v);
setResult(null);
}}
className="w-[260px]"
/>
</div>
{result && (
@ -271,18 +216,9 @@ export function DocumentsScreen() {
>
{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>
)}
{/* KYC identity cross-check — OCR'd name + DOB vs the captured lead */}
{kyc.hasMismatch ? (
<div className="flex items-start gap-2.5 text-sm text-amber-800 bg-escalated-soft border border-amber-300 rounded-md px-4 py-3">
<AlertTriangle size={16} className="text-amber-600 shrink-0 mt-0.5" />
@ -298,7 +234,7 @@ export function DocumentsScreen() {
))}
</ul>
<div className="mt-1.5 text-xs text-amber-700">
Document status set to Mismatch. Override only once youve confirmed the documents belong to this lead.
Document status set to "Mismatch". Override only once you've confirmed the documents belong to this lead.
</div>
</div>
</div>
@ -309,13 +245,12 @@ export function DocumentsScreen() {
</div>
) : null}
{/* OCR document capture */}
<div className="grid grid-cols-2 gap-[18px]">
<OcrCapture
ocr={OCR_FIELDS.pan}
docKey="pan"
activityId={ACT.uid}
instanceId={record?.instance_id}
instanceId={instanceId}
onAutofill={applyAutofill}
onFile={setFile(OCR_FIELDS.pan.id)}
onExtract={applyExtract}
@ -324,7 +259,7 @@ export function DocumentsScreen() {
ocr={OCR_FIELDS.aadhaar}
docKey="aadhaar"
activityId={ACT.uid}
instanceId={record?.instance_id}
instanceId={instanceId}
onAutofill={applyAutofill}
onFile={setFile(OCR_FIELDS.aadhaar.id)}
onExtract={applyExtract}
@ -354,7 +289,6 @@ export function DocumentsScreen() {
/>
</div>
{/* Salary slip file upload */}
<div className="mt-4">
<SalarySlipUpload value={files[F.salarySlip] ?? null} onChange={setFile(F.salarySlip)} />
</div>
@ -367,14 +301,13 @@ export function DocumentsScreen() {
</div>
</div>
<div className="flex gap-2.5 mt-5">
<Button variant="primary" disabled={!record || submitting} onClick={submit}>
<Button variant="primary" disabled={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>

View File

@ -1,11 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { CheckCircle2 } from 'lucide-react';
import { Avatar, Button, Card, formatINR, Input, SelectField } from '../components';
import { useLeads } from '../api/leads';
import { useZino } from '../api/provider';
import { productOf } from '../api/adapters';
import { ACTIVITIES } from '../api/config';
import { Button, Card, formatINR, Input } from '../';
import { useZino } from '../../api/provider';
import { ACTIVITIES } from '../../api/config';
import type { ActivityBodyProps } from './types';
const F = ACTIVITIES.ISSUE_POLICY.fields;
@ -13,7 +11,6 @@ function todayISO(): string {
return new Date().toISOString().slice(0, 10);
}
// issue date + term years -> ISO date (maturity). Empty if inputs invalid.
function addYears(iso: string, years: number): string {
if (!iso || !years) return '';
const d = new Date(iso);
@ -22,30 +19,8 @@ function addYears(iso: string, years: number): string {
return d.toISOString().slice(0, 10);
}
export function IssuePolicyScreen() {
export function IssuePolicyBody({ instanceId, record, onSuccess }: ActivityBodyProps) {
const { client } = useZino();
const navigate = useNavigate();
const [params] = useSearchParams();
const instanceParam = params.get('instance');
const { records, refetch } = useLeads();
// Issue Policy advances from Underwriting (after an approved verdict — Issuance Guard 947).
const eligible = useMemo(
() => records.filter((r) => r.current_state_name === 'Underwriting'),
[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],
);
const [issueDate, setIssueDate] = useState(todayISO());
const [term, setTerm] = useState(20);
@ -58,7 +33,6 @@ export function IssuePolicyScreen() {
setPremium(record.premium_amount || 0);
}, [record]);
// commencement = issue date; maturity = issue + term (computed client-side).
const commencement = issueDate;
const maturity = useMemo(() => addYears(issueDate, term), [issueDate, term]);
@ -66,19 +40,17 @@ export function IssuePolicyScreen() {
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
async function submit() {
if (!record) return;
setSubmitting(true);
setResult(null);
try {
// policy_number is backend-generated (id_gen POL-YYYY-####) — do NOT send it.
await client.performActivity(record.instance_id, ACTIVITIES.ISSUE_POLICY.uid, {
const res = await client.performActivity(instanceId, ACTIVITIES.ISSUE_POLICY.uid, {
[F.issueDate]: issueDate,
[F.term]: term,
[F.maturity]: maturity,
[F.premium]: premium,
});
setResult({ ok: true, msg: 'Policy issued — number generated automatically.' });
refetch();
onSuccess?.(res);
} catch (e) {
setResult({
ok: false,
@ -92,43 +64,8 @@ export function IssuePolicyScreen() {
const lead = record;
return (
<div className="grid gap-[18px] max-w-[1140px] mx-auto items-start grid-cols-1 lg:grid-cols-[1fr_360px]">
<div className="grid gap-[18px] items-start grid-cols-1 lg:grid-cols-[1fr_360px]">
<div className="flex flex-col gap-[18px] min-w-0">
{/* 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.current_state_name ?? '—'} · {productOf(lead.recommended_product) ?? '—'} · SA
{formatINR(lead.sum_assured ?? 0)}
</div>
</div>
</>
) : (
<div className="flex-1 text-sm text-faint">Select a lead at the Underwriting stage.</div>
)}
<SelectField
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={(v) => {
setSelectedId(v);
setResult(null);
}}
className="w-[260px]"
/>
</div>
{result && (
<div
className={
@ -139,14 +76,6 @@ export function IssuePolicyScreen() {
>
{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>
)}
@ -176,7 +105,7 @@ export function IssuePolicyScreen() {
Policy number is auto-generated on issue (POL-YYYY-####).
</div>
<div className="flex gap-2.5 mt-5">
<Button variant="primary" disabled={!record || submitting} onClick={submit}>
<Button variant="primary" disabled={submitting} onClick={submit}>
{submitting ? 'Issuing…' : 'Issue policy'}
</Button>
</div>

View File

@ -1,9 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { CheckCircle2 } from 'lucide-react';
import {
AIDecisionCard,
Avatar,
Button,
Card,
formatINR,
@ -11,19 +9,18 @@ import {
MultiSelect,
RupeeAmount,
SelectField,
} 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, regionOf } from '../api/adapters';
import { fieldFromSchema } from '../api/schema';
import { ACTIVITIES } from '../api/config';
import type { LeadRecord } from '../api/types';
} from '../';
import { LookupField } from '../form';
import type { LookupValue } from '../form';
import { useQuery, useZino } from '../../api/provider';
import { decisionToCard } from '../../api/adapters';
import { fieldFromSchema } from '../../api/schema';
import { ACTIVITIES } from '../../api/config';
import type { ActivityBodyProps } from './types';
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) {
@ -47,39 +44,14 @@ function toRiderValues(raw: LeadRecord['riders']): string[] {
.filter(Boolean);
}
export function RecommendScreen() {
export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyProps) {
const { client } = useZino();
const navigate = useNavigate();
const [params] = useSearchParams();
const instanceParam = params.get('instance');
const { records, refetch } = useLeads();
// Field config (product lookup template, frequency + rider options) from the
// live form schema, not hardcoded.
const schemaQ = useQuery(() => client.formSchema(ACTIVITIES.RECOMMEND_PRODUCT.uid), []);
const schemaQ = useQuery(() => client.formSchema(ACTIVITIES.RECOMMEND_PRODUCT.uid, instanceId), [instanceId]);
const productField = useMemo(() => fieldFromSchema(schemaQ.data, F.product), [schemaQ.data]);
const freqOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.premiumFrequency)?.options ?? [], [schemaQ.data]);
const riderOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.riders)?.options ?? [], [schemaQ.data]);
// 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<LookupValue | null>(null);
const [sum, setSum] = useState(2000000);
const [freq, setFreq] = useState('annual');
@ -108,8 +80,9 @@ export function RecommendScreen() {
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
const incomeMultiple = record?.annual_income ? (sum / record.annual_income).toFixed(1) : '—';
async function submit() {
if (!record) return;
if (!product) {
setResult({ ok: false, msg: 'Pick a product from the catalogue.' });
return;
@ -117,7 +90,7 @@ export function RecommendScreen() {
setSubmitting(true);
setResult(null);
try {
await client.performActivity(record.instance_id, ACTIVITIES.RECOMMEND_PRODUCT.uid, {
const res = await client.performActivity(instanceId, ACTIVITIES.RECOMMEND_PRODUCT.uid, {
[F.product]: product,
[F.sumAssured]: sum,
[F.premiumAmount]: effectivePremium,
@ -126,7 +99,7 @@ export function RecommendScreen() {
[F.notes]: notes,
});
setResult({ ok: true, msg: 'Recommendation submitted — lead advanced to Product Recommended.' });
refetch();
onSuccess?.(res);
} catch (e) {
setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') });
} finally {
@ -134,47 +107,9 @@ export function RecommendScreen() {
}
}
const lead = record;
const incomeMultiple = lead?.annual_income ? (sum / lead.annual_income).toFixed(1) : '—';
return (
<div className="grid gap-[18px] max-w-[1140px] mx-auto items-start grid-cols-1 lg:grid-cols-[1fr_360px]">
<div className="grid gap-[18px] items-start grid-cols-1 lg:grid-cols-[1fr_360px]">
<div className="flex flex-col gap-[18px] min-w-0">
{/* 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">
{regionOf(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>
)}
<SelectField
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={(v) => {
setSelectedId(v);
setResult(null);
}}
className="w-[260px]"
/>
</div>
{result && (
<div
className={
@ -185,14 +120,6 @@ export function RecommendScreen() {
>
{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>
)}
@ -207,7 +134,7 @@ export function RecommendScreen() {
storageCols={productField.lookupStorage ?? []}
activityId={ACTIVITIES.RECOMMEND_PRODUCT.uid}
fieldId={F.product}
instanceId={record?.instance_id}
instanceId={instanceId}
value={product}
onChange={setProduct}
/>
@ -258,14 +185,13 @@ export function RecommendScreen() {
</div>
</div>
<div className="flex gap-2.5 mt-5">
<Button variant="primary" disabled={!record || submitting} onClick={submit}>
<Button variant="primary" disabled={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">
@ -286,16 +212,15 @@ export function RecommendScreen() {
</div>
</div>
<RecommendAiSuggestion instanceId={record?.instance_id} />
<RecommendAiSuggestion instanceId={instanceId} />
</div>
</div>
);
}
// Pull the latest AI decision for this lead, if any, as the suggestion card.
function RecommendAiSuggestion({ instanceId }: { instanceId?: number }) {
function RecommendAiSuggestion({ instanceId }: { instanceId: number | string }) {
const { client } = useZino();
const q = useQuery(() => client.aiDecisions(instanceId!), [instanceId], instanceId != null);
const q = useQuery(() => client.aiDecisions(Number(instanceId)), [instanceId]);
const latest = (q.data ?? [])[0];
if (!latest) return null;
const card = decisionToCard(latest);

View File

@ -1,66 +1,39 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { CheckCircle2 } from 'lucide-react';
import { Avatar, Button, Card, formatINR, SelectField } from '../components';
import { useLeads } from '../api/leads';
import { useQuery, useZino } from '../api/provider';
import { fieldFromSchema } from '../api/schema';
import { ACTIVITIES } from '../api/config';
import { assess } from '../lib/underwriting';
import { Button, Card, formatINR, SelectField } from '../';
import { useQuery, useZino } from '../../api/provider';
import { fieldFromSchema } from '../../api/schema';
import { ACTIVITIES } from '../../api/config';
import { assess } from '../../lib/underwriting';
import type { ActivityBodyProps } from './types';
const F = ACTIVITIES.SUBMIT_UNDERWRITING.fields;
export function UnderwritingScreen() {
export function UnderwritingBody({ instanceId, record, onSuccess }: ActivityBodyProps) {
const { client } = useZino();
const navigate = useNavigate();
const [params] = useSearchParams();
const instanceParam = params.get('instance');
const { records, refetch } = useLeads();
// Status options from the live form schema, not hardcoded.
const schemaQ = useQuery(() => client.formSchema(ACTIVITIES.SUBMIT_UNDERWRITING.uid), []);
const schemaQ = useQuery(() => client.formSchema(ACTIVITIES.SUBMIT_UNDERWRITING.uid, instanceId), []);
const statusOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.status)?.options ?? [], [schemaQ.data]);
// Leads where Submit Underwriting is valid (state = Eligibility Assessed).
const eligible = useMemo(
() => records.filter((r) => r.current_state_name === 'Eligibility Assessed'),
[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],
);
const [status, setStatus] = useState('approved');
useEffect(() => {
if (record?.underwriting_status) setStatus(String(record.underwriting_status));
}, [record]);
// Decision support — computed client-side, recomputes whenever the lead changes.
const a = useMemo(() => (record ? assess(record) : null), [record]);
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 {
// underwriting_ref is backend-generated (id_gen UW-YYYY-####) — send only the verdict.
await client.performActivity(record.instance_id, ACTIVITIES.SUBMIT_UNDERWRITING.uid, {
const res = await client.performActivity(instanceId, ACTIVITIES.SUBMIT_UNDERWRITING.uid, {
[F.status]: status,
});
setResult({ ok: true, msg: 'Underwriting verdict submitted.' });
refetch();
onSuccess?.(res);
} catch (e) {
setResult({
ok: false,
@ -74,43 +47,8 @@ export function UnderwritingScreen() {
const lead = record;
return (
<div className="grid gap-[18px] max-w-[1140px] mx-auto items-start grid-cols-1 lg:grid-cols-[1fr_360px]">
<div className="grid gap-[18px] items-start grid-cols-1 lg:grid-cols-[1fr_360px]">
<div className="flex flex-col gap-[18px] min-w-0">
{/* 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.current_state_name ?? '—'} · eligibility {lead.eligibility_status ?? '—'} · SA
{formatINR(lead.sum_assured ?? 0)}
</div>
</div>
</>
) : (
<div className="flex-1 text-sm text-faint">Select a lead at the Eligibility Assessed stage.</div>
)}
<SelectField
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={(v) => {
setSelectedId(v);
setResult(null);
}}
className="w-[260px]"
/>
</div>
{result && (
<div
className={
@ -121,14 +59,6 @@ export function UnderwritingScreen() {
>
{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>
)}
@ -150,14 +80,13 @@ export function UnderwritingScreen() {
Underwriting reference is auto-generated on submit (UW-YYYY-####).
</div>
<div className="flex gap-2.5 mt-5">
<Button variant="primary" disabled={!record || submitting} onClick={submit}>
<Button variant="primary" disabled={submitting} onClick={submit}>
{submitting ? 'Submitting…' : 'Record verdict'}
</Button>
</div>
</Card>
</div>
{/* RIGHT — auto-computed assessment, visible on open */}
<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">
@ -212,7 +141,7 @@ export function UnderwritingScreen() {
</>
) : (
<div className="text-sm text-on-navy-muted">
{record ? 'Not enough data (DOB / income / sum assured) to assess.' : 'Select a lead at Eligibility Assessed.'}
{record ? 'Not enough data (DOB / income / sum assured) to assess.' : 'Not enough data to assess.'}
</div>
)}
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -139,7 +139,7 @@ export function PickerShell<T>({
createPortal(
<div
ref={panelRef}
style={{ position: 'fixed', top: pos.top, left: pos.left, width: pos.width, zIndex: 50 }}
style={{ position: 'fixed', top: pos.top, left: pos.left, width: pos.width, zIndex: 10050 }}
className="bg-card border border-border-default rounded-md shadow-lg overflow-hidden"
>
{searchable && (

View File

@ -16,3 +16,7 @@ export { PickerShell } from './PickerShell';
export type { PickerShellProps } from './PickerShell';
export { MultiSelect } from './MultiSelect';
export type { MultiSelectProps, MultiSelectOption } from './MultiSelect';
export { Modal } from './Modal';
export type { ModalProps, ModalWidth } from './Modal';
export { Pagination } from './Pagination';
export type { PaginationProps } from './Pagination';

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { CheckCircle2 } from 'lucide-react';
import { CheckCircle2, Lock } from 'lucide-react';
import { Button } from '../core/Button';
import { Input } from '../core/Input';
import { SelectField } from '../core/SelectField';
@ -50,6 +50,12 @@ function seedFor(field: FieldDef, record?: LeadRecord | null): unknown {
}
}
/** True for a backend RBAC denial so we can show a human line, not the raw
* "user N does not have permission for activity <uuid>". */
export function isPermissionError(msg?: string | null): boolean {
return !!msg && /permission|not authoriz|unauthoriz|forbidden|\b403\b/i.test(msg);
}
/** Renders an activity's fields from the live form-screens schema and submits
* via performActivity (or startInstance when no instanceId is given). */
export function ActivityForm({ meta, instanceId, record, onSuccess, successMessage }: ActivityFormProps) {
@ -114,7 +120,11 @@ export function ActivityForm({ meta, instanceId, record, onSuccess, successMessa
setResult({ ok: true, msg: successMessage ?? `${meta.name} ${meta.done}.` });
onSuccess?.(res);
} catch (e) {
setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') });
const raw = e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed');
setResult({
ok: false,
msg: isPermissionError(raw) ? `You dont have permission to ${meta.name.toLowerCase()}.` : raw,
});
} finally {
setSubmitting(false);
}
@ -124,6 +134,19 @@ export function ActivityForm({ meta, instanceId, record, onSuccess, successMessa
return <div className="py-6 text-sm text-faint">Loading form</div>;
}
if (schemaQ.error || !fields.length) {
if (isPermissionError(schemaQ.error)) {
return (
<div className="flex items-start gap-2.5 rounded-md border border-amber-200 bg-amber-50 px-4 py-3.5 text-sm text-amber-800">
<Lock size={16} className="mt-0.5 shrink-0 text-amber-600" />
<div>
<div className="font-semibold">You dont have permission for this action</div>
<div className="mt-0.5 text-xs text-amber-700">
Your role cant {meta.name.toLowerCase()}. Ask an admin to grant your role access to this activity.
</div>
</div>
</div>
);
}
return (
<div className="py-4 text-sm text-amber-700">
{schemaQ.error ? `Couldn't load the form: ${schemaQ.error}` : 'This activity has no input fields.'}

View File

@ -1,5 +1,5 @@
import { useState } from 'react';
import { ChevronDown, FileText, TriangleAlert } from 'lucide-react';
import { ChevronDown, FileText, RefreshCw, TriangleAlert } from 'lucide-react';
import { cn } from '../../lib/cn';
import { Avatar } from '../core/Avatar';
import { ReasoningBody } from './ReasoningBody';
@ -8,6 +8,8 @@ import type { Decision } from '../../types';
export interface AIDecisionStreamProps {
decisions: Decision[];
loading?: boolean;
/** Manual refresh handler. Shown as a refresh button in the header. */
onRefresh?: () => void;
/** Autonomy threshold. @default 0.7 */
threshold?: number;
className?: string;
@ -22,6 +24,7 @@ export interface AIDecisionStreamProps {
export function AIDecisionStream({
decisions,
loading = false,
onRefresh,
threshold = 0.7,
className,
}: AIDecisionStreamProps) {
@ -33,9 +36,23 @@ export function AIDecisionStream({
<div className={cn('flex flex-col gap-3.5', className)}>
<div className="flex items-center justify-between">
<h3 className="m-0 text-sm font-bold text-strong">AI decision stream</h3>
<span className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy bg-navy-900 px-2 py-1 rounded-pill">
{decisions.length} action{decisions.length === 1 ? '' : 's'}
</span>
<div className="flex items-center gap-2">
<span className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy bg-navy-900 px-2 py-1 rounded-pill">
{decisions.length} action{decisions.length === 1 ? '' : 's'}
</span>
{onRefresh && (
<button
type="button"
onClick={onRefresh}
disabled={loading}
title="Refresh AI decisions"
aria-label="Refresh AI decisions"
className="w-7 h-7 rounded-md border border-border-subtle bg-card cursor-pointer text-muted hover:text-strong disabled:opacity-50 flex items-center justify-center shrink-0"
>
<RefreshCw size={14} className={cn(loading && 'animate-spin')} />
</button>
)}
</div>
</div>
{escalations.length > 0 && (
@ -52,7 +69,11 @@ export function AIDecisionStream({
</div>
)}
{loading && <div className="text-sm text-faint">Loading decisions</div>}
{/* Placeholder only on first load; a manual refresh keeps the list visible
(the header button spins) so the stream never blanks out. */}
{loading && decisions.length === 0 && (
<div className="text-sm text-faint">Loading decisions</div>
)}
{!loading && decisions.length === 0 && (
<div className="text-sm text-faint">No AI decisions for this lead yet.</div>
)}

View File

@ -1,3 +1,4 @@
import type { ReactNode } from 'react';
import { cn } from '../../lib/cn';
import type { Lead, Tone } from '../../types';
import { Avatar } from '../core/Avatar';
@ -8,6 +9,9 @@ export interface LeadRowProps {
lead: Lead;
onClick?: () => void;
className?: string;
/** When set, replaces the "last action" cell (e.g. a worklist action button).
* Clicks inside it don't trigger the row's onClick. */
action?: ReactNode;
}
/** Shared grid template for the leads table — keep header + rows in sync. */
@ -27,8 +31,9 @@ function scoreColor(score: number): string {
return 'var(--slate-500)';
}
/** Leads-list table row — name, score, segment, channel, owner, last AI action. */
export function LeadRow({ lead, onClick, className }: LeadRowProps) {
/** Leads-list table row name, score, segment, channel, owner, last AI action
* (or a custom `action` cell). */
export function LeadRow({ lead, onClick, className, action }: LeadRowProps) {
return (
<div
onClick={onClick}
@ -64,11 +69,17 @@ export function LeadRow({ lead, onClick, className }: LeadRowProps) {
<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>
{/* last AI action — or a custom action cell */}
{action !== undefined ? (
<div className="flex justify-end" onClick={(e) => e.stopPropagation()}>
{action}
</div>
) : (
<div className="flex items-center gap-[7px] min-w-0">
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0', DOT[lead.lastTone])} />
<span className="text-sm text-muted truncate">{lead.lastAction}</span>
</div>
)}
</div>
);
}

View File

@ -19,11 +19,14 @@ export function WorkflowStepper({
className,
}: WorkflowStepperProps) {
const vertical = orientation === 'vertical';
// Every dot sits in a fixed 28px (h-7) band so all centers land on the same
// line regardless of dot size; connectors align to that center (14px), not to
// the dot+label column. Keeps the rail straight even with the larger active dot.
return (
<div
className={cn(
'flex font-sans',
vertical ? 'flex-col items-stretch' : 'flex-row items-center',
vertical ? 'flex-col items-stretch' : 'flex-row items-start',
className,
)}
>
@ -33,16 +36,18 @@ export function WorkflowStepper({
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}
<div className={cn('flex shrink-0', vertical ? 'flex-row items-center gap-3' : 'flex-col items-center gap-2')}>
<span className={cn('flex items-center justify-center', vertical ? '' : 'h-7')}>
<span
className={cn(
'rounded-full shrink-0 flex items-center justify-center text-xs font-bold transition-all duration-200',
active && 'w-7 h-7 bg-sunrise text-white shadow-sunrise',
done && 'w-[22px] h-[22px] bg-emerald-600 text-white',
!done && !active && 'w-[22px] h-[22px] bg-card text-slate-500 border-2 border-slate-300',
)}
>
{done ? <Check size={13} strokeWidth={3} /> : i + 1}
</span>
</span>
<span
className={cn(
@ -58,7 +63,7 @@ export function WorkflowStepper({
<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]',
vertical ? 'w-0.5 h-[18px] ml-[10px]' : 'flex-1 h-0.5 min-w-4 mt-[13px]',
)}
/>
)}

View File

@ -1,10 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import type { LucideIcon } from 'lucide-react';
import { Bell, FileText, FileSignature, LogOut, Menu, Search, ShieldCheck, Sparkles, UserCheck, Users, X } from 'lucide-react';
import { Bell, CalendarClock, FileText, LogOut, Menu, Search, ShieldCheck, Sparkles, UserCheck, Users, X } from 'lucide-react';
import { cn } from '../lib/cn';
import { Avatar } from '../components/core';
import { AI_EMPLOYEES } from '../api/config';
import { canSeeScreen } from '../api/config';
import { useZino } from '../api/provider';
import { useLeads } from '../api/leads';
import type { Lead } from '../types';
@ -19,29 +19,13 @@ interface NavItem {
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' },
{ to: '/underwriting', label: 'Underwriting', Icon: ShieldCheck, title: 'Underwriting', subtitle: 'Risk assessment & verdict' },
{ to: '/issue', label: 'Issue Policy', Icon: FileSignature, title: 'Issue policy', subtitle: 'Generate policy number & dates' },
{ to: '/schedule', label: 'Agent Schedule', Icon: CalendarClock, title: 'Agent schedule', subtitle: 'Sales-agent meetings & booking slots' },
{ to: '/qualify', label: 'Qualify & Assign', Icon: UserCheck, title: 'Qualification & Assignment', subtitle: 'Qualify new leads and assign them to an agent' },
{ to: '/engage', label: 'Customer Interaction', Icon: Sparkles, title: 'Customer Interaction', subtitle: 'Log contact, schedule meetings, recommend products' },
{ to: '/documents', label: 'Documents', Icon: FileText, title: 'Document & Assessment', subtitle: 'KYC collection, OCR, eligibility assessment' },
{ to: '/underwriting', label: 'Underwriting & Policy', Icon: ShieldCheck, title: 'Underwriting & Policy Issuance', subtitle: 'Underwrite, issue policy, onboard customer' },
];
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';
@ -63,7 +47,7 @@ function AriaLogo() {
}
/** Sidebar contents — shared between the static desktop rail and the mobile drawer. */
function SidebarInner({ activeTo, onNav, roster }: { activeTo: string; onNav: (to: string) => void; roster: RosterEntry[] }) {
function SidebarInner({ items, activeTo, onNav }: { items: NavItem[]; activeTo: string; onNav: (to: string) => void }) {
return (
<>
<div className="px-2 py-1">
@ -71,7 +55,7 @@ function SidebarInner({ activeTo, onNav, roster }: { activeTo: string; onNav: (t
</div>
<nav className="flex flex-col gap-1">
{NAV.map((n) => {
{items.map((n) => {
const on = activeTo === n.to;
return (
<button
@ -90,25 +74,6 @@ function SidebarInner({ activeTo, onNav, roster }: { activeTo: string; onNav: (t
);
})}
</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>
</>
);
}
@ -117,7 +82,7 @@ function Topbar({ title, subtitle, onMenu }: { 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-4 lg:px-7 gap-3 lg:gap-5 font-sans">
<header className="sticky top-0 z-30 h-16 shrink-0 bg-card border-b border-border-subtle flex items-center px-4 lg:px-7 gap-3 lg:gap-5 font-sans">
<button
onClick={onMenu}
aria-label="Open menu"
@ -161,12 +126,15 @@ function Topbar({ title, subtitle, onMenu }: { title: string; subtitle: string;
);
}
/** App shell — navy sidebar (nav + AI-employee roster) + topbar, with routed content. */
/** App shell — navy sidebar (nav) + topbar, with routed content. */
export function Shell() {
const location = useLocation();
const navigate = useNavigate();
const { leads } = useLeads();
const { user } = useZino();
const path = location.pathname;
// Nav is RBAC-filtered: a user only sees screens their roles allow.
const visibleNav = useMemo(() => NAV.filter((n) => canSeeScreen(user?.roles, n.to)), [user]);
const navMatch = NAV.find((n) => path.startsWith(n.to));
// Routes reached contextually (no sidebar entry) still get a topbar title.
const active =
@ -177,7 +145,6 @@ export function Shell() {
? { 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;
const [navOpen, setNavOpen] = useState(false);
@ -185,10 +152,10 @@ export function Shell() {
useEffect(() => setNavOpen(false), [path]);
return (
<div className="flex h-screen overflow-hidden bg-app font-sans">
{/* Static rail — lg and up. */}
<aside className="hidden lg:flex w-[248px] shrink-0 bg-navy-grad flex-col p-4 gap-6 font-sans">
<SidebarInner activeTo={navMatch?.to ?? ''} onNav={(to) => navigate(to)} roster={roster} />
<div className="flex min-h-screen bg-app font-sans">
{/* Static rail — lg and up. Pinned; the page scrolls normally beside it. */}
<aside className="hidden lg:flex w-[248px] shrink-0 bg-navy-grad flex-col p-4 gap-6 font-sans sticky top-0 h-screen self-start overflow-y-auto">
<SidebarInner items={visibleNav} activeTo={navMatch?.to ?? ''} onNav={(to) => navigate(to)} />
</aside>
{/* Drawer — below lg. */}
@ -203,14 +170,14 @@ export function Shell() {
>
<X size={20} />
</button>
<SidebarInner activeTo={navMatch?.to ?? ''} onNav={(to) => navigate(to)} roster={roster} />
<SidebarInner items={visibleNav} activeTo={navMatch?.to ?? ''} onNav={(to) => navigate(to)} />
</aside>
</div>
)}
<div className="flex-1 flex flex-col min-w-0">
<Topbar title={active.title} subtitle={subtitle} onMenu={() => setNavOpen(true)} />
<main className="flex-1 overflow-auto p-4 lg:p-7">
<main className="flex-1 p-4 lg:p-7">
<Outlet />
</main>
</div>

View File

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

View File

@ -9,6 +9,7 @@ import {
Button,
Card,
ChannelBadge,
formatINR,
RupeeAmount,
SegmentBadge,
TimelineEntry,
@ -71,6 +72,16 @@ function NextActionPanel({ record, onAdvanced }: { record: LeadRecord; onAdvance
);
}
// Compact Indian currency for the small stat tiles, so large cover figures
// (₹2,00,00,000) never overflow + get clipped by the card. Crore/lakh short
// form above ₹1L; full grouping below.
function compactINR(n: number): string {
if (!n) return '₹0';
if (n >= 1e7) return `${+(n / 1e7).toFixed(2)} Cr`;
if (n >= 1e5) return `${+(n / 1e5).toFixed(2)} L`;
return `${formatINR(n)}`;
}
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">
@ -95,12 +106,9 @@ export function Lead360Screen() {
);
const instanceId = record?.instance_id;
const decisionsQ = useQuery(
() => client.aiDecisions(instanceId!),
[instanceId],
instanceId != null,
12_000, // poll: async AI decisions land without a manual reload
);
// No polling — async AI decisions are pulled on demand via the stream's
// refresh button (and after any activity advance), so the view never flickers.
const decisionsQ = useQuery(() => client.aiDecisions(instanceId!), [instanceId], instanceId != null);
const auditQ = useQuery(() => client.audit(instanceId!), [instanceId], instanceId != null);
const onAdvanced = () => {
@ -108,6 +116,11 @@ export function Lead360Screen() {
decisionsQ.refetch();
auditQ.refetch();
};
// Manual refresh of the live AI feed + audit (no reload of the whole lead set).
const refreshFeed = () => {
decisionsQ.refetch();
auditQ.refetch();
};
if (leadsLoading) {
return <div className="p-10 text-center text-sm text-faint">Loading lead</div>;
@ -145,87 +158,73 @@ export function Lead360Screen() {
? meeting.toLocaleString('en-IN', { weekday: 'short', day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit', hour12: true })
: null;
const issued = lead.stage >= STAGES.indexOf('Policy Issued');
return (
<div className="grid gap-[18px] max-w-[1320px] mx-auto items-start grid-cols-1 lg:grid-cols-[280px_1fr] xl:grid-cols-[300px_1fr_380px]">
{/* 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 className="mx-auto flex w-full max-w-[1320px] flex-col gap-[18px]">
{/* HERO identity + lifecycle + headline figures unified into one band so
the page reads as a single record, not a scatter of tiles. */}
<Card bodyClassName="p-0">
<div className="flex flex-col gap-5 p-5">
<div className="flex flex-wrap items-center gap-4">
<Avatar name={lead.name} size={56} className="shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-xl font-bold text-strong truncate">{lead.name}</span>
<SegmentBadge segment={lead.segment} />
<ChannelBadge channel={lead.channel} />
</div>
</div>
<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. min-w-0 lets the 1fr track shrink
instead of forcing the grid wider than its container. */}
<div className="flex flex-col gap-[18px] min-w-0">
<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}
<div className="text-xs text-faint mt-1">
#{lead.id} · {lead.city} · owned by {lead.owner}
{meetingLabel ? ` · meeting ${meetingLabel}` : ''}
</div>
</div>
<Badge tone={issued ? 'success' : 'warning'} dot>
{record.current_state_name ?? '—'}
</Badge>
</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 && (
{/* lifecycle stepper */}
<div className="overflow-x-auto pb-1">
<WorkflowStepper stages={STAGES} current={Math.max(0, lead.stage)} />
</div>
{/* headline figures */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div className="min-w-0 bg-slate-50 rounded-md px-3.5 py-3">
<div className="text-2xs text-faint mb-1.5">Sum assured</div>
<div className="font-numeric text-2xl font-extrabold leading-none text-strong nums truncate">{compactINR(cover)}</div>
</div>
<div className="min-w-0 bg-slate-50 rounded-md px-3.5 py-3">
<div className="text-2xs text-faint mb-1.5">Premium</div>
<div className="font-numeric text-2xl font-extrabold leading-none text-sunrise-600 nums truncate">{compactINR(premium)}</div>
</div>
<div className="min-w-0 bg-slate-50 rounded-md px-3.5 py-3 col-span-2 sm:col-span-1">
<div className="text-2xs text-faint mb-1.5">Annual income</div>
<div className="font-numeric text-2xl font-extrabold leading-none text-strong nums truncate">{compactINR(lead.income)}</div>
</div>
</div>
</div>
</Card>
{/* BODY — primary work on the left, live AI feed + reference on the right. */}
<div className="grid items-start gap-[18px] grid-cols-1 lg:grid-cols-[1fr_360px]">
{/* MAIN */}
<div className="flex flex-col gap-[18px] min-w-0">
<Card title="Next action">
<div className="flex items-center gap-3.5 mb-4">
<Avatar name={lead.owner} ai={lead.ownerAi} size={40} />
<div className="flex-1 min-w-0">
<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}</div>
</div>
</div>
<div className="pt-4 border-t border-border-subtle">
<NextActionPanel record={record} onAdvanced={onAdvanced} />
</div>
</Card>
{onboarded && (
<Card
title="Policy & onboarding"
action={
@ -280,32 +279,51 @@ export function Lead360Screen() {
</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>
{/* Activity log + profile reference sit side by side profile is
context, not a footer, so it reads next to the audit trail. */}
<div className="grid items-start gap-[18px] grid-cols-1 md:grid-cols-[1fr_300px]">
<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>
{/* RIGHT AI decision stream + escalations.
At lg it drops to a full-width row beneath profile + center; at xl it's the 3rd column. */}
<AIDecisionStream
decisions={decisions}
loading={decisionsQ.loading}
className="min-w-0 lg:col-span-2 xl:col-span-1"
/>
<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>
</div>
{/* RAIL — live AI decision feed (manual refresh, no polling). */}
<div className="flex flex-col gap-[18px] min-w-0">
<AIDecisionStream
decisions={decisions}
loading={decisionsQ.loading}
onRefresh={refreshFeed}
/>
</div>
</div>
</div>
);
}

View File

@ -1,27 +0,0 @@
import { useNavigate } from 'react-router-dom';
import { Card } from '../components';
import { ActivityForm } from '../components/form';
import { useLeads } from '../api/leads';
import { ACTIVITY_META } 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
meta={ACTIVITY_META.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

@ -1,21 +1,27 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Columns3, Plus, Table } from 'lucide-react';
import { Columns3, Table } from 'lucide-react';
import { cn } from '../lib/cn';
import {
Avatar,
Badge,
Button,
Card,
ChannelBadge,
KpiCard,
LeadRow,
LEAD_GRID,
Pagination,
SegmentBadge,
} from '../components';
import type { Kpi, Lead, SegmentDatum } from '../types';
import { useLeads } from '../api/leads';
import { STAGES } from '../api/config';
import { AddLeadButton } from '../components/activities';
import { LeadFilters } from '../components/LeadFilters';
import { recordToLead } from '../api/adapters';
import { applyFilters, EMPTY_FILTER, type FilterState } from '../api/filters';
const PAGE_SIZE = 10;
function scoreColor(score: number): string {
if (score >= 75) return 'var(--sunrise-600)';
@ -212,10 +218,25 @@ export function PipelineScreen() {
const navigate = useNavigate();
const openLead = (l: Lead) => navigate(`/leads/${l.id}`);
const { leads, loading, error } = useLeads();
const { leads, records, fields, loading, error, refetch } = useLeads();
const [filters, setFilters] = useState<FilterState>(EMPTY_FILTER);
// KPIs / funnel / segments summarize the whole pipeline; the list below is
// what the filter narrows.
const kpis = useMemo(() => buildKpis(leads), [leads]);
const funnel = useMemo(() => buildFunnel(leads), [leads]);
const segments = useMemo(() => buildSegments(leads), [leads]);
const visibleLeads = useMemo(
() => applyFilters(records, filters, fields).map(recordToLead),
[records, filters, fields],
);
// Table-view pagination (the kanban board shows every stage at once). Page is
// clamped in <Pagination> and reset when the filter set changes.
const [page, setPage] = useState(1);
useEffect(() => setPage(1), [filters]);
const start = (Math.min(page, Math.max(1, Math.ceil(visibleLeads.length / PAGE_SIZE))) - 1) * PAGE_SIZE;
const pageLeads = visibleLeads.slice(start, start + PAGE_SIZE);
return (
<div className="flex flex-col gap-5 max-w-[1240px] mx-auto">
@ -232,12 +253,13 @@ export function PipelineScreen() {
))}
</div>
{/* charts */}
{/* charts cards stay equal height (aligned bottoms); the shorter
segments content is vertically centered so there's no dead space. */}
<div className="grid grid-cols-1 lg:grid-cols-[1.7fr_1fr] gap-4">
<Card title="Pipeline funnel" action={<Badge tone="neutral">Live</Badge>}>
<FunnelChart data={funnel} />
</Card>
<Card title="Lead segments">
<Card title="Lead segments" className="flex flex-col" bodyClassName="flex-1 flex items-center">
<SegmentDonut segments={segments} />
</Card>
</div>
@ -249,18 +271,11 @@ export function PipelineScreen() {
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>
<AddLeadButton onDone={refetch} />
</div>
}
>
<LeadFilters records={records} fields={fields} value={filters} onChange={setFilters} />
{loading ? (
<div className="p-10 text-center text-sm text-faint">Loading leads</div>
) : view === 'table' ? (
@ -279,18 +294,21 @@ export function PipelineScreen() {
<span>Owner</span>
<span>Last action</span>
</div>
{leads.length ? (
leads.map((l) => <LeadRow key={l.id} lead={l} onClick={() => openLead(l)} />)
{pageLeads.length ? (
pageLeads.map((l) => <LeadRow key={l.id} lead={l} onClick={() => openLead(l)} />)
) : (
<div className="p-10 text-center text-sm text-faint">No leads yet.</div>
<div className="p-10 text-center text-sm text-faint">No leads match these filters.</div>
)}
</div>
</div>
) : (
<div className="p-5">
<KanbanBoard leads={leads} onOpenLead={openLead} />
<KanbanBoard leads={visibleLeads} onOpenLead={openLead} />
</div>
)}
{view === 'table' && !loading && (
<Pagination page={page} pageSize={PAGE_SIZE} total={visibleLeads.length} onPage={setPage} />
)}
</Card>
</div>
);

View File

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

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

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

View File

@ -123,10 +123,12 @@
}
@layer base {
html,
html {
height: 100%;
}
body,
#root {
height: 100%;
min-height: 100%;
}
body {
margin: 0;