From 1a622b1267bbecc6c760345644999adf256bb7cc Mon Sep 17 00:00:00 2001 From: Bhanu Prakash Sai Potteri Date: Wed, 24 Jun 2026 17:04:26 +0530 Subject: [PATCH] feat: worklist screens + modal-driven activity forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/App.tsx | 33 +- src/api/config.ts | 26 ++ src/api/filters.ts | 118 +++++++ src/api/leads.tsx | 9 +- src/api/meetings.ts | 79 +++++ src/components/LeadFilters.tsx | 209 +++++++++++ src/components/activities/ActivityActions.tsx | 102 ++++++ .../activities/AssignBody.tsx} | 103 +----- .../activities/DocumentsBody.tsx} | 105 +----- .../activities/IssuePolicyBody.tsx} | 89 +---- .../activities/RecommendBody.tsx} | 117 ++----- .../activities/UnderwritingBody.tsx} | 97 +----- src/components/activities/Worklist.tsx | 123 +++++++ src/components/activities/index.ts | 6 + src/components/activities/registry.ts | 55 +++ src/components/activities/types.ts | 14 + src/components/core/Modal.tsx | 77 +++++ src/components/core/Pagination.tsx | 41 +++ src/components/core/PickerShell.tsx | 2 +- src/components/core/index.ts | 4 + src/components/form/ActivityForm.tsx | 27 +- src/components/insurance/AIDecisionStream.tsx | 31 +- src/components/insurance/LeadRow.tsx | 25 +- src/components/insurance/WorkflowStepper.tsx | 29 +- src/layout/Shell.tsx | 73 ++-- src/pages/Login.tsx | 122 ++++++- src/screens/Lead360Screen.tsx | 228 ++++++------ src/screens/NewLeadScreen.tsx | 27 -- src/screens/PipelineScreen.tsx | 56 ++- src/screens/ScheduleScreen.tsx | 325 ++++++++++++++++++ src/screens/worklists.tsx | 84 +++++ src/styles/styles.css | 6 +- 32 files changed, 1752 insertions(+), 690 deletions(-) create mode 100644 src/api/filters.ts create mode 100644 src/api/meetings.ts create mode 100644 src/components/LeadFilters.tsx create mode 100644 src/components/activities/ActivityActions.tsx rename src/{screens/AssignScreen.tsx => components/activities/AssignBody.tsx} (52%) rename src/{screens/DocumentsScreen.tsx => components/activities/DocumentsBody.tsx} (76%) rename src/{screens/IssuePolicyScreen.tsx => components/activities/IssuePolicyBody.tsx} (59%) rename src/{screens/RecommendScreen.tsx => components/activities/RecommendBody.tsx} (64%) rename src/{screens/UnderwritingScreen.tsx => components/activities/UnderwritingBody.tsx} (59%) create mode 100644 src/components/activities/Worklist.tsx create mode 100644 src/components/activities/index.ts create mode 100644 src/components/activities/registry.ts create mode 100644 src/components/activities/types.ts create mode 100644 src/components/core/Modal.tsx create mode 100644 src/components/core/Pagination.tsx delete mode 100644 src/screens/NewLeadScreen.tsx create mode 100644 src/screens/ScheduleScreen.tsx create mode 100644 src/screens/worklists.tsx diff --git a/src/App.tsx b/src/App.tsx index 11232c0..641442e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ; + return <>{children}; +} + export function App() { return ( @@ -35,14 +45,13 @@ export function App() { > } /> } /> - } /> + } /> } /> } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> } /> diff --git a/src/api/config.ts b/src/api/config.ts index c34b29f..3d7b6f1 100644 --- a/src/api/config.ts +++ b/src/api/config.ts @@ -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 = { + '/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)); +} diff --git a/src/api/filters.ts b/src/api/filters.ts new file mode 100644 index 0000000..7a3484a --- /dev/null +++ b/src/api/filters.ts @@ -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; +} + +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; + 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(); + 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; + }); +} diff --git a/src/api/leads.tsx b/src/api/leads.tsx index cecc560..2c72b7d 100644 --- a/src/api/leads.tsx +++ b/src/api/leads.tsx @@ -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( - () => ({ leads, records, loading, error, refetch }), - [leads, records, loading, error, refetch], + () => ({ leads, records, fields, loading, error, refetch }), + [leads, records, fields, loading, error, refetch], ); return {children}; diff --git a/src/api/meetings.ts b/src/api/meetings.ts new file mode 100644 index 0000000..55d814a --- /dev/null +++ b/src/api/meetings.ts @@ -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(); + 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]); +} diff --git a/src/components/LeadFilters.tsx b/src/components/LeadFilters.tsx new file mode 100644 index 0000000..b19d74f --- /dev/null +++ b/src/components/LeadFilters.tsx @@ -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 ( +
+ {searchFields.length > 0 && ( + onChange({ ...value, search: e.target.value })} + placeholder={`Search ${searchLabel}…`} + prefix={} + className="w-full max-w-[280px]" + /> + )} + + {fFields.length > 0 && ( + + )} + + {(activeCount > 0 || value.search) && ( + + )} + + {/* Active-filter chips */} + {fFields.map(({ field }) => { + const f = value.byField[field.field_key]; + if (!isActive(f)) return null; + return ( + + {field.output_label}: + {summarize(f, field.data_type)} + + + ); + })} + + setOpen(false)} title="Filter leads" width="md"> +
+ {fFields.map(({ field, options }) => { + const kind = filterKind(field.data_type); + const f = value.byField[field.field_key] ?? {}; + if (kind === 'set') { + return ( + ({ value: v, label: prettify(v) }))} + value={f.values ?? []} + onChange={(vals) => setField(field.field_key, { values: vals })} + /> + ); + } + if (kind === 'number') { + return ( +
+ {field.output_label} +
+ setField(field.field_key, { ...f, min: numOrUndef(e.target.value) })} + /> + + setField(field.field_key, { ...f, max: numOrUndef(e.target.value) })} + /> +
+
+ ); + } + return ( +
+ {field.output_label} +
+ setField(field.field_key, { ...f, from: e.target.value || undefined })} + /> + + setField(field.field_key, { ...f, to: e.target.value || undefined })} + /> +
+
+ ); + })} +
+ +
+ + +
+
+
+ ); +} + +function numOrUndef(s: string): number | undefined { + if (s === '') return undefined; + const n = Number(s); + return Number.isNaN(n) ? undefined : n; +} + +function pruneExcluded(byField: Record, exclude: string[]): Record { + if (!exclude.length) return byField; + const out: Record = {}; + 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 ?? '…'}`; +} diff --git a/src/components/activities/ActivityActions.tsx b/src/components/activities/ActivityActions.tsx new file mode 100644 index 0000000..3f8ee8e --- /dev/null +++ b/src/components/activities/ActivityActions.tsx @@ -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 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 ( + <> + + {open && ( + setOpen(false)} title={step.title} subtitle={subtitle} width={step.width}> + {step.Body ? ( + { + setOpen(false); + onDone(); + }} + /> + ) : ( + { + setOpen(false); + onDone(); + }} + /> + )} + + )} + + ); +} + +/** 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 ; + } + return ( +
+ {primary && } + {canDisqualify && } +
+ ); +} + +/** Toolbar "Add Lead" — Capture (INIT) submitted via /start, no instance yet. */ +export function AddLeadButton({ onDone }: { onDone: () => void }) { + const [open, setOpen] = useState(false); + return ( + <> + + {open && ( + setOpen(false)} title={CAPTURE_STEP.title} width={CAPTURE_STEP.width}> + { + setOpen(false); + onDone(); + }} + /> + + )} + + ); +} diff --git a/src/screens/AssignScreen.tsx b/src/components/activities/AssignBody.tsx similarity index 52% rename from src/screens/AssignScreen.tsx rename to src/components/activities/AssignBody.tsx index 5f14373..be5647f 100644 --- a/src/screens/AssignScreen.tsx +++ b/src/components/activities/AssignBody.tsx @@ -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(null); const [agent, setAgent] = useState(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 ( -
+
- {/* context strip + lead picker */} -
- {record ? ( - <> - -
-
- {record.lead_name ?? `Lead ${record.instance_id}`} · #{record.instance_id} -
-
- {record.city ?? record.state_region ?? '—'} · {record.product_interest ?? 'No stated interest'} · income ₹ - {formatINR(record.annual_income ?? 0)} -
-
- {record.lead_segment && {record.lead_segment}} - - ) : ( -
Select a lead at the Qualified stage.
- )} - ({ - 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]" - /> -
- {result && (
{result.ok && } {result.msg} - {result.ok && record && ( - - )}
)} @@ -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 &&
Loading form…
}
-
- {/* RIGHT — assignment summary + AI suggestion */}
Assignment
@@ -188,15 +123,15 @@ export function AssignScreen() {
- +
); } -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); diff --git a/src/screens/DocumentsScreen.tsx b/src/components/activities/DocumentsBody.tsx similarity index 76% rename from src/screens/DocumentsScreen.tsx rename to src/components/activities/DocumentsBody.tsx index 54297c5..d2bfe33 100644 --- a/src/screens/DocumentsScreen.tsx +++ b/src/components/activities/DocumentsBody.tsx @@ -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 = { const DOC_LABEL: Record = { 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>({}); - // OCR'd identity per doc — held only in memory for the cross-check (never persisted). const [ocrExtracts, setOcrExtracts] = useState>>({}); - // 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 = {}; 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 ( -
+
{record ? ( @@ -242,23 +202,8 @@ export function DocumentsScreen() {
) : ( -
Select a lead at the Product Recommended stage.
+
No lead record provided.
)} - ({ - 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]" - />
{result && ( @@ -271,18 +216,9 @@ export function DocumentsScreen() { > {result.ok && } {result.msg} - {result.ok && record && ( - - )}
)} - {/* KYC identity cross-check — OCR'd name + DOB vs the captured lead */} {kyc.hasMismatch ? (
@@ -298,7 +234,7 @@ export function DocumentsScreen() { ))}
- Document status set to “Mismatch”. Override only once you’ve confirmed the documents belong to this lead. + Document status set to "Mismatch". Override only once you've confirmed the documents belong to this lead.
@@ -309,13 +245,12 @@ export function DocumentsScreen() { ) : null} - {/* OCR document capture */}
- {/* Salary slip file upload */}
@@ -367,14 +301,13 @@ export function DocumentsScreen() {
-
- {/* RIGHT — checklist */}

Document checklist

diff --git a/src/screens/IssuePolicyScreen.tsx b/src/components/activities/IssuePolicyBody.tsx similarity index 59% rename from src/screens/IssuePolicyScreen.tsx rename to src/components/activities/IssuePolicyBody.tsx index b7f62b0..660d645 100644 --- a/src/screens/IssuePolicyScreen.tsx +++ b/src/components/activities/IssuePolicyBody.tsx @@ -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(''); - 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 ( -
+
- {/* context strip + lead picker */} -
- {lead ? ( - <> - -
-
- {lead.lead_name ?? `Lead ${lead.instance_id}`} · #{lead.instance_id} -
-
- {lead.current_state_name ?? '—'} · {productOf(lead.recommended_product) ?? '—'} · SA ₹ - {formatINR(lead.sum_assured ?? 0)} -
-
- - ) : ( -
Select a lead at the Underwriting stage.
- )} - ({ - 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]" - /> -
- {result && (
{result.ok && } {result.msg} - {result.ok && lead && ( - - )}
)} @@ -176,7 +105,7 @@ export function IssuePolicyScreen() { Policy number is auto-generated on issue (POL-YYYY-####).
-
diff --git a/src/screens/RecommendScreen.tsx b/src/components/activities/RecommendBody.tsx similarity index 64% rename from src/screens/RecommendScreen.tsx rename to src/components/activities/RecommendBody.tsx index 8c70a77..4ad8ea5 100644 --- a/src/screens/RecommendScreen.tsx +++ b/src/components/activities/RecommendBody.tsx @@ -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(''); - 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(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 ( -
+
- {/* context strip + lead picker */} -
- {lead ? ( - <> - -
-
- {lead.lead_name ?? `Lead ${lead.instance_id}`} · #{lead.instance_id} -
-
- {regionOf(lead.assigned_region) ?? lead.city ?? '—'} · {lead.product_interest ?? 'No stated interest'} · income ₹ - {formatINR(lead.annual_income ?? 0)} -
-
- - ) : ( -
Select a lead at the Meeting Scheduled stage.
- )} - ({ - 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]" - /> -
- {result && (
{result.ok && } {result.msg} - {result.ok && lead && ( - - )}
)} @@ -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() {
-
- {/* RIGHT — indicative premium helper + AI suggestion */}
@@ -286,16 +212,15 @@ export function RecommendScreen() {
- +
); } -// 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); diff --git a/src/screens/UnderwritingScreen.tsx b/src/components/activities/UnderwritingBody.tsx similarity index 59% rename from src/screens/UnderwritingScreen.tsx rename to src/components/activities/UnderwritingBody.tsx index 1dd0b86..6f75be1 100644 --- a/src/screens/UnderwritingScreen.tsx +++ b/src/components/activities/UnderwritingBody.tsx @@ -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(''); - 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 ( -
+
- {/* context strip + lead picker */} -
- {lead ? ( - <> - -
-
- {lead.lead_name ?? `Lead ${lead.instance_id}`} · #{lead.instance_id} -
-
- {lead.current_state_name ?? '—'} · eligibility {lead.eligibility_status ?? '—'} · SA ₹ - {formatINR(lead.sum_assured ?? 0)} -
-
- - ) : ( -
Select a lead at the Eligibility Assessed stage.
- )} - ({ - 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]" - /> -
- {result && (
{result.ok && } {result.msg} - {result.ok && lead && ( - - )}
)} @@ -150,14 +80,13 @@ export function UnderwritingScreen() { Underwriting reference is auto-generated on submit (UW-YYYY-####).
-
- {/* RIGHT — auto-computed assessment, visible on open */}
@@ -212,7 +141,7 @@ export function UnderwritingScreen() { ) : (
- {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.'}
)}
diff --git a/src/components/activities/Worklist.tsx b/src/components/activities/Worklist.tsx new file mode 100644 index 0000000..c640bf0 --- /dev/null +++ b/src/components/activities/Worklist.tsx @@ -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(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 ( +
+ {/* KPI row — Pipeline-screen tiles, width-capped so a couple don't stretch huge. */} + {tiles?.length ? ( +
+ {tiles.map((t) => ( + + ))} +
+ ) : null} + + {/* Leads — shared table, with a per-row action cell. */} + : undefined} + > + + {loading && !rows.length ? ( +
Loading leads…
+ ) : ( +
+
+
+ Lead + Score + Segment + Channel + Owner + Action +
+ {rows.length ? ( + pageRows.map(({ record, lead }) => ( + navigate(`/leads/${record.instance_id}`)} + action={} + /> + )) + ) : ( +
+ {scoped.length ? 'No leads match these filters.' : emptyHint ?? 'No leads at this stage.'} +
+ )} +
+
+ )} + + +
+
+ ); +} diff --git a/src/components/activities/index.ts b/src/components/activities/index.ts new file mode 100644 index 0000000..7a59747 --- /dev/null +++ b/src/components/activities/index.ts @@ -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'; diff --git a/src/components/activities/registry.ts b/src/components/activities/registry.ts new file mode 100644 index 0000000..a230874 --- /dev/null +++ b/src/components/activities/registry.ts @@ -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 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; + /** Generic path — fed to . Carries the activity uid. */ + meta?: ActivityMeta; +} + +/** The one primary action per lifecycle state. Absent state = no primary action. */ +export const STEP_BY_STATE: Record = { + '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, +}; diff --git a/src/components/activities/types.ts b/src/components/activities/types.ts new file mode 100644 index 0000000..b3f55de --- /dev/null +++ b/src/components/activities/types.ts @@ -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; +} diff --git a/src/components/core/Modal.tsx b/src/components/core/Modal.tsx new file mode 100644 index 0000000..85bbf69 --- /dev/null +++ b/src/components/core/Modal.tsx @@ -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 = { + 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( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+
+
+ {title &&

{title}

} + {subtitle &&
{subtitle}
} +
+ +
+
{children}
+
+
, + document.body, + ); +} diff --git a/src/components/core/Pagination.tsx b/src/components/core/Pagination.tsx new file mode 100644 index 0000000..dfb34fa --- /dev/null +++ b/src/components/core/Pagination.tsx @@ -0,0 +1,41 @@ +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { Button } from './Button'; + +export interface PaginationProps { + /** 1-based current page. */ + page: number; + pageSize: number; + /** Total rows across all pages (already filtered). */ + total: number; + onPage: (page: number) => void; +} + +/** Client-side pager: "Showing a–b of N" + Prev/Next. Renders nothing when the + * set fits on one page. Shared by the Pipeline table and the worklists. */ +export function Pagination({ page, pageSize, total, onPage }: PaginationProps) { + if (total <= pageSize) return null; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const safePage = Math.min(Math.max(page, 1), totalPages); + const start = (safePage - 1) * pageSize; + + return ( +
+ + Showing {start + 1}–{Math.min(start + pageSize, total)} of {total} + +
+ + + {safePage} / {totalPages} + + +
+
+ ); +} diff --git a/src/components/core/PickerShell.tsx b/src/components/core/PickerShell.tsx index cfbc23d..b8d529c 100644 --- a/src/components/core/PickerShell.tsx +++ b/src/components/core/PickerShell.tsx @@ -139,7 +139,7 @@ export function PickerShell({ createPortal(
{searchable && ( diff --git a/src/components/core/index.ts b/src/components/core/index.ts index 3335d05..5a09525 100644 --- a/src/components/core/index.ts +++ b/src/components/core/index.ts @@ -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'; diff --git a/src/components/form/ActivityForm.tsx b/src/components/form/ActivityForm.tsx index 355bc0a..b01ccd6 100644 --- a/src/components/form/ActivityForm.tsx +++ b/src/components/form/ActivityForm.tsx @@ -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 ". */ +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 don’t have permission to ${meta.name.toLowerCase()}.` : raw, + }); } finally { setSubmitting(false); } @@ -124,6 +134,19 @@ export function ActivityForm({ meta, instanceId, record, onSuccess, successMessa return
Loading form…
; } if (schemaQ.error || !fields.length) { + if (isPermissionError(schemaQ.error)) { + return ( +
+ +
+
You don’t have permission for this action
+
+ Your role can’t {meta.name.toLowerCase()}. Ask an admin to grant your role access to this activity. +
+
+
+ ); + } return (
{schemaQ.error ? `Couldn't load the form: ${schemaQ.error}` : 'This activity has no input fields.'} diff --git a/src/components/insurance/AIDecisionStream.tsx b/src/components/insurance/AIDecisionStream.tsx index 1b344fb..b4a2dba 100644 --- a/src/components/insurance/AIDecisionStream.tsx +++ b/src/components/insurance/AIDecisionStream.tsx @@ -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({

AI decision stream

- - {decisions.length} action{decisions.length === 1 ? '' : 's'} - +
+ + {decisions.length} action{decisions.length === 1 ? '' : 's'} + + {onRefresh && ( + + )} +
{escalations.length > 0 && ( @@ -52,7 +69,11 @@ export function AIDecisionStream({
)} - {loading &&
Loading decisions…
} + {/* 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 && ( +
Loading decisions…
+ )} {!loading && decisions.length === 0 && (
No AI decisions for this lead yet.
)} diff --git a/src/components/insurance/LeadRow.tsx b/src/components/insurance/LeadRow.tsx index 9695799..ec5e652 100644 --- a/src/components/insurance/LeadRow.tsx +++ b/src/components/insurance/LeadRow.tsx @@ -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 (
{lead.owner}
- {/* last AI action */} -
- - {lead.lastAction} -
+ {/* last AI action — or a custom action cell */} + {action !== undefined ? ( +
e.stopPropagation()}> + {action} +
+ ) : ( +
+ + {lead.lastAction} +
+ )}
); } diff --git a/src/components/insurance/WorkflowStepper.tsx b/src/components/insurance/WorkflowStepper.tsx index c6799dc..89555ae 100644 --- a/src/components/insurance/WorkflowStepper.tsx +++ b/src/components/insurance/WorkflowStepper.tsx @@ -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 (
@@ -33,16 +36,18 @@ export function WorkflowStepper({ const isLast = i === stages.length - 1; return ( -
- - {done ? : i + 1} +
+ + + {done ? : i + 1} + )} diff --git a/src/layout/Shell.tsx b/src/layout/Shell.tsx index 76f1c29..802bf4d 100644 --- a/src/layout/Shell.tsx +++ b/src/layout/Shell.tsx @@ -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 ( <>
@@ -71,7 +55,7 @@ function SidebarInner({ activeTo, onNav, roster }: { activeTo: string; onNav: (t
)}
setNavOpen(true)} /> -
+
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 4a461a4..2aa1db8 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -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) { + return ( + + ); +} + 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(null); const [busy, setBusy] = useState(false); @@ -30,42 +63,107 @@ export function Login() { } return ( -
-
-
- +
+ {/* subtle grid, faded toward the edges */} +
+ {/* one soft static glow for warmth */} +
+ + {/* one glass container holding the brand story (left) + the form (right) */} +
+ {/* brand story */} +
+
+ + A + +
+
aria
+
Lead-to-policy console
+
+
+ +

+ The AI-native +
+ lead-to-policy console. +

+ +
+ {HIGHLIGHTS.map((h) => ( +
+ + + +
+
{h.title}
+
{h.body}
+
+
+ ))} +
+
+ + {/* form side */} +
+
+ A
-
aria
-
Lead-to-policy console
+
Welcome back
+
Sign in to your console
- } type="email" value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="username" + placeholder="you@company.com" /> - } + type={show ? 'text' : 'password'} value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" + placeholder="••••••••" + trailing={ + + } /> {error && ( -
+
{error}
)} - +
); diff --git a/src/screens/Lead360Screen.tsx b/src/screens/Lead360Screen.tsx index a33c5a3..9806626 100644 --- a/src/screens/Lead360Screen.tsx +++ b/src/screens/Lead360Screen.tsx @@ -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 (
@@ -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
Loading lead…
; @@ -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 ( -
- {/* LEFT — profile */} -
- -
- -
-
{lead.name}
-
- #{lead.id} · {lead.city} +
+ {/* HERO — identity + lifecycle + headline figures unified into one band so + the page reads as a single record, not a scatter of tiles. */} + +
+
+ +
+
+ {lead.name} + +
-
-
- - -
-
-
- - -
- - - - - - - - } /> - -
- Product interest - {lead.interest} -
-
-
- - {/* CENTER — workflow + current activity. min-w-0 lets the 1fr track shrink - instead of forcing the grid wider than its container. */} -
- -
- -
-
- - = STAGES.indexOf('Policy Issued') ? 'success' : 'warning'} dot>{record.current_state_name ?? '—'}} - > -
- -
-
{record.current_state_name ?? 'In progress'}
-
- Owned by {lead.owner} +
+ #{lead.id} · {lead.city} · owned by {lead.owner} {meetingLabel ? ` · meeting ${meetingLabel}` : ''}
+ + {record.current_state_name ?? '—'} +
-
-
-
Sum assured
- -
-
-
Premium
- -
-
-
- -
- - {onboarded && ( + {/* lifecycle stepper */} +
+ +
+ + {/* headline figures */} +
+
+
Sum assured
+
{compactINR(cover)}
+
+
+
Premium
+
{compactINR(premium)}
+
+
+
Annual income
+
{compactINR(lead.income)}
+
+
+
+
+ + {/* BODY — primary work on the left, live AI feed + reference on the right. */} +
+ {/* MAIN */} +
+ +
+ +
+
{record.current_state_name ?? 'In progress'}
+
Owned by {lead.owner}
+
+
+
+ +
+
+ + {onboarded && ( )} - - {timeline.length ? ( - timeline.map((t, i) => ( - - )) - ) : ( -
No activity recorded yet.
- )} -
-
+ {/* Activity log + profile reference sit side by side — profile is + context, not a footer, so it reads next to the audit trail. */} +
+ + {timeline.length ? ( + timeline.map((t, i) => ( + + )) + ) : ( +
No activity recorded yet.
+ )} +
- {/* RIGHT — AI decision stream + escalations. - At lg it drops to a full-width row beneath profile + center; at xl it's the 3rd column. */} - + + + + + + } /> + +
+ Product interest + {lead.interest} +
+
+
+
+ + {/* RAIL — live AI decision feed (manual refresh, no polling). */} +
+ +
+
); } diff --git a/src/screens/NewLeadScreen.tsx b/src/screens/NewLeadScreen.tsx deleted file mode 100644 index a6289cb..0000000 --- a/src/screens/NewLeadScreen.tsx +++ /dev/null @@ -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 ( -
- - { - refetch(); - const id = res.instance_id; - if (id != null) navigate(`/leads/${id}`); - }} - /> - -
- ); -} diff --git a/src/screens/PipelineScreen.tsx b/src/screens/PipelineScreen.tsx index 44f0248..e7e52c4 100644 --- a/src/screens/PipelineScreen.tsx +++ b/src/screens/PipelineScreen.tsx @@ -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(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 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 (
@@ -232,12 +253,13 @@ export function PipelineScreen() { ))}
- {/* charts */} + {/* charts — cards stay equal height (aligned bottoms); the shorter + segments content is vertically centered so there's no dead space. */}
Live}> - +
@@ -249,18 +271,11 @@ export function PipelineScreen() { action={
- - +
} > + {loading ? (
Loading leads…
) : view === 'table' ? ( @@ -279,18 +294,21 @@ export function PipelineScreen() { Owner Last action
- {leads.length ? ( - leads.map((l) => openLead(l)} />) + {pageLeads.length ? ( + pageLeads.map((l) => openLead(l)} />) ) : ( -
No leads yet.
+
No leads match these filters.
)}
) : (
- +
)} + {view === 'table' && !loading && ( + + )}
); diff --git a/src/screens/ScheduleScreen.tsx b/src/screens/ScheduleScreen.tsx new file mode 100644 index 0000000..d8d7089 --- /dev/null +++ b/src/screens/ScheduleScreen.tsx @@ -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 = { + 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 ( + + ); +} + +/** 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 ( +
+
+
+ + setQ(e.target.value)} + placeholder="Search agents" + className="bg-transparent border-none outline-none text-sm text-body w-full placeholder:text-faint" + /> +
+
+
+ {items.length === 0 ? ( +
No agents match.
+ ) : ( + items.map((r) => { + const on = r.agent === selected; + return ( + + ); + }) + )} +
+
+ ); +} + +export function ScheduleScreen() { + const navigate = useNavigate(); + const { user } = useZino(); + const { meetings, loading, error } = useMeetings(); + const [range, setRange] = useState('week'); + const [selected, setSelected] = useState(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(() => { + const m = new Map(); + 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(); + 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 ( +
+ {error && ( +
+ Couldn’t load meetings: {error} +
+ )} + + {loading ? ( +
Loading meetings…
+ ) : roster.length === 0 ? ( +
+ +
No meetings scheduled yet
+
+ ) : ( +
+ + + {/* Detail: selected agent's agenda */} +
+
+
+ +
+

{selected}

+
+ {sel?.upcoming ?? 0} upcoming · {sel?.total ?? 0} total +
+
+
+
+ {RANGES.map(([id, label]) => ( + + ))} +
+
+ + {days.length === 0 ? ( +
+ +
No meetings in this range
+
Try “All”, or pick another agent.
+
+ ) : ( +
+ {days.map(([k, ms]) => ( +
+
+

{dayLabel(ms[0].datetime, todayKey, tomorrowKey)}

+
+ {ms.length} meetings +
+
+ {ms.map((m) => ( + navigate(`/leads/${m.leadId}`)} + /> + ))} +
+
+ ))} +
+ )} +
+
+ )} +
+ ); +} diff --git a/src/screens/worklists.tsx b/src/screens/worklists.tsx new file mode 100644 index 0000000..a37731f --- /dev/null +++ b/src/screens/worklists.tsx @@ -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 ( + + ); +} + +/** 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 ( + + ); +} + +/** 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 ( + + ); +} + +/** 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 ( + + ); +} diff --git a/src/styles/styles.css b/src/styles/styles.css index ee3e821..3f02df6 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -123,10 +123,12 @@ } @layer base { - html, + html { + height: 100%; + } body, #root { - height: 100%; + min-height: 100%; } body { margin: 0;