feat: worklist screens + modal-driven activity forms
- state-scoped lead worklists (list → row action → form modal) replace per-activity screens - shared Modal / ActivityActions / registry; bespoke bodies + generic ActivityForm - screen RBAC (canSeeScreen + RequireScreen), client-side pagination, lead filters - fixes: document scroll, PickerShell z-index above modal, friendly RBAC message, Lead360 compact INR, segments-card alignment
This commit is contained in:
parent
8f419919bb
commit
1a622b1267
33
src/App.tsx
33
src/App.tsx
@ -2,16 +2,18 @@ import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Shell } from './layout/Shell';
|
import { Shell } from './layout/Shell';
|
||||||
import { PipelineScreen } from './screens/PipelineScreen';
|
import { PipelineScreen } from './screens/PipelineScreen';
|
||||||
|
import { ScheduleScreen } from './screens/ScheduleScreen';
|
||||||
import { Lead360Screen } from './screens/Lead360Screen';
|
import { Lead360Screen } from './screens/Lead360Screen';
|
||||||
import { AssignScreen } from './screens/AssignScreen';
|
import {
|
||||||
import { RecommendScreen } from './screens/RecommendScreen';
|
QualificationScreen,
|
||||||
import { DocumentsScreen } from './screens/DocumentsScreen';
|
InteractionScreen,
|
||||||
import { UnderwritingScreen } from './screens/UnderwritingScreen';
|
DocAssessmentScreen,
|
||||||
import { IssuePolicyScreen } from './screens/IssuePolicyScreen';
|
UnderwritingPolicyScreen,
|
||||||
import { NewLeadScreen } from './screens/NewLeadScreen';
|
} from './screens/worklists';
|
||||||
import { Login } from './pages/Login';
|
import { Login } from './pages/Login';
|
||||||
import { useZino } from './api/provider';
|
import { useZino } from './api/provider';
|
||||||
import { LeadsProvider } from './api/leads';
|
import { LeadsProvider } from './api/leads';
|
||||||
|
import { canSeeScreen } from './api/config';
|
||||||
|
|
||||||
function RequireAuth({ children }: { children: ReactNode }) {
|
function RequireAuth({ children }: { children: ReactNode }) {
|
||||||
const { user } = useZino();
|
const { user } = useZino();
|
||||||
@ -20,6 +22,14 @@ function RequireAuth({ children }: { children: ReactNode }) {
|
|||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deep-link guard — a screen the user's roles can't see redirects to the
|
||||||
|
// dashboard (which is open to every operational role).
|
||||||
|
function RequireScreen({ path, children }: { path: string; children: ReactNode }) {
|
||||||
|
const { user } = useZino();
|
||||||
|
if (!canSeeScreen(user?.roles, path)) return <Navigate to="/pipeline" replace />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
@ -35,14 +45,13 @@ export function App() {
|
|||||||
>
|
>
|
||||||
<Route index element={<Navigate to="/pipeline" replace />} />
|
<Route index element={<Navigate to="/pipeline" replace />} />
|
||||||
<Route path="pipeline" element={<PipelineScreen />} />
|
<Route path="pipeline" element={<PipelineScreen />} />
|
||||||
<Route path="new" element={<NewLeadScreen />} />
|
<Route path="schedule" element={<RequireScreen path="/schedule"><ScheduleScreen /></RequireScreen>} />
|
||||||
<Route path="leads" element={<Lead360Screen />} />
|
<Route path="leads" element={<Lead360Screen />} />
|
||||||
<Route path="leads/:id" element={<Lead360Screen />} />
|
<Route path="leads/:id" element={<Lead360Screen />} />
|
||||||
<Route path="assign" element={<AssignScreen />} />
|
<Route path="qualify" element={<RequireScreen path="/qualify"><QualificationScreen /></RequireScreen>} />
|
||||||
<Route path="recommend" element={<RecommendScreen />} />
|
<Route path="engage" element={<RequireScreen path="/engage"><InteractionScreen /></RequireScreen>} />
|
||||||
<Route path="documents" element={<DocumentsScreen />} />
|
<Route path="documents" element={<RequireScreen path="/documents"><DocAssessmentScreen /></RequireScreen>} />
|
||||||
<Route path="underwriting" element={<UnderwritingScreen />} />
|
<Route path="underwriting" element={<RequireScreen path="/underwriting"><UnderwritingPolicyScreen /></RequireScreen>} />
|
||||||
<Route path="issue" element={<IssuePolicyScreen />} />
|
|
||||||
<Route path="*" element={<Navigate to="/pipeline" replace />} />
|
<Route path="*" element={<Navigate to="/pipeline" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@ -149,3 +149,29 @@ export function aiEmployeeForState(stateName?: string): { name: string; role: st
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Screen RBAC (mirrors tbl_appconfig_screens.permissions for app 385) ---
|
||||||
|
// Roles that bypass screen gating entirely (platform admins see every screen).
|
||||||
|
export const SUPER_ROLES = ['Super Admin', 'Admin'];
|
||||||
|
|
||||||
|
// Route path → roles allowed to see the screen. A path absent here is open to
|
||||||
|
// all authenticated users (e.g. the contextual Lead 360 detail). `/schedule`
|
||||||
|
// is an Aria-only screen — managers oversee agent meetings, agents see theirs.
|
||||||
|
export const SCREEN_ACCESS: Record<string, string[]> = {
|
||||||
|
'/pipeline': ['Sales Agent', 'Underwriter', 'Branch Manager'],
|
||||||
|
'/qualify': ['Sales Agent', 'Branch Manager'],
|
||||||
|
'/engage': ['Sales Agent'],
|
||||||
|
'/documents': ['Underwriter'],
|
||||||
|
'/underwriting': ['Underwriter', 'Branch Manager'],
|
||||||
|
'/schedule': ['Sales Agent', 'Branch Manager'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Whether `roles` may see the screen at `path`. Super-admins always can;
|
||||||
|
* unlisted paths are open. */
|
||||||
|
export function canSeeScreen(roles: string[] | undefined, path: string): boolean {
|
||||||
|
const allowed = SCREEN_ACCESS[path];
|
||||||
|
if (!allowed) return true;
|
||||||
|
const r = roles ?? [];
|
||||||
|
if (r.some((role) => SUPER_ROLES.includes(role))) return true;
|
||||||
|
return r.some((role) => allowed.includes(role));
|
||||||
|
}
|
||||||
|
|||||||
118
src/api/filters.ts
Normal file
118
src/api/filters.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
// Client-side recordview filtering. The app loads the full lead recordview once
|
||||||
|
// (api/leads.tsx), so search + filters run over the in-memory rows rather than
|
||||||
|
// re-querying. Which fields are filterable/searchable comes from the live
|
||||||
|
// recordview config (`is_filter` / `is_search`); option lists are derived from
|
||||||
|
// the values actually present in the data (config carries no static options).
|
||||||
|
import type { LeadRecord, RecordViewField } from './types';
|
||||||
|
|
||||||
|
/** One field's active filter. `values` = set membership; min/max = number range;
|
||||||
|
* from/to = date range. Empty/absent on every key means "no filter". */
|
||||||
|
export interface FieldFilter {
|
||||||
|
values?: string[];
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterState {
|
||||||
|
search: string;
|
||||||
|
byField: Record<string, FieldFilter>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_FILTER: FilterState = { search: '', byField: {} };
|
||||||
|
|
||||||
|
export type FilterKind = 'set' | 'number' | 'date';
|
||||||
|
|
||||||
|
export function filterKind(dataType: string): FilterKind {
|
||||||
|
if (dataType === 'number') return 'number';
|
||||||
|
if (dataType === 'date' || dataType === 'datetime') return 'date';
|
||||||
|
return 'set';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const filterableFields = (fields: RecordViewField[]) => fields.filter((f) => f.is_filter);
|
||||||
|
export const searchableFields = (fields: RecordViewField[]) => fields.filter((f) => f.is_search);
|
||||||
|
|
||||||
|
// Flatten a record field to zero+ comparable strings. Several columns arrive as
|
||||||
|
// structured objects (owner_agent {name}, recommended_product {plan_name},
|
||||||
|
// assigned_region {region}, phone {…}) or arrays (riders) — normalize them all.
|
||||||
|
export function fieldValues(record: LeadRecord, key: string): string[] {
|
||||||
|
return flatten(record[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flatten(raw: unknown): string[] {
|
||||||
|
if (raw == null || raw === '') return [];
|
||||||
|
if (Array.isArray(raw)) return raw.flatMap(flatten);
|
||||||
|
if (typeof raw === 'object') {
|
||||||
|
const o = raw as Record<string, unknown>;
|
||||||
|
const pick =
|
||||||
|
o.plan_name ?? o.name ?? o.region ?? o.label ?? o.value ?? o.phone_with_dial_code ?? o.phone;
|
||||||
|
return pick == null ? [] : [String(pick)];
|
||||||
|
}
|
||||||
|
return [String(raw)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distinct present values for a field, sorted — the option list for a set filter. */
|
||||||
|
export function distinctValues(records: LeadRecord[], key: string): string[] {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const r of records) for (const v of fieldValues(r, key)) set.add(v);
|
||||||
|
return [...set].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Title-case code-ish values (whatsapp / pending_docs); leave human text alone. */
|
||||||
|
export function prettify(v: string): string {
|
||||||
|
if (/^[a-z0-9_-]+$/.test(v)) {
|
||||||
|
return v.replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isActive(f: FieldFilter | undefined): boolean {
|
||||||
|
if (!f) return false;
|
||||||
|
return !!f.values?.length || f.min != null || f.max != null || !!f.from || !!f.to;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activeFilterCount(state: FilterState): number {
|
||||||
|
return Object.values(state.byField).filter(isActive).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply search + per-field filters to the loaded rows. */
|
||||||
|
export function applyFilters(
|
||||||
|
records: LeadRecord[],
|
||||||
|
state: FilterState,
|
||||||
|
fields: RecordViewField[],
|
||||||
|
): LeadRecord[] {
|
||||||
|
const q = state.search.trim().toLowerCase();
|
||||||
|
const sFields = searchableFields(fields);
|
||||||
|
const dtByKey = new Map(fields.map((f) => [f.field_key, f.data_type]));
|
||||||
|
|
||||||
|
return records.filter((r) => {
|
||||||
|
if (q) {
|
||||||
|
const hit = sFields.some((f) => fieldValues(r, f.field_key).some((v) => v.toLowerCase().includes(q)));
|
||||||
|
if (!hit) return false;
|
||||||
|
}
|
||||||
|
for (const [key, f] of Object.entries(state.byField)) {
|
||||||
|
if (!isActive(f)) continue;
|
||||||
|
const vals = fieldValues(r, key);
|
||||||
|
switch (filterKind(dtByKey.get(key) ?? 'text')) {
|
||||||
|
case 'set':
|
||||||
|
if (f.values?.length && !vals.some((v) => f.values!.includes(v))) return false;
|
||||||
|
break;
|
||||||
|
case 'number': {
|
||||||
|
const n = vals.length ? Number(vals[0]) : NaN;
|
||||||
|
if (f.min != null && (Number.isNaN(n) || n < f.min)) return false;
|
||||||
|
if (f.max != null && (Number.isNaN(n) || n > f.max)) return false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'date': {
|
||||||
|
const t = vals.length ? new Date(vals[0]).getTime() : NaN;
|
||||||
|
if (f.from && (Number.isNaN(t) || t < new Date(f.from).getTime())) return false;
|
||||||
|
// +1 day so an end date is inclusive of the whole day.
|
||||||
|
if (f.to && (Number.isNaN(t) || t > new Date(f.to).getTime() + 86_400_000)) return false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -4,13 +4,15 @@ import type { Lead } from '../types';
|
|||||||
import { useQuery, useZino } from './provider';
|
import { useQuery, useZino } from './provider';
|
||||||
import { recordToLead } from './adapters';
|
import { recordToLead } from './adapters';
|
||||||
import { RECORD_VIEW_IDS } from './config';
|
import { RECORD_VIEW_IDS } from './config';
|
||||||
import type { LeadRecord } from './types';
|
import type { LeadRecord, RecordViewField } from './types';
|
||||||
|
|
||||||
interface LeadsContextValue {
|
interface LeadsContextValue {
|
||||||
/** Domain-mapped leads (pipeline list, roster, KPIs). */
|
/** Domain-mapped leads (pipeline list, roster, KPIs). */
|
||||||
leads: Lead[];
|
leads: Lead[];
|
||||||
/** Raw recordview rows — action screens read region/income/etc. from these. */
|
/** Raw recordview rows — action screens read region/income/etc. from these. */
|
||||||
records: LeadRecord[];
|
records: LeadRecord[];
|
||||||
|
/** Recordview field config — drives the filter/search controls. */
|
||||||
|
fields: RecordViewField[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
@ -31,10 +33,11 @@ export function LeadsProvider({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const records = useMemo(() => (data?.data ?? []) as LeadRecord[], [data]);
|
const records = useMemo(() => (data?.data ?? []) as LeadRecord[], [data]);
|
||||||
|
const fields = useMemo(() => data?.config?.fields ?? [], [data]);
|
||||||
const leads = useMemo(() => records.map(recordToLead), [records]);
|
const leads = useMemo(() => records.map(recordToLead), [records]);
|
||||||
const value = useMemo<LeadsContextValue>(
|
const value = useMemo<LeadsContextValue>(
|
||||||
() => ({ leads, records, loading, error, refetch }),
|
() => ({ leads, records, fields, loading, error, refetch }),
|
||||||
[leads, records, loading, error, refetch],
|
[leads, records, fields, loading, error, refetch],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <LeadsContext.Provider value={value}>{children}</LeadsContext.Provider>;
|
return <LeadsContext.Provider value={value}>{children}</LeadsContext.Provider>;
|
||||||
|
|||||||
79
src/api/meetings.ts
Normal file
79
src/api/meetings.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
// Derive a flat meeting/booking list from the shared lead recordview.
|
||||||
|
// One lead row carries at most one meeting (meeting_datetime + mode + address),
|
||||||
|
// owned by owner_agent. No separate agent/meeting table exists — the agent
|
||||||
|
// roster is whatever owner_agent values the leads carry.
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useLeads } from './leads';
|
||||||
|
import type { LeadRecord } from './types';
|
||||||
|
|
||||||
|
export interface Meeting {
|
||||||
|
leadId: string;
|
||||||
|
leadName: string;
|
||||||
|
/** Normalized agent display name, or 'Unassigned'. */
|
||||||
|
agent: string;
|
||||||
|
agentId: number | null;
|
||||||
|
/** ISO datetime of the slot. */
|
||||||
|
datetime: string;
|
||||||
|
/** Raw mode key (video | phone | branch | home_visit | …). */
|
||||||
|
mode: string;
|
||||||
|
/** Zoom link / branch name / address — may be empty. */
|
||||||
|
address?: string;
|
||||||
|
/** current_state_name — where the lead sits in the lifecycle. */
|
||||||
|
stage: string;
|
||||||
|
interest?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNASSIGNED = 'Unassigned';
|
||||||
|
|
||||||
|
// owner_agent arrives as {id,name} (clean lookup rows), a bare string (legacy /
|
||||||
|
// test rows), or null. Flatten to a stable {name,id}.
|
||||||
|
function normAgent(raw: unknown): { name: string; id: number | null } {
|
||||||
|
if (raw && typeof raw === 'object') {
|
||||||
|
const o = raw as { id?: number; name?: string };
|
||||||
|
const name = (o.name ?? '').trim();
|
||||||
|
return { name: name || UNASSIGNED, id: typeof o.id === 'number' ? o.id : null };
|
||||||
|
}
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
const name = raw.trim();
|
||||||
|
return { name: name || UNASSIGNED, id: null };
|
||||||
|
}
|
||||||
|
return { name: UNASSIGNED, id: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordToMeeting(r: LeadRecord): Meeting | null {
|
||||||
|
if (!r.meeting_datetime) return null;
|
||||||
|
const dt = new Date(r.meeting_datetime);
|
||||||
|
if (Number.isNaN(dt.getTime())) return null;
|
||||||
|
const agent = normAgent(r.owner_agent);
|
||||||
|
const address = typeof r.meeting_address === 'string' ? r.meeting_address.trim() : '';
|
||||||
|
return {
|
||||||
|
leadId: String(r.instance_id),
|
||||||
|
leadName: r.lead_name ?? `Lead ${r.instance_id}`,
|
||||||
|
agent: agent.name,
|
||||||
|
agentId: agent.id,
|
||||||
|
datetime: r.meeting_datetime,
|
||||||
|
mode: (r.meeting_mode ?? '').toLowerCase(),
|
||||||
|
address: address || undefined,
|
||||||
|
stage: r.current_state_name ?? '—',
|
||||||
|
interest: r.product_interest ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMeetings(): { meetings: Meeting[]; agents: string[]; loading: boolean; error: string | null } {
|
||||||
|
const { records, loading, error } = useLeads();
|
||||||
|
return useMemo(() => {
|
||||||
|
const meetings = records
|
||||||
|
.map(recordToMeeting)
|
||||||
|
.filter((m): m is Meeting => m !== null)
|
||||||
|
.sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime());
|
||||||
|
// Roster ordered by meeting load, Unassigned always last.
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const m of meetings) counts.set(m.agent, (counts.get(m.agent) ?? 0) + 1);
|
||||||
|
const agents = [...counts.keys()].sort((a, b) => {
|
||||||
|
if (a === UNASSIGNED) return 1;
|
||||||
|
if (b === UNASSIGNED) return -1;
|
||||||
|
return (counts.get(b) ?? 0) - (counts.get(a) ?? 0);
|
||||||
|
});
|
||||||
|
return { meetings, agents, loading, error };
|
||||||
|
}, [records, loading, error]);
|
||||||
|
}
|
||||||
209
src/components/LeadFilters.tsx
Normal file
209
src/components/LeadFilters.tsx
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
// Recordview filter toolbar: an inline search box + a "Filters" modal driven by
|
||||||
|
// the recordview's `is_filter` / `is_search` config. Drops in above any lead
|
||||||
|
// table (Pipeline, the worklists). Owns its modal; filtering itself is the pure
|
||||||
|
// applyFilters() in api/filters.ts, so the parent stays in control of the state.
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Search, SlidersHorizontal, X } from 'lucide-react';
|
||||||
|
import { Button, Input, Modal, MultiSelect } from './core';
|
||||||
|
import type { LeadRecord, RecordViewField } from '../api/types';
|
||||||
|
import {
|
||||||
|
activeFilterCount,
|
||||||
|
distinctValues,
|
||||||
|
filterableFields,
|
||||||
|
filterKind,
|
||||||
|
isActive,
|
||||||
|
prettify,
|
||||||
|
searchableFields,
|
||||||
|
type FieldFilter,
|
||||||
|
type FilterState,
|
||||||
|
} from '../api/filters';
|
||||||
|
|
||||||
|
export interface LeadFiltersProps {
|
||||||
|
/** Unfiltered rows — option lists are derived from these. */
|
||||||
|
records: LeadRecord[];
|
||||||
|
fields: RecordViewField[];
|
||||||
|
value: FilterState;
|
||||||
|
onChange: (next: FilterState) => void;
|
||||||
|
/** Field keys to omit (e.g. current_state_name on a state-scoped worklist). */
|
||||||
|
excludeKeys?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeadFilters({ records, fields, value, onChange, excludeKeys = [] }: LeadFiltersProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const searchFields = useMemo(() => searchableFields(fields), [fields]);
|
||||||
|
// Only surface filter fields that actually have values to filter on.
|
||||||
|
const fFields = useMemo(
|
||||||
|
() =>
|
||||||
|
filterableFields(fields)
|
||||||
|
.filter((f) => !excludeKeys.includes(f.field_key))
|
||||||
|
.map((f) => ({ field: f, options: filterKind(f.data_type) === 'set' ? distinctValues(records, f.field_key) : [] }))
|
||||||
|
.filter(({ field, options }) => filterKind(field.data_type) !== 'set' || options.length > 0),
|
||||||
|
[fields, records, excludeKeys],
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeCount = activeFilterCount({ ...value, byField: pruneExcluded(value.byField, excludeKeys) });
|
||||||
|
const searchLabel = searchFields.map((f) => f.output_label).join(', ') || 'leads';
|
||||||
|
|
||||||
|
if (!searchFields.length && !fFields.length) return null;
|
||||||
|
|
||||||
|
const setField = (key: string, next: FieldFilter) =>
|
||||||
|
onChange({ ...value, byField: { ...value.byField, [key]: next } });
|
||||||
|
const clearField = (key: string) => {
|
||||||
|
const rest = { ...value.byField };
|
||||||
|
delete rest[key];
|
||||||
|
onChange({ ...value, byField: rest });
|
||||||
|
};
|
||||||
|
const clearAll = () => onChange({ search: '', byField: {} });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2.5 px-[18px] py-3 border-b border-border-subtle">
|
||||||
|
{searchFields.length > 0 && (
|
||||||
|
<Input
|
||||||
|
value={value.search}
|
||||||
|
onChange={(e) => onChange({ ...value, search: e.target.value })}
|
||||||
|
placeholder={`Search ${searchLabel}…`}
|
||||||
|
prefix={<Search size={15} />}
|
||||||
|
className="w-full max-w-[280px]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fFields.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
iconLeft={<SlidersHorizontal size={14} />}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
Filters
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<span className="ml-1 inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-pill bg-sunrise text-white text-2xs font-bold nums">
|
||||||
|
{activeCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(activeCount > 0 || value.search) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearAll}
|
||||||
|
className="text-xs font-semibold text-muted hover:text-strong inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<X size={13} /> Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active-filter chips */}
|
||||||
|
{fFields.map(({ field }) => {
|
||||||
|
const f = value.byField[field.field_key];
|
||||||
|
if (!isActive(f)) return null;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={field.field_key}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-pill bg-sunrise-100 text-sunrise-600 text-2xs font-semibold pl-2.5 pr-1.5 py-1"
|
||||||
|
>
|
||||||
|
<span className="opacity-70">{field.output_label}:</span>
|
||||||
|
{summarize(f, field.data_type)}
|
||||||
|
<button type="button" onClick={() => clearField(field.field_key)} className="hover:opacity-70">
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Modal open={open} onClose={() => setOpen(false)} title="Filter leads" width="md">
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
{fFields.map(({ field, options }) => {
|
||||||
|
const kind = filterKind(field.data_type);
|
||||||
|
const f = value.byField[field.field_key] ?? {};
|
||||||
|
if (kind === 'set') {
|
||||||
|
return (
|
||||||
|
<MultiSelect
|
||||||
|
key={field.field_key}
|
||||||
|
label={field.output_label}
|
||||||
|
options={options.map((v) => ({ value: v, label: prettify(v) }))}
|
||||||
|
value={f.values ?? []}
|
||||||
|
onChange={(vals) => setField(field.field_key, { values: vals })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (kind === 'number') {
|
||||||
|
return (
|
||||||
|
<div key={field.field_key} className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-sm font-medium text-muted">{field.output_label}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="Min"
|
||||||
|
value={f.min ?? ''}
|
||||||
|
onChange={(e) => setField(field.field_key, { ...f, min: numOrUndef(e.target.value) })}
|
||||||
|
/>
|
||||||
|
<span className="text-faint">–</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="Max"
|
||||||
|
value={f.max ?? ''}
|
||||||
|
onChange={(e) => setField(field.field_key, { ...f, max: numOrUndef(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={field.field_key} className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-sm font-medium text-muted">{field.output_label}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={f.from ?? ''}
|
||||||
|
onChange={(e) => setField(field.field_key, { ...f, from: e.target.value || undefined })}
|
||||||
|
/>
|
||||||
|
<span className="text-faint">–</span>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={f.to ?? ''}
|
||||||
|
onChange={(e) => setField(field.field_key, { ...f, to: e.target.value || undefined })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex items-center justify-between">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => onChange({ ...value, byField: {} })}>
|
||||||
|
Clear all
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => setOpen(false)}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function numOrUndef(s: string): number | undefined {
|
||||||
|
if (s === '') return undefined;
|
||||||
|
const n = Number(s);
|
||||||
|
return Number.isNaN(n) ? undefined : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneExcluded(byField: Record<string, FieldFilter>, exclude: string[]): Record<string, FieldFilter> {
|
||||||
|
if (!exclude.length) return byField;
|
||||||
|
const out: Record<string, FieldFilter> = {};
|
||||||
|
for (const [k, v] of Object.entries(byField)) if (!exclude.includes(k)) out[k] = v;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(f: FieldFilter, dataType: string): string {
|
||||||
|
if (filterKind(dataType) === 'set') {
|
||||||
|
const vs = f.values ?? [];
|
||||||
|
return vs.length <= 2 ? vs.map(prettify).join(', ') : `${vs.length} selected`;
|
||||||
|
}
|
||||||
|
if (filterKind(dataType) === 'number') {
|
||||||
|
return `${f.min ?? '…'} – ${f.max ?? '…'}`;
|
||||||
|
}
|
||||||
|
return `${f.from ?? '…'} – ${f.to ?? '…'}`;
|
||||||
|
}
|
||||||
102
src/components/activities/ActivityActions.tsx
Normal file
102
src/components/activities/ActivityActions.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { Button } from '../core/Button';
|
||||||
|
import type { ButtonSize, ButtonVariant } from '../core/Button';
|
||||||
|
import { Modal } from '../core/Modal';
|
||||||
|
import { ActivityForm } from '../form/ActivityForm';
|
||||||
|
import { DISQUALIFY_STATES } from '../../api/forms';
|
||||||
|
import type { LeadRecord } from '../../api/types';
|
||||||
|
import { STEP_BY_STATE, DISQUALIFY_STEP, CAPTURE_STEP, type ActivityStep } from './registry';
|
||||||
|
|
||||||
|
// A button that opens its step's form in a modal for one lead. Bespoke steps
|
||||||
|
// render their *Body; generic steps render <ActivityForm> from the live schema.
|
||||||
|
function StepButton({
|
||||||
|
step,
|
||||||
|
record,
|
||||||
|
onDone,
|
||||||
|
variant,
|
||||||
|
size = 'sm',
|
||||||
|
}: {
|
||||||
|
step: ActivityStep;
|
||||||
|
record: LeadRecord;
|
||||||
|
onDone: () => void;
|
||||||
|
variant: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const subtitle = `${record.lead_name ?? `Lead ${record.instance_id}`} · #${record.instance_id} · ${record.current_state_name ?? ''}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant={variant} size={size} onClick={() => setOpen(true)}>
|
||||||
|
{step.buttonLabel}
|
||||||
|
</Button>
|
||||||
|
{open && (
|
||||||
|
<Modal open onClose={() => setOpen(false)} title={step.title} subtitle={subtitle} width={step.width}>
|
||||||
|
{step.Body ? (
|
||||||
|
<step.Body
|
||||||
|
instanceId={record.instance_id}
|
||||||
|
record={record}
|
||||||
|
onSuccess={() => {
|
||||||
|
setOpen(false);
|
||||||
|
onDone();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ActivityForm
|
||||||
|
meta={step.meta!}
|
||||||
|
instanceId={record.instance_id}
|
||||||
|
record={record}
|
||||||
|
onSuccess={() => {
|
||||||
|
setOpen(false);
|
||||||
|
onDone();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The per-row action cell: the one primary activity for this lead's state,
|
||||||
|
* plus Disqualify wherever it's valid. */
|
||||||
|
export function LeadActions({ record, onDone }: { record: LeadRecord; onDone: () => void }) {
|
||||||
|
const state = record.current_state_name ?? '';
|
||||||
|
const primary = STEP_BY_STATE[state];
|
||||||
|
const canDisqualify = DISQUALIFY_STATES.has(state);
|
||||||
|
|
||||||
|
if (!primary && !canDisqualify) {
|
||||||
|
return <span className="text-2xs text-faint">—</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{primary && <StepButton step={primary} record={record} onDone={onDone} variant="primary" />}
|
||||||
|
{canDisqualify && <StepButton step={DISQUALIFY_STEP} record={record} onDone={onDone} variant="ghost" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toolbar "Add Lead" — Capture (INIT) submitted via /start, no instance yet. */
|
||||||
|
export function AddLeadButton({ onDone }: { onDone: () => void }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="primary" size="md" onClick={() => setOpen(true)}>
|
||||||
|
<Plus size={16} />
|
||||||
|
Add Lead
|
||||||
|
</Button>
|
||||||
|
{open && (
|
||||||
|
<Modal open onClose={() => setOpen(false)} title={CAPTURE_STEP.title} width={CAPTURE_STEP.width}>
|
||||||
|
<ActivityForm
|
||||||
|
meta={CAPTURE_STEP.meta!}
|
||||||
|
onSuccess={() => {
|
||||||
|
setOpen(false);
|
||||||
|
onDone();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,42 +1,23 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
||||||
import { CheckCircle2, UserCheck } from 'lucide-react';
|
import { CheckCircle2, UserCheck } from 'lucide-react';
|
||||||
import { AIDecisionCard, Avatar, Badge, Button, Card, formatINR, SelectField } from '../components';
|
import { AIDecisionCard, Button, Card } from '../';
|
||||||
import { LookupField } from '../components/form';
|
import { LookupField } from '../form';
|
||||||
import type { LookupValue } from '../components/form';
|
import type { LookupValue } from '../form';
|
||||||
import { useQuery, useZino } from '../api/provider';
|
import { useQuery, useZino } from '../../api/provider';
|
||||||
import { useLeads } from '../api/leads';
|
import { decisionToCard } from '../../api/adapters';
|
||||||
import { decisionToCard } from '../api/adapters';
|
import { fieldFromSchema } from '../../api/schema';
|
||||||
import { fieldFromSchema } from '../api/schema';
|
import { ACTIVITY_META } from '../../api/forms';
|
||||||
import { ACTIVITY_META } from '../api/forms';
|
import type { ActivityBodyProps } from './types';
|
||||||
|
|
||||||
const DEF = ACTIVITY_META.assign;
|
const DEF = ACTIVITY_META.assign;
|
||||||
|
|
||||||
export function AssignScreen() {
|
export function AssignBody({ instanceId, record, onSuccess }: ActivityBodyProps) {
|
||||||
const { client } = useZino();
|
const { client } = useZino();
|
||||||
const navigate = useNavigate();
|
|
||||||
const [params] = useSearchParams();
|
|
||||||
const instanceParam = params.get('instance');
|
|
||||||
|
|
||||||
const { records, refetch } = useLeads();
|
const schemaQ = useQuery(() => client.formSchema(DEF.uid, instanceId), [instanceId]);
|
||||||
|
|
||||||
// Lookup field config (templates / display+storage cols) comes from the live
|
|
||||||
// form schema, not hardcoded.
|
|
||||||
const schemaQ = useQuery(() => client.formSchema(DEF.uid), []);
|
|
||||||
const REGION = useMemo(() => fieldFromSchema(schemaQ.data, 'assigned_region_input'), [schemaQ.data]);
|
const REGION = useMemo(() => fieldFromSchema(schemaQ.data, 'assigned_region_input'), [schemaQ.data]);
|
||||||
const OWNER = useMemo(() => fieldFromSchema(schemaQ.data, 'owner_agent_input'), [schemaQ.data]);
|
const OWNER = useMemo(() => fieldFromSchema(schemaQ.data, 'owner_agent_input'), [schemaQ.data]);
|
||||||
|
|
||||||
// Assign Lead is valid at the Qualified state.
|
|
||||||
const eligible = useMemo(() => records.filter((r) => r.current_state_name === 'Qualified'), [records]);
|
|
||||||
|
|
||||||
const [selectedId, setSelectedId] = useState('');
|
|
||||||
useEffect(() => {
|
|
||||||
if (instanceParam) setSelectedId(instanceParam);
|
|
||||||
else if (!selectedId && eligible[0]) setSelectedId(String(eligible[0].instance_id));
|
|
||||||
}, [instanceParam, eligible, selectedId]);
|
|
||||||
|
|
||||||
const record = useMemo(() => records.find((r) => String(r.instance_id) === selectedId), [records, selectedId]);
|
|
||||||
|
|
||||||
const [region, setRegion] = useState<LookupValue | null>(null);
|
const [region, setRegion] = useState<LookupValue | null>(null);
|
||||||
const [agent, setAgent] = useState<LookupValue | null>(null);
|
const [agent, setAgent] = useState<LookupValue | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -49,7 +30,6 @@ export function AssignScreen() {
|
|||||||
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!record) return;
|
|
||||||
if (!region) {
|
if (!region) {
|
||||||
setResult({ ok: false, msg: 'Assigned region is required.' });
|
setResult({ ok: false, msg: 'Assigned region is required.' });
|
||||||
return;
|
return;
|
||||||
@ -57,12 +37,12 @@ export function AssignScreen() {
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
try {
|
try {
|
||||||
await client.performActivity(record.instance_id, DEF.uid, {
|
const res = await client.performActivity(instanceId, DEF.uid, {
|
||||||
assigned_region_input: region,
|
assigned_region_input: region,
|
||||||
...(agent ? { owner_agent_input: agent } : {}),
|
...(agent ? { owner_agent_input: agent } : {}),
|
||||||
});
|
});
|
||||||
setResult({ ok: true, msg: 'Lead assigned — advanced to Assigned.' });
|
setResult({ ok: true, msg: 'Lead assigned — advanced to Assigned.' });
|
||||||
refetch();
|
onSuccess?.(res);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') });
|
setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') });
|
||||||
} finally {
|
} finally {
|
||||||
@ -71,44 +51,8 @@ export function AssignScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-[18px] max-w-[1140px] mx-auto items-start grid-cols-1 lg:grid-cols-[1fr_340px]">
|
<div className="grid gap-[18px] items-start grid-cols-1 lg:grid-cols-[1fr_340px]">
|
||||||
<div className="flex flex-col gap-[18px] min-w-0">
|
<div className="flex flex-col gap-[18px] min-w-0">
|
||||||
{/* context strip + lead picker */}
|
|
||||||
<div className="flex items-center gap-3 bg-card border border-border-subtle rounded-lg px-[18px] py-3.5 shadow-sm">
|
|
||||||
{record ? (
|
|
||||||
<>
|
|
||||||
<Avatar name={record.lead_name ?? `Lead ${record.instance_id}`} size={40} />
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-base font-bold text-strong">
|
|
||||||
{record.lead_name ?? `Lead ${record.instance_id}`} · #{record.instance_id}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-faint">
|
|
||||||
{record.city ?? record.state_region ?? '—'} · {record.product_interest ?? 'No stated interest'} · income ₹
|
|
||||||
{formatINR(record.annual_income ?? 0)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{record.lead_segment && <Badge tone="accent">{record.lead_segment}</Badge>}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="flex-1 text-sm text-faint">Select a lead at the Qualified stage.</div>
|
|
||||||
)}
|
|
||||||
<SelectField
|
|
||||||
options={[
|
|
||||||
{ value: '', label: eligible.length ? 'Choose lead…' : 'No qualified leads' },
|
|
||||||
...records.map((r) => ({
|
|
||||||
value: String(r.instance_id),
|
|
||||||
label: `#${r.instance_id} ${r.lead_name ?? ''} · ${r.current_state_name ?? ''}`,
|
|
||||||
})),
|
|
||||||
]}
|
|
||||||
value={selectedId}
|
|
||||||
onChange={(v) => {
|
|
||||||
setSelectedId(v);
|
|
||||||
setResult(null);
|
|
||||||
}}
|
|
||||||
className="w-[260px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
@ -119,14 +63,6 @@ export function AssignScreen() {
|
|||||||
>
|
>
|
||||||
{result.ok && <CheckCircle2 size={16} />}
|
{result.ok && <CheckCircle2 size={16} />}
|
||||||
{result.msg}
|
{result.msg}
|
||||||
{result.ok && record && (
|
|
||||||
<button
|
|
||||||
className="ml-auto text-sm font-semibold text-link underline"
|
|
||||||
onClick={() => navigate(`/leads/${record.instance_id}`)}
|
|
||||||
>
|
|
||||||
View lead
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -141,7 +77,7 @@ export function AssignScreen() {
|
|||||||
storageCols={REGION.lookupStorage ?? []}
|
storageCols={REGION.lookupStorage ?? []}
|
||||||
activityId={DEF.uid}
|
activityId={DEF.uid}
|
||||||
fieldId={REGION.id}
|
fieldId={REGION.id}
|
||||||
instanceId={record?.instance_id}
|
instanceId={instanceId}
|
||||||
value={region}
|
value={region}
|
||||||
onChange={setRegion}
|
onChange={setRegion}
|
||||||
/>
|
/>
|
||||||
@ -154,7 +90,7 @@ export function AssignScreen() {
|
|||||||
storageCols={OWNER.lookupStorage ?? []}
|
storageCols={OWNER.lookupStorage ?? []}
|
||||||
activityId={DEF.uid}
|
activityId={DEF.uid}
|
||||||
fieldId={OWNER.id}
|
fieldId={OWNER.id}
|
||||||
instanceId={record?.instance_id}
|
instanceId={instanceId}
|
||||||
value={agent}
|
value={agent}
|
||||||
onChange={setAgent}
|
onChange={setAgent}
|
||||||
/>
|
/>
|
||||||
@ -162,14 +98,13 @@ export function AssignScreen() {
|
|||||||
{schemaQ.loading && <div className="col-span-full text-sm text-faint">Loading form…</div>}
|
{schemaQ.loading && <div className="col-span-full text-sm text-faint">Loading form…</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2.5 mt-5">
|
<div className="flex gap-2.5 mt-5">
|
||||||
<Button variant="primary" disabled={!record || submitting} onClick={submit}>
|
<Button variant="primary" disabled={submitting} onClick={submit}>
|
||||||
{submitting ? 'Assigning…' : 'Assign lead'}
|
{submitting ? 'Assigning…' : 'Assign lead'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RIGHT — assignment summary + AI suggestion */}
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="bg-navy-grad rounded-lg px-5 py-[22px] shadow-md">
|
<div className="bg-navy-grad rounded-lg px-5 py-[22px] shadow-md">
|
||||||
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy-muted mb-3.5">Assignment</div>
|
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy-muted mb-3.5">Assignment</div>
|
||||||
@ -188,15 +123,15 @@ export function AssignScreen() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AssignAiSuggestion instanceId={record?.instance_id} />
|
<AssignAiSuggestion instanceId={instanceId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AssignAiSuggestion({ instanceId }: { instanceId?: number }) {
|
function AssignAiSuggestion({ instanceId }: { instanceId: number | string }) {
|
||||||
const { client } = useZino();
|
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];
|
const latest = (q.data ?? [])[0];
|
||||||
if (!latest) return null;
|
if (!latest) return null;
|
||||||
const card = decisionToCard(latest);
|
const card = decisionToCard(latest);
|
||||||
@ -1,14 +1,13 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
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 { AlertTriangle, CheckCircle2, FileText, Info, ShieldCheck, UploadCloud } from 'lucide-react';
|
||||||
import { Avatar, Badge, Button, Card, DocumentUploadCard, formatINR, Input, SelectField } from '../components';
|
import { Avatar, Badge, Button, Card, DocumentUploadCard, formatINR, Input, SelectField } from '../';
|
||||||
import type { BadgeTone, DocStatus, OcrField } from '../components';
|
import type { BadgeTone, DocStatus, OcrField } from '../';
|
||||||
import { useQuery, useZino } from '../api/provider';
|
import { useQuery, useZino } from '../../api/provider';
|
||||||
import { useLeads } from '../api/leads';
|
import { fieldFromSchema } from '../../api/schema';
|
||||||
import { fieldFromSchema } from '../api/schema';
|
import { ACTIVITIES } from '../../api/config';
|
||||||
import { ACTIVITIES } from '../api/config';
|
import { OCR_FIELDS } from '../../api/forms';
|
||||||
import { OCR_FIELDS } from '../api/forms';
|
import { crossCheck, type DocExtract, type DocKey } from '../../lib/kyc-match';
|
||||||
import { crossCheck, type DocExtract, type DocKey } from '../lib/kyc-match';
|
import type { ActivityBodyProps } from './types';
|
||||||
|
|
||||||
const ACT = ACTIVITIES.COLLECT_DOCUMENTS;
|
const ACT = ACTIVITIES.COLLECT_DOCUMENTS;
|
||||||
const F = ACT.fields;
|
const F = ACT.fields;
|
||||||
@ -22,15 +21,12 @@ const STATUS_TONE: Record<string, BadgeTone> = {
|
|||||||
|
|
||||||
const DOC_LABEL: Record<DocKey, string> = { pan: 'PAN', aadhaar: 'Aadhaar' };
|
const DOC_LABEL: Record<DocKey, string> = { pan: 'PAN', aadhaar: 'Aadhaar' };
|
||||||
|
|
||||||
// One human-readable line per mismatched field for the banner.
|
|
||||||
function mismatchLine(f: { doc: DocKey; field: 'name' | 'dob'; captured: string; ocr: string }): string {
|
function mismatchLine(f: { doc: DocKey; field: 'name' | 'dob'; captured: string; ocr: string }): string {
|
||||||
const what = f.field === 'name' ? 'name' : 'DOB';
|
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)}`;
|
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({
|
function OcrCapture({
|
||||||
ocr,
|
ocr,
|
||||||
docKey,
|
docKey,
|
||||||
@ -63,17 +59,13 @@ function OcrCapture({
|
|||||||
try {
|
try {
|
||||||
const res = await client.extractOcr(file, { activityId, fieldId: ocr.id, instanceId });
|
const res = await client.extractOcr(file, { activityId, fieldId: ocr.id, instanceId });
|
||||||
const extracted = res.extracted ?? {};
|
const extracted = res.extracted ?? {};
|
||||||
// Autofill mapped target inputs.
|
|
||||||
for (const m of ocr.mappings) {
|
for (const m of ocr.mappings) {
|
||||||
if (Object.prototype.hasOwnProperty.call(extracted, m.extraction_key)) {
|
if (Object.prototype.hasOwnProperty.call(extracted, m.extraction_key)) {
|
||||||
onAutofill(m.target_field, 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));
|
const asStr = (v: unknown) => (v == null ? null : String(v));
|
||||||
onExtract(docKey, { name: asStr(extracted.full_name), dob: asStr(extracted.date_of_birth) });
|
onExtract(docKey, { name: asStr(extracted.full_name), dob: asStr(extracted.date_of_birth) });
|
||||||
// Show every extracted key in the card for confirmation.
|
|
||||||
setFields(
|
setFields(
|
||||||
Object.entries(extracted)
|
Object.entries(extracted)
|
||||||
.filter(([, v]) => v !== null && v !== undefined && v !== '')
|
.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 { client } = useZino();
|
||||||
const navigate = useNavigate();
|
|
||||||
const [params] = useSearchParams();
|
|
||||||
const instanceParam = params.get('instance');
|
|
||||||
|
|
||||||
const { records, refetch } = useLeads();
|
const schemaQ = useQuery(() => client.formSchema(ACT.uid, instanceId), [instanceId]);
|
||||||
|
|
||||||
// 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 docStatusOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.docStatus)?.options ?? [], [schemaQ.data]);
|
const docStatusOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.docStatus)?.options ?? [], [schemaQ.data]);
|
||||||
|
|
||||||
const [pan, setPan] = useState('');
|
const [pan, setPan] = useState('');
|
||||||
const [aadhaar, setAadhaar] = useState('');
|
const [aadhaar, setAadhaar] = useState('');
|
||||||
const [verifiedIncome, setVerifiedIncome] = useState(0);
|
const [verifiedIncome, setVerifiedIncome] = useState(0);
|
||||||
const [docStatus, setDocStatus] = useState('verified');
|
const [docStatus, setDocStatus] = useState('verified');
|
||||||
// Picked-but-not-yet-uploaded files, keyed by field id.
|
|
||||||
const [files, setFiles] = useState<Record<string, File | null>>({});
|
const [files, setFiles] = useState<Record<string, File | null>>({});
|
||||||
// OCR'd identity per doc — held only in memory for the cross-check (never persisted).
|
|
||||||
const [ocrExtracts, setOcrExtracts] = useState<Partial<Record<DocKey, DocExtract>>>({});
|
const [ocrExtracts, setOcrExtracts] = useState<Partial<Record<DocKey, DocExtract>>>({});
|
||||||
// True once the agent edits the status by hand — stops the auto-mismatch nudge.
|
|
||||||
const statusTouched = useRef(false);
|
const statusTouched = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -166,20 +134,15 @@ export function DocumentsScreen() {
|
|||||||
const setFile = (fieldId: string) => (f: File | null) => setFiles((p) => ({ ...p, [fieldId]: f }));
|
const setFile = (fieldId: string) => (f: File | null) => setFiles((p) => ({ ...p, [fieldId]: f }));
|
||||||
const applyExtract = (doc: DocKey, ex: DocExtract) => setOcrExtracts((p) => ({ ...p, [doc]: ex }));
|
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(
|
const kyc = useMemo(
|
||||||
() => crossCheck({ name: record?.lead_name, dob: record?.date_of_birth }, ocrExtracts),
|
() => crossCheck({ name: record?.lead_name, dob: record?.date_of_birth }, ocrExtracts),
|
||||||
[record, 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(() => {
|
useEffect(() => {
|
||||||
if (kyc.hasMismatch && !statusTouched.current) setDocStatus('mismatch');
|
if (kyc.hasMismatch && !statusTouched.current) setDocStatus('mismatch');
|
||||||
}, [kyc.hasMismatch]);
|
}, [kyc.hasMismatch]);
|
||||||
|
|
||||||
// OCR autofills its mapped target text field.
|
|
||||||
const applyAutofill = (target: string, value: unknown) => {
|
const applyAutofill = (target: string, value: unknown) => {
|
||||||
const v = value == null ? '' : String(value);
|
const v = value == null ? '' : String(value);
|
||||||
if (target === F.panNumber) setPan(v.toUpperCase());
|
if (target === F.panNumber) setPan(v.toUpperCase());
|
||||||
@ -190,20 +153,17 @@ export function DocumentsScreen() {
|
|||||||
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!record) return;
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
const activityId = ACT.uid;
|
const activityId = ACT.uid;
|
||||||
const instanceId = record.instance_id;
|
|
||||||
try {
|
try {
|
||||||
// Upload any picked files first; store the returned meta as a 1-item array.
|
|
||||||
const fileData: Record<string, unknown> = {};
|
const fileData: Record<string, unknown> = {};
|
||||||
for (const [fieldId, file] of Object.entries(files)) {
|
for (const [fieldId, file] of Object.entries(files)) {
|
||||||
if (!file) continue;
|
if (!file) continue;
|
||||||
const meta = await client.uploadFile(file, { activityId, fieldId, instanceId });
|
const meta = await client.uploadFile(file, { activityId, fieldId, instanceId });
|
||||||
fileData[fieldId] = [meta];
|
fileData[fieldId] = [meta];
|
||||||
}
|
}
|
||||||
await client.performActivity(instanceId, activityId, {
|
const res = await client.performActivity(instanceId, activityId, {
|
||||||
[F.panNumber]: pan,
|
[F.panNumber]: pan,
|
||||||
[F.aadhaarNumber]: aadhaar,
|
[F.aadhaarNumber]: aadhaar,
|
||||||
[F.verifiedIncome]: verifiedIncome,
|
[F.verifiedIncome]: verifiedIncome,
|
||||||
@ -211,7 +171,7 @@ export function DocumentsScreen() {
|
|||||||
...fileData,
|
...fileData,
|
||||||
});
|
});
|
||||||
setResult({ ok: true, msg: 'Documents submitted — lead advanced to Documents Collected.' });
|
setResult({ ok: true, msg: 'Documents submitted — lead advanced to Documents Collected.' });
|
||||||
refetch();
|
onSuccess?.(res);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') });
|
setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') });
|
||||||
} finally {
|
} finally {
|
||||||
@ -228,7 +188,7 @@ export function DocumentsScreen() {
|
|||||||
const completion = Math.round((checklist.filter((c) => c.done).length / checklist.length) * 100);
|
const completion = Math.round((checklist.filter((c) => c.done).length / checklist.length) * 100);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-[18px] max-w-[1180px] mx-auto items-start grid-cols-1 lg:grid-cols-[1fr_320px]">
|
<div className="grid gap-[18px] items-start grid-cols-1 lg:grid-cols-[1fr_320px]">
|
||||||
<div className="flex flex-col gap-[18px] min-w-0">
|
<div className="flex flex-col gap-[18px] min-w-0">
|
||||||
<div className="flex items-center gap-3 bg-card border border-border-subtle rounded-lg px-[18px] py-3.5 shadow-sm">
|
<div className="flex items-center gap-3 bg-card border border-border-subtle rounded-lg px-[18px] py-3.5 shadow-sm">
|
||||||
{record ? (
|
{record ? (
|
||||||
@ -242,23 +202,8 @@ export function DocumentsScreen() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 text-sm text-faint">Select a lead at the Product Recommended stage.</div>
|
<div className="flex-1 text-sm text-faint">No lead record provided.</div>
|
||||||
)}
|
)}
|
||||||
<SelectField
|
|
||||||
options={[
|
|
||||||
{ value: '', label: eligible.length ? 'Choose lead…' : 'No eligible leads' },
|
|
||||||
...records.map((r) => ({
|
|
||||||
value: String(r.instance_id),
|
|
||||||
label: `#${r.instance_id} ${r.lead_name ?? ''} · ${r.current_state_name ?? ''}`,
|
|
||||||
})),
|
|
||||||
]}
|
|
||||||
value={selectedId}
|
|
||||||
onChange={(v) => {
|
|
||||||
setSelectedId(v);
|
|
||||||
setResult(null);
|
|
||||||
}}
|
|
||||||
className="w-[260px]"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
@ -271,18 +216,9 @@ export function DocumentsScreen() {
|
|||||||
>
|
>
|
||||||
{result.ok && <CheckCircle2 size={16} />}
|
{result.ok && <CheckCircle2 size={16} />}
|
||||||
{result.msg}
|
{result.msg}
|
||||||
{result.ok && record && (
|
|
||||||
<button
|
|
||||||
className="ml-auto text-sm font-semibold text-link underline"
|
|
||||||
onClick={() => navigate(`/leads/${record.instance_id}`)}
|
|
||||||
>
|
|
||||||
View lead
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* KYC identity cross-check — OCR'd name + DOB vs the captured lead */}
|
|
||||||
{kyc.hasMismatch ? (
|
{kyc.hasMismatch ? (
|
||||||
<div className="flex items-start gap-2.5 text-sm text-amber-800 bg-escalated-soft border border-amber-300 rounded-md px-4 py-3">
|
<div className="flex items-start gap-2.5 text-sm text-amber-800 bg-escalated-soft border border-amber-300 rounded-md px-4 py-3">
|
||||||
<AlertTriangle size={16} className="text-amber-600 shrink-0 mt-0.5" />
|
<AlertTriangle size={16} className="text-amber-600 shrink-0 mt-0.5" />
|
||||||
@ -298,7 +234,7 @@ export function DocumentsScreen() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="mt-1.5 text-xs text-amber-700">
|
<div className="mt-1.5 text-xs text-amber-700">
|
||||||
Document status set to “Mismatch”. Override only once you’ve confirmed the documents belong to this lead.
|
Document status set to "Mismatch". Override only once you've confirmed the documents belong to this lead.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -309,13 +245,12 @@ export function DocumentsScreen() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* OCR document capture */}
|
|
||||||
<div className="grid grid-cols-2 gap-[18px]">
|
<div className="grid grid-cols-2 gap-[18px]">
|
||||||
<OcrCapture
|
<OcrCapture
|
||||||
ocr={OCR_FIELDS.pan}
|
ocr={OCR_FIELDS.pan}
|
||||||
docKey="pan"
|
docKey="pan"
|
||||||
activityId={ACT.uid}
|
activityId={ACT.uid}
|
||||||
instanceId={record?.instance_id}
|
instanceId={instanceId}
|
||||||
onAutofill={applyAutofill}
|
onAutofill={applyAutofill}
|
||||||
onFile={setFile(OCR_FIELDS.pan.id)}
|
onFile={setFile(OCR_FIELDS.pan.id)}
|
||||||
onExtract={applyExtract}
|
onExtract={applyExtract}
|
||||||
@ -324,7 +259,7 @@ export function DocumentsScreen() {
|
|||||||
ocr={OCR_FIELDS.aadhaar}
|
ocr={OCR_FIELDS.aadhaar}
|
||||||
docKey="aadhaar"
|
docKey="aadhaar"
|
||||||
activityId={ACT.uid}
|
activityId={ACT.uid}
|
||||||
instanceId={record?.instance_id}
|
instanceId={instanceId}
|
||||||
onAutofill={applyAutofill}
|
onAutofill={applyAutofill}
|
||||||
onFile={setFile(OCR_FIELDS.aadhaar.id)}
|
onFile={setFile(OCR_FIELDS.aadhaar.id)}
|
||||||
onExtract={applyExtract}
|
onExtract={applyExtract}
|
||||||
@ -354,7 +289,6 @@ export function DocumentsScreen() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Salary slip file upload */}
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<SalarySlipUpload value={files[F.salarySlip] ?? null} onChange={setFile(F.salarySlip)} />
|
<SalarySlipUpload value={files[F.salarySlip] ?? null} onChange={setFile(F.salarySlip)} />
|
||||||
</div>
|
</div>
|
||||||
@ -367,14 +301,13 @@ export function DocumentsScreen() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2.5 mt-5">
|
<div className="flex gap-2.5 mt-5">
|
||||||
<Button variant="primary" disabled={!record || submitting} onClick={submit}>
|
<Button variant="primary" disabled={submitting} onClick={submit}>
|
||||||
{submitting ? 'Submitting…' : 'Submit documents'}
|
{submitting ? 'Submitting…' : 'Submit documents'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RIGHT — checklist */}
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<section className="bg-card rounded-lg border border-border-subtle shadow-sm p-5">
|
<section className="bg-card rounded-lg border border-border-subtle shadow-sm p-5">
|
||||||
<h3 className="mt-0 mb-3.5 text-sm font-semibold text-strong">Document checklist</h3>
|
<h3 className="mt-0 mb-3.5 text-sm font-semibold text-strong">Document checklist</h3>
|
||||||
@ -1,11 +1,9 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
||||||
import { CheckCircle2 } from 'lucide-react';
|
import { CheckCircle2 } from 'lucide-react';
|
||||||
import { Avatar, Button, Card, formatINR, Input, SelectField } from '../components';
|
import { Button, Card, formatINR, Input } from '../';
|
||||||
import { useLeads } from '../api/leads';
|
import { useZino } from '../../api/provider';
|
||||||
import { useZino } from '../api/provider';
|
import { ACTIVITIES } from '../../api/config';
|
||||||
import { productOf } from '../api/adapters';
|
import type { ActivityBodyProps } from './types';
|
||||||
import { ACTIVITIES } from '../api/config';
|
|
||||||
|
|
||||||
const F = ACTIVITIES.ISSUE_POLICY.fields;
|
const F = ACTIVITIES.ISSUE_POLICY.fields;
|
||||||
|
|
||||||
@ -13,7 +11,6 @@ function todayISO(): string {
|
|||||||
return new Date().toISOString().slice(0, 10);
|
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 {
|
function addYears(iso: string, years: number): string {
|
||||||
if (!iso || !years) return '';
|
if (!iso || !years) return '';
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@ -22,30 +19,8 @@ function addYears(iso: string, years: number): string {
|
|||||||
return d.toISOString().slice(0, 10);
|
return d.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IssuePolicyScreen() {
|
export function IssuePolicyBody({ instanceId, record, onSuccess }: ActivityBodyProps) {
|
||||||
const { client } = useZino();
|
const { client } = useZino();
|
||||||
const navigate = useNavigate();
|
|
||||||
const [params] = useSearchParams();
|
|
||||||
const instanceParam = params.get('instance');
|
|
||||||
|
|
||||||
const { records, refetch } = useLeads();
|
|
||||||
|
|
||||||
// Issue Policy advances from Underwriting (after an approved verdict — Issuance Guard 947).
|
|
||||||
const eligible = useMemo(
|
|
||||||
() => records.filter((r) => r.current_state_name === 'Underwriting'),
|
|
||||||
[records],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [selectedId, setSelectedId] = useState<string>('');
|
|
||||||
useEffect(() => {
|
|
||||||
if (instanceParam) setSelectedId(instanceParam);
|
|
||||||
else if (!selectedId && eligible[0]) setSelectedId(String(eligible[0].instance_id));
|
|
||||||
}, [instanceParam, eligible, selectedId]);
|
|
||||||
|
|
||||||
const record = useMemo(
|
|
||||||
() => records.find((r) => String(r.instance_id) === selectedId),
|
|
||||||
[records, selectedId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [issueDate, setIssueDate] = useState(todayISO());
|
const [issueDate, setIssueDate] = useState(todayISO());
|
||||||
const [term, setTerm] = useState(20);
|
const [term, setTerm] = useState(20);
|
||||||
@ -58,7 +33,6 @@ export function IssuePolicyScreen() {
|
|||||||
setPremium(record.premium_amount || 0);
|
setPremium(record.premium_amount || 0);
|
||||||
}, [record]);
|
}, [record]);
|
||||||
|
|
||||||
// commencement = issue date; maturity = issue + term (computed client-side).
|
|
||||||
const commencement = issueDate;
|
const commencement = issueDate;
|
||||||
const maturity = useMemo(() => addYears(issueDate, term), [issueDate, term]);
|
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);
|
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!record) return;
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
try {
|
try {
|
||||||
// policy_number is backend-generated (id_gen POL-YYYY-####) — do NOT send it.
|
const res = await client.performActivity(instanceId, ACTIVITIES.ISSUE_POLICY.uid, {
|
||||||
await client.performActivity(record.instance_id, ACTIVITIES.ISSUE_POLICY.uid, {
|
|
||||||
[F.issueDate]: issueDate,
|
[F.issueDate]: issueDate,
|
||||||
[F.term]: term,
|
[F.term]: term,
|
||||||
[F.maturity]: maturity,
|
[F.maturity]: maturity,
|
||||||
[F.premium]: premium,
|
[F.premium]: premium,
|
||||||
});
|
});
|
||||||
setResult({ ok: true, msg: 'Policy issued — number generated automatically.' });
|
setResult({ ok: true, msg: 'Policy issued — number generated automatically.' });
|
||||||
refetch();
|
onSuccess?.(res);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setResult({
|
setResult({
|
||||||
ok: false,
|
ok: false,
|
||||||
@ -92,43 +64,8 @@ export function IssuePolicyScreen() {
|
|||||||
const lead = record;
|
const lead = record;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-[18px] max-w-[1140px] mx-auto items-start grid-cols-1 lg:grid-cols-[1fr_360px]">
|
<div className="grid gap-[18px] items-start grid-cols-1 lg:grid-cols-[1fr_360px]">
|
||||||
<div className="flex flex-col gap-[18px] min-w-0">
|
<div className="flex flex-col gap-[18px] min-w-0">
|
||||||
{/* context strip + lead picker */}
|
|
||||||
<div className="flex items-center gap-3 bg-card border border-border-subtle rounded-lg px-[18px] py-3.5 shadow-sm">
|
|
||||||
{lead ? (
|
|
||||||
<>
|
|
||||||
<Avatar name={lead.lead_name ?? `Lead ${lead.instance_id}`} size={40} />
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-base font-bold text-strong">
|
|
||||||
{lead.lead_name ?? `Lead ${lead.instance_id}`} · #{lead.instance_id}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-faint">
|
|
||||||
{lead.current_state_name ?? '—'} · {productOf(lead.recommended_product) ?? '—'} · SA ₹
|
|
||||||
{formatINR(lead.sum_assured ?? 0)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="flex-1 text-sm text-faint">Select a lead at the Underwriting stage.</div>
|
|
||||||
)}
|
|
||||||
<SelectField
|
|
||||||
options={[
|
|
||||||
{ value: '', label: eligible.length ? 'Choose lead…' : 'No eligible leads' },
|
|
||||||
...records.map((r) => ({
|
|
||||||
value: String(r.instance_id),
|
|
||||||
label: `#${r.instance_id} ${r.lead_name ?? ''} · ${r.current_state_name ?? ''}`,
|
|
||||||
})),
|
|
||||||
]}
|
|
||||||
value={selectedId}
|
|
||||||
onChange={(v) => {
|
|
||||||
setSelectedId(v);
|
|
||||||
setResult(null);
|
|
||||||
}}
|
|
||||||
className="w-[260px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
@ -139,14 +76,6 @@ export function IssuePolicyScreen() {
|
|||||||
>
|
>
|
||||||
{result.ok && <CheckCircle2 size={16} />}
|
{result.ok && <CheckCircle2 size={16} />}
|
||||||
{result.msg}
|
{result.msg}
|
||||||
{result.ok && lead && (
|
|
||||||
<button
|
|
||||||
className="ml-auto text-sm font-semibold text-link underline"
|
|
||||||
onClick={() => navigate(`/leads/${lead.instance_id}`)}
|
|
||||||
>
|
|
||||||
View lead
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -176,7 +105,7 @@ export function IssuePolicyScreen() {
|
|||||||
Policy number is auto-generated on issue (POL-YYYY-####).
|
Policy number is auto-generated on issue (POL-YYYY-####).
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2.5 mt-5">
|
<div className="flex gap-2.5 mt-5">
|
||||||
<Button variant="primary" disabled={!record || submitting} onClick={submit}>
|
<Button variant="primary" disabled={submitting} onClick={submit}>
|
||||||
{submitting ? 'Issuing…' : 'Issue policy'}
|
{submitting ? 'Issuing…' : 'Issue policy'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -1,9 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
||||||
import { CheckCircle2 } from 'lucide-react';
|
import { CheckCircle2 } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
AIDecisionCard,
|
AIDecisionCard,
|
||||||
Avatar,
|
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
formatINR,
|
formatINR,
|
||||||
@ -11,19 +9,18 @@ import {
|
|||||||
MultiSelect,
|
MultiSelect,
|
||||||
RupeeAmount,
|
RupeeAmount,
|
||||||
SelectField,
|
SelectField,
|
||||||
} from '../components';
|
} from '../';
|
||||||
import { LookupField } from '../components/form';
|
import { LookupField } from '../form';
|
||||||
import type { LookupValue } from '../components/form';
|
import type { LookupValue } from '../form';
|
||||||
import { useQuery, useZino } from '../api/provider';
|
import { useQuery, useZino } from '../../api/provider';
|
||||||
import { useLeads } from '../api/leads';
|
import { decisionToCard } from '../../api/adapters';
|
||||||
import { decisionToCard, regionOf } from '../api/adapters';
|
import { fieldFromSchema } from '../../api/schema';
|
||||||
import { fieldFromSchema } from '../api/schema';
|
import { ACTIVITIES } from '../../api/config';
|
||||||
import { ACTIVITIES } from '../api/config';
|
import type { ActivityBodyProps } from './types';
|
||||||
import type { LeadRecord } from '../api/types';
|
import type { LeadRecord } from '../../api/types';
|
||||||
|
|
||||||
const F = ACTIVITIES.RECOMMEND_PRODUCT.fields;
|
const F = ACTIVITIES.RECOMMEND_PRODUCT.fields;
|
||||||
|
|
||||||
// Crude indicative premium — rate per sum assured, adjusted by frequency.
|
|
||||||
function indicativePremium(sum: number, freq: string): number {
|
function indicativePremium(sum: number, freq: string): number {
|
||||||
const base = Math.round(sum * 0.00142);
|
const base = Math.round(sum * 0.00142);
|
||||||
switch (freq) {
|
switch (freq) {
|
||||||
@ -47,39 +44,14 @@ function toRiderValues(raw: LeadRecord['riders']): string[] {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RecommendScreen() {
|
export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyProps) {
|
||||||
const { client } = useZino();
|
const { client } = useZino();
|
||||||
const navigate = useNavigate();
|
|
||||||
const [params] = useSearchParams();
|
|
||||||
const instanceParam = params.get('instance');
|
|
||||||
|
|
||||||
const { records, refetch } = useLeads();
|
const schemaQ = useQuery(() => client.formSchema(ACTIVITIES.RECOMMEND_PRODUCT.uid, instanceId), [instanceId]);
|
||||||
|
|
||||||
// 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 productField = useMemo(() => fieldFromSchema(schemaQ.data, F.product), [schemaQ.data]);
|
const productField = useMemo(() => fieldFromSchema(schemaQ.data, F.product), [schemaQ.data]);
|
||||||
const freqOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.premiumFrequency)?.options ?? [], [schemaQ.data]);
|
const freqOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.premiumFrequency)?.options ?? [], [schemaQ.data]);
|
||||||
const riderOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.riders)?.options ?? [], [schemaQ.data]);
|
const riderOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.riders)?.options ?? [], [schemaQ.data]);
|
||||||
|
|
||||||
// Leads where Recommend Product is valid (state = Meeting Scheduled).
|
|
||||||
const eligible = useMemo(
|
|
||||||
() => records.filter((r) => r.current_state_name === 'Meeting Scheduled'),
|
|
||||||
[records],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [selectedId, setSelectedId] = useState<string>('');
|
|
||||||
useEffect(() => {
|
|
||||||
if (instanceParam) setSelectedId(instanceParam);
|
|
||||||
else if (!selectedId && eligible[0]) setSelectedId(String(eligible[0].instance_id));
|
|
||||||
}, [instanceParam, eligible, selectedId]);
|
|
||||||
|
|
||||||
const record = useMemo(
|
|
||||||
() => records.find((r) => String(r.instance_id) === selectedId),
|
|
||||||
[records, selectedId],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Form state — seeded from the instance's existing values when present.
|
|
||||||
const [product, setProduct] = useState<LookupValue | null>(null);
|
const [product, setProduct] = useState<LookupValue | null>(null);
|
||||||
const [sum, setSum] = useState(2000000);
|
const [sum, setSum] = useState(2000000);
|
||||||
const [freq, setFreq] = useState('annual');
|
const [freq, setFreq] = useState('annual');
|
||||||
@ -108,8 +80,9 @@ export function RecommendScreen() {
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
|
const incomeMultiple = record?.annual_income ? (sum / record.annual_income).toFixed(1) : '—';
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!record) return;
|
|
||||||
if (!product) {
|
if (!product) {
|
||||||
setResult({ ok: false, msg: 'Pick a product from the catalogue.' });
|
setResult({ ok: false, msg: 'Pick a product from the catalogue.' });
|
||||||
return;
|
return;
|
||||||
@ -117,7 +90,7 @@ export function RecommendScreen() {
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
try {
|
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.product]: product,
|
||||||
[F.sumAssured]: sum,
|
[F.sumAssured]: sum,
|
||||||
[F.premiumAmount]: effectivePremium,
|
[F.premiumAmount]: effectivePremium,
|
||||||
@ -126,7 +99,7 @@ export function RecommendScreen() {
|
|||||||
[F.notes]: notes,
|
[F.notes]: notes,
|
||||||
});
|
});
|
||||||
setResult({ ok: true, msg: 'Recommendation submitted — lead advanced to Product Recommended.' });
|
setResult({ ok: true, msg: 'Recommendation submitted — lead advanced to Product Recommended.' });
|
||||||
refetch();
|
onSuccess?.(res);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') });
|
setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') });
|
||||||
} finally {
|
} finally {
|
||||||
@ -134,47 +107,9 @@ export function RecommendScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const lead = record;
|
|
||||||
const incomeMultiple = lead?.annual_income ? (sum / lead.annual_income).toFixed(1) : '—';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-[18px] max-w-[1140px] mx-auto items-start grid-cols-1 lg:grid-cols-[1fr_360px]">
|
<div className="grid gap-[18px] items-start grid-cols-1 lg:grid-cols-[1fr_360px]">
|
||||||
<div className="flex flex-col gap-[18px] min-w-0">
|
<div className="flex flex-col gap-[18px] min-w-0">
|
||||||
{/* context strip + lead picker */}
|
|
||||||
<div className="flex items-center gap-3 bg-card border border-border-subtle rounded-lg px-[18px] py-3.5 shadow-sm">
|
|
||||||
{lead ? (
|
|
||||||
<>
|
|
||||||
<Avatar name={lead.lead_name ?? `Lead ${lead.instance_id}`} size={40} />
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-base font-bold text-strong">
|
|
||||||
{lead.lead_name ?? `Lead ${lead.instance_id}`} · #{lead.instance_id}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-faint">
|
|
||||||
{regionOf(lead.assigned_region) ?? lead.city ?? '—'} · {lead.product_interest ?? 'No stated interest'} · income ₹
|
|
||||||
{formatINR(lead.annual_income ?? 0)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="flex-1 text-sm text-faint">Select a lead at the Meeting Scheduled stage.</div>
|
|
||||||
)}
|
|
||||||
<SelectField
|
|
||||||
options={[
|
|
||||||
{ value: '', label: eligible.length ? 'Choose lead…' : 'No eligible leads' },
|
|
||||||
...records.map((r) => ({
|
|
||||||
value: String(r.instance_id),
|
|
||||||
label: `#${r.instance_id} ${r.lead_name ?? ''} · ${r.current_state_name ?? ''}`,
|
|
||||||
})),
|
|
||||||
]}
|
|
||||||
value={selectedId}
|
|
||||||
onChange={(v) => {
|
|
||||||
setSelectedId(v);
|
|
||||||
setResult(null);
|
|
||||||
}}
|
|
||||||
className="w-[260px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
@ -185,14 +120,6 @@ export function RecommendScreen() {
|
|||||||
>
|
>
|
||||||
{result.ok && <CheckCircle2 size={16} />}
|
{result.ok && <CheckCircle2 size={16} />}
|
||||||
{result.msg}
|
{result.msg}
|
||||||
{result.ok && lead && (
|
|
||||||
<button
|
|
||||||
className="ml-auto text-sm font-semibold text-link underline"
|
|
||||||
onClick={() => navigate(`/leads/${lead.instance_id}`)}
|
|
||||||
>
|
|
||||||
View lead
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -207,7 +134,7 @@ export function RecommendScreen() {
|
|||||||
storageCols={productField.lookupStorage ?? []}
|
storageCols={productField.lookupStorage ?? []}
|
||||||
activityId={ACTIVITIES.RECOMMEND_PRODUCT.uid}
|
activityId={ACTIVITIES.RECOMMEND_PRODUCT.uid}
|
||||||
fieldId={F.product}
|
fieldId={F.product}
|
||||||
instanceId={record?.instance_id}
|
instanceId={instanceId}
|
||||||
value={product}
|
value={product}
|
||||||
onChange={setProduct}
|
onChange={setProduct}
|
||||||
/>
|
/>
|
||||||
@ -258,14 +185,13 @@ export function RecommendScreen() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2.5 mt-5">
|
<div className="flex gap-2.5 mt-5">
|
||||||
<Button variant="primary" disabled={!record || submitting} onClick={submit}>
|
<Button variant="primary" disabled={submitting} onClick={submit}>
|
||||||
{submitting ? 'Submitting…' : 'Send recommendation'}
|
{submitting ? 'Submitting…' : 'Send recommendation'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RIGHT — indicative premium helper + AI suggestion */}
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="bg-navy-grad rounded-lg px-5 py-[22px] shadow-md">
|
<div className="bg-navy-grad rounded-lg px-5 py-[22px] shadow-md">
|
||||||
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy-muted mb-3.5">
|
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy-muted mb-3.5">
|
||||||
@ -286,16 +212,15 @@ export function RecommendScreen() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RecommendAiSuggestion instanceId={record?.instance_id} />
|
<RecommendAiSuggestion instanceId={instanceId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pull the latest AI decision for this lead, if any, as the suggestion card.
|
function RecommendAiSuggestion({ instanceId }: { instanceId: number | string }) {
|
||||||
function RecommendAiSuggestion({ instanceId }: { instanceId?: number }) {
|
|
||||||
const { client } = useZino();
|
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];
|
const latest = (q.data ?? [])[0];
|
||||||
if (!latest) return null;
|
if (!latest) return null;
|
||||||
const card = decisionToCard(latest);
|
const card = decisionToCard(latest);
|
||||||
@ -1,66 +1,39 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
||||||
import { CheckCircle2 } from 'lucide-react';
|
import { CheckCircle2 } from 'lucide-react';
|
||||||
import { Avatar, Button, Card, formatINR, SelectField } from '../components';
|
import { Button, Card, formatINR, SelectField } from '../';
|
||||||
import { useLeads } from '../api/leads';
|
import { useQuery, useZino } from '../../api/provider';
|
||||||
import { useQuery, useZino } from '../api/provider';
|
import { fieldFromSchema } from '../../api/schema';
|
||||||
import { fieldFromSchema } from '../api/schema';
|
import { ACTIVITIES } from '../../api/config';
|
||||||
import { ACTIVITIES } from '../api/config';
|
import { assess } from '../../lib/underwriting';
|
||||||
import { assess } from '../lib/underwriting';
|
import type { ActivityBodyProps } from './types';
|
||||||
|
|
||||||
const F = ACTIVITIES.SUBMIT_UNDERWRITING.fields;
|
const F = ACTIVITIES.SUBMIT_UNDERWRITING.fields;
|
||||||
|
|
||||||
export function UnderwritingScreen() {
|
export function UnderwritingBody({ instanceId, record, onSuccess }: ActivityBodyProps) {
|
||||||
const { client } = useZino();
|
const { client } = useZino();
|
||||||
const navigate = useNavigate();
|
|
||||||
const [params] = useSearchParams();
|
|
||||||
const instanceParam = params.get('instance');
|
|
||||||
|
|
||||||
const { records, refetch } = useLeads();
|
const schemaQ = useQuery(() => client.formSchema(ACTIVITIES.SUBMIT_UNDERWRITING.uid, instanceId), []);
|
||||||
|
|
||||||
// Status options from the live form schema, not hardcoded.
|
|
||||||
const schemaQ = useQuery(() => client.formSchema(ACTIVITIES.SUBMIT_UNDERWRITING.uid), []);
|
|
||||||
const statusOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.status)?.options ?? [], [schemaQ.data]);
|
const statusOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.status)?.options ?? [], [schemaQ.data]);
|
||||||
|
|
||||||
// Leads where Submit Underwriting is valid (state = Eligibility Assessed).
|
|
||||||
const eligible = useMemo(
|
|
||||||
() => records.filter((r) => r.current_state_name === 'Eligibility Assessed'),
|
|
||||||
[records],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [selectedId, setSelectedId] = useState<string>('');
|
|
||||||
useEffect(() => {
|
|
||||||
if (instanceParam) setSelectedId(instanceParam);
|
|
||||||
else if (!selectedId && eligible[0]) setSelectedId(String(eligible[0].instance_id));
|
|
||||||
}, [instanceParam, eligible, selectedId]);
|
|
||||||
|
|
||||||
const record = useMemo(
|
|
||||||
() => records.find((r) => String(r.instance_id) === selectedId),
|
|
||||||
[records, selectedId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [status, setStatus] = useState('approved');
|
const [status, setStatus] = useState('approved');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (record?.underwriting_status) setStatus(String(record.underwriting_status));
|
if (record?.underwriting_status) setStatus(String(record.underwriting_status));
|
||||||
}, [record]);
|
}, [record]);
|
||||||
|
|
||||||
// Decision support — computed client-side, recomputes whenever the lead changes.
|
|
||||||
const a = useMemo(() => (record ? assess(record) : null), [record]);
|
const a = useMemo(() => (record ? assess(record) : null), [record]);
|
||||||
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!record) return;
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
try {
|
try {
|
||||||
// underwriting_ref is backend-generated (id_gen UW-YYYY-####) — send only the verdict.
|
const res = await client.performActivity(instanceId, ACTIVITIES.SUBMIT_UNDERWRITING.uid, {
|
||||||
await client.performActivity(record.instance_id, ACTIVITIES.SUBMIT_UNDERWRITING.uid, {
|
|
||||||
[F.status]: status,
|
[F.status]: status,
|
||||||
});
|
});
|
||||||
setResult({ ok: true, msg: 'Underwriting verdict submitted.' });
|
setResult({ ok: true, msg: 'Underwriting verdict submitted.' });
|
||||||
refetch();
|
onSuccess?.(res);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setResult({
|
setResult({
|
||||||
ok: false,
|
ok: false,
|
||||||
@ -74,43 +47,8 @@ export function UnderwritingScreen() {
|
|||||||
const lead = record;
|
const lead = record;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-[18px] max-w-[1140px] mx-auto items-start grid-cols-1 lg:grid-cols-[1fr_360px]">
|
<div className="grid gap-[18px] items-start grid-cols-1 lg:grid-cols-[1fr_360px]">
|
||||||
<div className="flex flex-col gap-[18px] min-w-0">
|
<div className="flex flex-col gap-[18px] min-w-0">
|
||||||
{/* context strip + lead picker */}
|
|
||||||
<div className="flex items-center gap-3 bg-card border border-border-subtle rounded-lg px-[18px] py-3.5 shadow-sm">
|
|
||||||
{lead ? (
|
|
||||||
<>
|
|
||||||
<Avatar name={lead.lead_name ?? `Lead ${lead.instance_id}`} size={40} />
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-base font-bold text-strong">
|
|
||||||
{lead.lead_name ?? `Lead ${lead.instance_id}`} · #{lead.instance_id}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-faint">
|
|
||||||
{lead.current_state_name ?? '—'} · eligibility {lead.eligibility_status ?? '—'} · SA ₹
|
|
||||||
{formatINR(lead.sum_assured ?? 0)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="flex-1 text-sm text-faint">Select a lead at the Eligibility Assessed stage.</div>
|
|
||||||
)}
|
|
||||||
<SelectField
|
|
||||||
options={[
|
|
||||||
{ value: '', label: eligible.length ? 'Choose lead…' : 'No eligible leads' },
|
|
||||||
...records.map((r) => ({
|
|
||||||
value: String(r.instance_id),
|
|
||||||
label: `#${r.instance_id} ${r.lead_name ?? ''} · ${r.current_state_name ?? ''}`,
|
|
||||||
})),
|
|
||||||
]}
|
|
||||||
value={selectedId}
|
|
||||||
onChange={(v) => {
|
|
||||||
setSelectedId(v);
|
|
||||||
setResult(null);
|
|
||||||
}}
|
|
||||||
className="w-[260px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
@ -121,14 +59,6 @@ export function UnderwritingScreen() {
|
|||||||
>
|
>
|
||||||
{result.ok && <CheckCircle2 size={16} />}
|
{result.ok && <CheckCircle2 size={16} />}
|
||||||
{result.msg}
|
{result.msg}
|
||||||
{result.ok && lead && (
|
|
||||||
<button
|
|
||||||
className="ml-auto text-sm font-semibold text-link underline"
|
|
||||||
onClick={() => navigate(`/leads/${lead.instance_id}`)}
|
|
||||||
>
|
|
||||||
View lead
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -150,14 +80,13 @@ export function UnderwritingScreen() {
|
|||||||
Underwriting reference is auto-generated on submit (UW-YYYY-####).
|
Underwriting reference is auto-generated on submit (UW-YYYY-####).
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2.5 mt-5">
|
<div className="flex gap-2.5 mt-5">
|
||||||
<Button variant="primary" disabled={!record || submitting} onClick={submit}>
|
<Button variant="primary" disabled={submitting} onClick={submit}>
|
||||||
{submitting ? 'Submitting…' : 'Record verdict'}
|
{submitting ? 'Submitting…' : 'Record verdict'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RIGHT — auto-computed assessment, visible on open */}
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="bg-navy-grad rounded-lg px-5 py-[22px] shadow-md">
|
<div className="bg-navy-grad rounded-lg px-5 py-[22px] shadow-md">
|
||||||
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy-muted mb-3.5">
|
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy-muted mb-3.5">
|
||||||
@ -212,7 +141,7 @@ export function UnderwritingScreen() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-on-navy-muted">
|
<div className="text-sm text-on-navy-muted">
|
||||||
{record ? 'Not enough data (DOB / income / sum assured) to assess.' : 'Select a lead at Eligibility Assessed.'}
|
{record ? 'Not enough data (DOB / income / sum assured) to assess.' : 'Not enough data to assess.'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
123
src/components/activities/Worklist.tsx
Normal file
123
src/components/activities/Worklist.tsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Card, KpiCard, LeadRow, LEAD_GRID, Pagination } from '../';
|
||||||
|
import { LeadFilters } from '../LeadFilters';
|
||||||
|
import { cn } from '../../lib/cn';
|
||||||
|
import { useLeads } from '../../api/leads';
|
||||||
|
import { recordToLead } from '../../api/adapters';
|
||||||
|
import { applyFilters, EMPTY_FILTER, type FilterState } from '../../api/filters';
|
||||||
|
import type { LeadRecord } from '../../api/types';
|
||||||
|
import { LeadActions, AddLeadButton } from './ActivityActions';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
/** A compact stat tile computed over this screen's (already state-filtered) rows. */
|
||||||
|
export interface TileSpec {
|
||||||
|
label: string;
|
||||||
|
value: (rows: LeadRecord[]) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorklistProps {
|
||||||
|
/** Lifecycle states this screen lists (in display order). */
|
||||||
|
states: readonly string[];
|
||||||
|
emptyHint?: string;
|
||||||
|
showAddLead?: boolean;
|
||||||
|
/** A small set of stat tiles shown above the table (keep it to 2-4). */
|
||||||
|
tiles?: TileSpec[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A state-scoped lead worklist styled like the Lead Pipeline screen: a KPI row
|
||||||
|
* + the shared leads table (LeadRow), with each row's one primary action
|
||||||
|
* (+ Disqualify) in its action cell, launched in a modal. */
|
||||||
|
export function Worklist({ states, emptyHint, showAddLead, tiles }: WorklistProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { records, fields, loading, refetch } = useLeads();
|
||||||
|
const [filters, setFilters] = useState<FilterState>(EMPTY_FILTER);
|
||||||
|
|
||||||
|
const order = useMemo(() => new Map(states.map((s, i) => [s, i])), [states]);
|
||||||
|
// Scope to this screen's states, then apply the search/filter bar.
|
||||||
|
const scoped = useMemo(
|
||||||
|
() =>
|
||||||
|
records
|
||||||
|
.filter((r) => order.has(r.current_state_name ?? ''))
|
||||||
|
.sort((a, b) => order.get(a.current_state_name ?? '')! - order.get(b.current_state_name ?? '')!),
|
||||||
|
[records, order],
|
||||||
|
);
|
||||||
|
const recs = useMemo(() => applyFilters(scoped, filters, fields), [scoped, filters, fields]);
|
||||||
|
const rows = useMemo(() => recs.map((record) => ({ record, lead: recordToLead(record) })), [recs]);
|
||||||
|
|
||||||
|
// Client-side pagination over the filtered rows (all leads are already loaded
|
||||||
|
// via useLeads). Page is clamped so it stays valid after rows shrink.
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
// Snap back to the first page whenever the filter set changes.
|
||||||
|
useEffect(() => setPage(1), [filters]);
|
||||||
|
const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
|
||||||
|
const safePage = Math.min(page, totalPages);
|
||||||
|
const start = (safePage - 1) * PAGE_SIZE;
|
||||||
|
const pageRows = rows.slice(start, start + PAGE_SIZE);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-[1240px] flex-col gap-5">
|
||||||
|
{/* KPI row — Pipeline-screen tiles, width-capped so a couple don't stretch huge. */}
|
||||||
|
{tiles?.length ? (
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:flex sm:flex-wrap">
|
||||||
|
{tiles.map((t) => (
|
||||||
|
<KpiCard key={t.label} label={t.label} value={t.value(recs)} className="sm:w-[240px]" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Leads — shared table, with a per-row action cell. */}
|
||||||
|
<Card
|
||||||
|
title="Leads in queue"
|
||||||
|
pad={false}
|
||||||
|
action={showAddLead ? <AddLeadButton onDone={refetch} /> : undefined}
|
||||||
|
>
|
||||||
|
<LeadFilters
|
||||||
|
records={scoped}
|
||||||
|
fields={fields}
|
||||||
|
value={filters}
|
||||||
|
onChange={setFilters}
|
||||||
|
excludeKeys={['current_state_name']}
|
||||||
|
/>
|
||||||
|
{loading && !rows.length ? (
|
||||||
|
<div className="p-10 text-center text-sm text-faint">Loading leads…</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="min-w-[820px]">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'grid gap-3 px-[18px] py-[11px] bg-slate-50 border-b border-border-subtle text-[10px] font-bold uppercase tracking-[0.06em] text-faint',
|
||||||
|
LEAD_GRID,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>Lead</span>
|
||||||
|
<span>Score</span>
|
||||||
|
<span>Segment</span>
|
||||||
|
<span>Channel</span>
|
||||||
|
<span>Owner</span>
|
||||||
|
<span className="text-right">Action</span>
|
||||||
|
</div>
|
||||||
|
{rows.length ? (
|
||||||
|
pageRows.map(({ record, lead }) => (
|
||||||
|
<LeadRow
|
||||||
|
key={record.instance_id}
|
||||||
|
lead={lead}
|
||||||
|
onClick={() => navigate(`/leads/${record.instance_id}`)}
|
||||||
|
action={<LeadActions record={record} onDone={refetch} />}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="p-10 text-center text-sm text-faint">
|
||||||
|
{scoped.length ? 'No leads match these filters.' : emptyHint ?? 'No leads at this stage.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Pagination page={safePage} pageSize={PAGE_SIZE} total={rows.length} onPage={setPage} />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
src/components/activities/index.ts
Normal file
6
src/components/activities/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export { Worklist } from './Worklist';
|
||||||
|
export type { WorklistProps, TileSpec } from './Worklist';
|
||||||
|
export { LeadActions, AddLeadButton } from './ActivityActions';
|
||||||
|
export { STEP_BY_STATE, DISQUALIFY_STEP, CAPTURE_STEP } from './registry';
|
||||||
|
export type { ActivityStep } from './registry';
|
||||||
|
export type { ActivityBodyProps } from './types';
|
||||||
55
src/components/activities/registry.ts
Normal file
55
src/components/activities/registry.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
// Per-state action registry. Maps a lead's current_state_name → the single
|
||||||
|
// primary activity available on it: button label, modal title/width, and the
|
||||||
|
// body to render. Bespoke steps render a hand-built *Body; generic steps fall
|
||||||
|
// back to <ActivityForm> driven by the live schema (meta carries the uid).
|
||||||
|
|
||||||
|
import type { ComponentType } from 'react';
|
||||||
|
import type { ModalWidth } from '../core/Modal';
|
||||||
|
import { ACTIVITY_META, type ActivityMeta } from '../../api/forms';
|
||||||
|
import type { ActivityBodyProps } from './types';
|
||||||
|
import { AssignBody } from './AssignBody';
|
||||||
|
import { RecommendBody } from './RecommendBody';
|
||||||
|
import { DocumentsBody } from './DocumentsBody';
|
||||||
|
import { UnderwritingBody } from './UnderwritingBody';
|
||||||
|
import { IssuePolicyBody } from './IssuePolicyBody';
|
||||||
|
|
||||||
|
export interface ActivityStep {
|
||||||
|
/** Row button + modal title copy. */
|
||||||
|
buttonLabel: string;
|
||||||
|
title: string;
|
||||||
|
width: ModalWidth;
|
||||||
|
/** Bespoke body — when set, rendered instead of the generic ActivityForm. */
|
||||||
|
Body?: ComponentType<ActivityBodyProps>;
|
||||||
|
/** Generic path — fed to <ActivityForm>. Carries the activity uid. */
|
||||||
|
meta?: ActivityMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one primary action per lifecycle state. Absent state = no primary action. */
|
||||||
|
export const STEP_BY_STATE: Record<string, ActivityStep> = {
|
||||||
|
'New Lead': { buttonLabel: 'Qualify', title: 'Qualify Lead', width: 'md', meta: ACTIVITY_META.qualify },
|
||||||
|
Qualified: { buttonLabel: 'Assign', title: 'Assign Lead', width: 'lg', Body: AssignBody },
|
||||||
|
Assigned: { buttonLabel: 'Log Contact', title: 'Log First Contact', width: 'md', meta: ACTIVITY_META.contact },
|
||||||
|
Contacted: { buttonLabel: 'Schedule', title: 'Schedule Meeting', width: 'md', meta: ACTIVITY_META.schedule },
|
||||||
|
'Meeting Scheduled': { buttonLabel: 'Recommend', title: 'Recommend Product', width: 'xl', Body: RecommendBody },
|
||||||
|
'Product Recommended': { buttonLabel: 'Collect Docs', title: 'Collect Documents', width: 'xl', Body: DocumentsBody },
|
||||||
|
'Documents Collected': { buttonLabel: 'Assess', title: 'Assess Eligibility', width: 'md', meta: ACTIVITY_META.assess },
|
||||||
|
'Eligibility Assessed': { buttonLabel: 'Underwrite', title: 'Submit Underwriting', width: 'lg', Body: UnderwritingBody },
|
||||||
|
Underwriting: { buttonLabel: 'Issue Policy', title: 'Issue Policy', width: 'lg', Body: IssuePolicyBody },
|
||||||
|
'Policy Issued': { buttonLabel: 'Onboard', title: 'Onboard Customer', width: 'md', meta: ACTIVITY_META.onboard },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Secondary action — Disqualify, valid from any non-terminal pre-issuance state. */
|
||||||
|
export const DISQUALIFY_STEP: ActivityStep = {
|
||||||
|
buttonLabel: 'Disqualify',
|
||||||
|
title: 'Disqualify Lead',
|
||||||
|
width: 'md',
|
||||||
|
meta: ACTIVITY_META.disqualify,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** INIT action — Add Lead (Capture), submitted via /start (no instance yet). */
|
||||||
|
export const CAPTURE_STEP: ActivityStep = {
|
||||||
|
buttonLabel: 'Add Lead',
|
||||||
|
title: 'Capture Lead',
|
||||||
|
width: 'lg',
|
||||||
|
meta: ACTIVITY_META.capture,
|
||||||
|
};
|
||||||
14
src/components/activities/types.ts
Normal file
14
src/components/activities/types.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import type { ActivityResult, LeadRecord } from '../../api/types';
|
||||||
|
|
||||||
|
/** Contract every activity body conforms to. A body renders ONE activity's
|
||||||
|
* form (bespoke layout, fields fed from the live schema), submits via
|
||||||
|
* performActivity for the given instance, and calls onSuccess on success.
|
||||||
|
* It owns NO page chrome, lead-picker, or routing — the Modal + list screen do.
|
||||||
|
*/
|
||||||
|
export interface ActivityBodyProps {
|
||||||
|
instanceId: number | string;
|
||||||
|
/** Seed values from the row's recordview data. */
|
||||||
|
record?: LeadRecord | null;
|
||||||
|
/** Fired after a successful submit — the host closes the modal + refetches. */
|
||||||
|
onSuccess?: (res: ActivityResult) => void;
|
||||||
|
}
|
||||||
77
src/components/core/Modal.tsx
Normal file
77
src/components/core/Modal.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { useEffect, type ReactNode } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { cn } from '../../lib/cn';
|
||||||
|
|
||||||
|
export type ModalWidth = 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
|
||||||
|
const WIDTH: Record<ModalWidth, string> = {
|
||||||
|
sm: 'max-w-[480px]',
|
||||||
|
md: 'max-w-[640px]',
|
||||||
|
lg: 'max-w-[820px]',
|
||||||
|
xl: 'max-w-[1000px]',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: ReactNode;
|
||||||
|
subtitle?: ReactNode;
|
||||||
|
/** @default "md" */
|
||||||
|
width?: ModalWidth;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Portal modal host — backdrop, Esc / click-out close, scroll-locked body.
|
||||||
|
* Aria's equivalent of the renderer's FormPopup; hosts an activity body. */
|
||||||
|
export function Modal({ open, onClose, title, subtitle, width = 'md', children }: ModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
};
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[10000] flex items-start justify-center overflow-y-auto bg-black/40 p-4 sm:p-8"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
className={cn(
|
||||||
|
'w-full bg-card rounded-lg border border-border-subtle shadow-lg my-auto',
|
||||||
|
WIDTH[width],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<header className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border-subtle">
|
||||||
|
<div className="min-w-0">
|
||||||
|
{title && <h3 className="m-0 text-md font-semibold text-strong truncate">{title}</h3>}
|
||||||
|
{subtitle && <div className="text-xs text-faint mt-0.5">{subtitle}</div>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
className="shrink-0 -mr-1 -mt-1 p-1.5 rounded-md text-faint hover:text-strong hover:bg-black/5"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className="p-5">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/components/core/Pagination.tsx
Normal file
41
src/components/core/Pagination.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { Button } from './Button';
|
||||||
|
|
||||||
|
export interface PaginationProps {
|
||||||
|
/** 1-based current page. */
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
/** Total rows across all pages (already filtered). */
|
||||||
|
total: number;
|
||||||
|
onPage: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client-side pager: "Showing a–b of N" + Prev/Next. Renders nothing when the
|
||||||
|
* set fits on one page. Shared by the Pipeline table and the worklists. */
|
||||||
|
export function Pagination({ page, pageSize, total, onPage }: PaginationProps) {
|
||||||
|
if (total <= pageSize) return null;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const safePage = Math.min(Math.max(page, 1), totalPages);
|
||||||
|
const start = (safePage - 1) * pageSize;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3 border-t border-border-subtle px-[18px] py-3">
|
||||||
|
<span className="text-xs text-faint">
|
||||||
|
Showing {start + 1}–{Math.min(start + pageSize, total)} of {total}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="secondary" size="sm" disabled={safePage <= 1} onClick={() => onPage(safePage - 1)}>
|
||||||
|
<ChevronLeft size={15} />
|
||||||
|
Prev
|
||||||
|
</Button>
|
||||||
|
<span className="px-1 text-xs font-medium text-muted nums">
|
||||||
|
{safePage} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button variant="secondary" size="sm" disabled={safePage >= totalPages} onClick={() => onPage(safePage + 1)}>
|
||||||
|
Next
|
||||||
|
<ChevronRight size={15} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -139,7 +139,7 @@ export function PickerShell<T>({
|
|||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
style={{ position: 'fixed', top: pos.top, left: pos.left, width: pos.width, zIndex: 50 }}
|
style={{ position: 'fixed', top: pos.top, left: pos.left, width: pos.width, zIndex: 10050 }}
|
||||||
className="bg-card border border-border-default rounded-md shadow-lg overflow-hidden"
|
className="bg-card border border-border-default rounded-md shadow-lg overflow-hidden"
|
||||||
>
|
>
|
||||||
{searchable && (
|
{searchable && (
|
||||||
|
|||||||
@ -16,3 +16,7 @@ export { PickerShell } from './PickerShell';
|
|||||||
export type { PickerShellProps } from './PickerShell';
|
export type { PickerShellProps } from './PickerShell';
|
||||||
export { MultiSelect } from './MultiSelect';
|
export { MultiSelect } from './MultiSelect';
|
||||||
export type { MultiSelectProps, MultiSelectOption } 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';
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { CheckCircle2 } from 'lucide-react';
|
import { CheckCircle2, Lock } from 'lucide-react';
|
||||||
import { Button } from '../core/Button';
|
import { Button } from '../core/Button';
|
||||||
import { Input } from '../core/Input';
|
import { Input } from '../core/Input';
|
||||||
import { SelectField } from '../core/SelectField';
|
import { SelectField } from '../core/SelectField';
|
||||||
@ -50,6 +50,12 @@ function seedFor(field: FieldDef, record?: LeadRecord | null): unknown {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True for a backend RBAC denial — so we can show a human line, not the raw
|
||||||
|
* "user N does not have permission for activity <uuid>". */
|
||||||
|
export function isPermissionError(msg?: string | null): boolean {
|
||||||
|
return !!msg && /permission|not authoriz|unauthoriz|forbidden|\b403\b/i.test(msg);
|
||||||
|
}
|
||||||
|
|
||||||
/** Renders an activity's fields from the live form-screens schema and submits
|
/** Renders an activity's fields from the live form-screens schema and submits
|
||||||
* via performActivity (or startInstance when no instanceId is given). */
|
* via performActivity (or startInstance when no instanceId is given). */
|
||||||
export function ActivityForm({ meta, instanceId, record, onSuccess, successMessage }: ActivityFormProps) {
|
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}.` });
|
setResult({ ok: true, msg: successMessage ?? `${meta.name} ${meta.done}.` });
|
||||||
onSuccess?.(res);
|
onSuccess?.(res);
|
||||||
} catch (e) {
|
} 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 {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@ -124,6 +134,19 @@ export function ActivityForm({ meta, instanceId, record, onSuccess, successMessa
|
|||||||
return <div className="py-6 text-sm text-faint">Loading form…</div>;
|
return <div className="py-6 text-sm text-faint">Loading form…</div>;
|
||||||
}
|
}
|
||||||
if (schemaQ.error || !fields.length) {
|
if (schemaQ.error || !fields.length) {
|
||||||
|
if (isPermissionError(schemaQ.error)) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2.5 rounded-md border border-amber-200 bg-amber-50 px-4 py-3.5 text-sm text-amber-800">
|
||||||
|
<Lock size={16} className="mt-0.5 shrink-0 text-amber-600" />
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold">You don’t have permission for this action</div>
|
||||||
|
<div className="mt-0.5 text-xs text-amber-700">
|
||||||
|
Your role can’t {meta.name.toLowerCase()}. Ask an admin to grant your role access to this activity.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="py-4 text-sm text-amber-700">
|
<div className="py-4 text-sm text-amber-700">
|
||||||
{schemaQ.error ? `Couldn't load the form: ${schemaQ.error}` : 'This activity has no input fields.'}
|
{schemaQ.error ? `Couldn't load the form: ${schemaQ.error}` : 'This activity has no input fields.'}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
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 { cn } from '../../lib/cn';
|
||||||
import { Avatar } from '../core/Avatar';
|
import { Avatar } from '../core/Avatar';
|
||||||
import { ReasoningBody } from './ReasoningBody';
|
import { ReasoningBody } from './ReasoningBody';
|
||||||
@ -8,6 +8,8 @@ import type { Decision } from '../../types';
|
|||||||
export interface AIDecisionStreamProps {
|
export interface AIDecisionStreamProps {
|
||||||
decisions: Decision[];
|
decisions: Decision[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
/** Manual refresh handler. Shown as a refresh button in the header. */
|
||||||
|
onRefresh?: () => void;
|
||||||
/** Autonomy threshold. @default 0.7 */
|
/** Autonomy threshold. @default 0.7 */
|
||||||
threshold?: number;
|
threshold?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -22,6 +24,7 @@ export interface AIDecisionStreamProps {
|
|||||||
export function AIDecisionStream({
|
export function AIDecisionStream({
|
||||||
decisions,
|
decisions,
|
||||||
loading = false,
|
loading = false,
|
||||||
|
onRefresh,
|
||||||
threshold = 0.7,
|
threshold = 0.7,
|
||||||
className,
|
className,
|
||||||
}: AIDecisionStreamProps) {
|
}: AIDecisionStreamProps) {
|
||||||
@ -33,9 +36,23 @@ export function AIDecisionStream({
|
|||||||
<div className={cn('flex flex-col gap-3.5', className)}>
|
<div className={cn('flex flex-col gap-3.5', className)}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="m-0 text-sm font-bold text-strong">AI decision stream</h3>
|
<h3 className="m-0 text-sm font-bold text-strong">AI decision stream</h3>
|
||||||
<span className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy bg-navy-900 px-2 py-1 rounded-pill">
|
<div className="flex items-center gap-2">
|
||||||
{decisions.length} action{decisions.length === 1 ? '' : 's'}
|
<span className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy bg-navy-900 px-2 py-1 rounded-pill">
|
||||||
</span>
|
{decisions.length} action{decisions.length === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
{onRefresh && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
title="Refresh AI decisions"
|
||||||
|
aria-label="Refresh AI decisions"
|
||||||
|
className="w-7 h-7 rounded-md border border-border-subtle bg-card cursor-pointer text-muted hover:text-strong disabled:opacity-50 flex items-center justify-center shrink-0"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} className={cn(loading && 'animate-spin')} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{escalations.length > 0 && (
|
{escalations.length > 0 && (
|
||||||
@ -52,7 +69,11 @@ export function AIDecisionStream({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading && <div className="text-sm text-faint">Loading decisions…</div>}
|
{/* Placeholder only on first load; a manual refresh keeps the list visible
|
||||||
|
(the header button spins) so the stream never blanks out. */}
|
||||||
|
{loading && decisions.length === 0 && (
|
||||||
|
<div className="text-sm text-faint">Loading decisions…</div>
|
||||||
|
)}
|
||||||
{!loading && decisions.length === 0 && (
|
{!loading && decisions.length === 0 && (
|
||||||
<div className="text-sm text-faint">No AI decisions for this lead yet.</div>
|
<div className="text-sm text-faint">No AI decisions for this lead yet.</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
import { cn } from '../../lib/cn';
|
import { cn } from '../../lib/cn';
|
||||||
import type { Lead, Tone } from '../../types';
|
import type { Lead, Tone } from '../../types';
|
||||||
import { Avatar } from '../core/Avatar';
|
import { Avatar } from '../core/Avatar';
|
||||||
@ -8,6 +9,9 @@ export interface LeadRowProps {
|
|||||||
lead: Lead;
|
lead: Lead;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
className?: string;
|
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. */
|
/** 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)';
|
return 'var(--slate-500)';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Leads-list table row — name, score, segment, channel, owner, last AI action. */
|
/** Leads-list table row — name, score, segment, channel, owner, last AI action
|
||||||
export function LeadRow({ lead, onClick, className }: LeadRowProps) {
|
* (or a custom `action` cell). */
|
||||||
|
export function LeadRow({ lead, onClick, className, action }: LeadRowProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@ -64,11 +69,17 @@ export function LeadRow({ lead, onClick, className }: LeadRowProps) {
|
|||||||
<Avatar name={lead.owner} ai={lead.ownerAi} size={24} />
|
<Avatar name={lead.owner} ai={lead.ownerAi} size={24} />
|
||||||
<span className="text-sm text-body truncate">{lead.owner}</span>
|
<span className="text-sm text-body truncate">{lead.owner}</span>
|
||||||
</div>
|
</div>
|
||||||
{/* last AI action */}
|
{/* last AI action — or a custom action cell */}
|
||||||
<div className="flex items-center gap-[7px] min-w-0">
|
{action !== undefined ? (
|
||||||
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0', DOT[lead.lastTone])} />
|
<div className="flex justify-end" onClick={(e) => e.stopPropagation()}>
|
||||||
<span className="text-sm text-muted truncate">{lead.lastAction}</span>
|
{action}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-[7px] min-w-0">
|
||||||
|
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0', DOT[lead.lastTone])} />
|
||||||
|
<span className="text-sm text-muted truncate">{lead.lastAction}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,11 +19,14 @@ export function WorkflowStepper({
|
|||||||
className,
|
className,
|
||||||
}: WorkflowStepperProps) {
|
}: WorkflowStepperProps) {
|
||||||
const vertical = orientation === 'vertical';
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex font-sans',
|
'flex font-sans',
|
||||||
vertical ? 'flex-col items-stretch' : 'flex-row items-center',
|
vertical ? 'flex-col items-stretch' : 'flex-row items-start',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -33,16 +36,18 @@ export function WorkflowStepper({
|
|||||||
const isLast = i === stages.length - 1;
|
const isLast = i === stages.length - 1;
|
||||||
return (
|
return (
|
||||||
<Fragment key={s}>
|
<Fragment key={s}>
|
||||||
<div className={cn('flex relative', vertical ? 'flex-row items-center gap-3' : 'flex-col items-center gap-2')}>
|
<div className={cn('flex shrink-0', vertical ? 'flex-row items-center gap-3' : 'flex-col items-center gap-2')}>
|
||||||
<span
|
<span className={cn('flex items-center justify-center', vertical ? '' : 'h-7')}>
|
||||||
className={cn(
|
<span
|
||||||
'rounded-full shrink-0 flex items-center justify-center text-xs font-bold transition-all duration-200',
|
className={cn(
|
||||||
active && 'w-7 h-7 bg-sunrise text-white shadow-sunrise',
|
'rounded-full shrink-0 flex items-center justify-center text-xs font-bold transition-all duration-200',
|
||||||
done && 'w-[22px] h-[22px] bg-emerald-600 text-white',
|
active && 'w-7 h-7 bg-sunrise text-white shadow-sunrise',
|
||||||
!done && !active && 'w-[22px] h-[22px] bg-card text-slate-500 border-2 border-slate-300',
|
done && 'w-[22px] h-[22px] bg-emerald-600 text-white',
|
||||||
)}
|
!done && !active && 'w-[22px] h-[22px] bg-card text-slate-500 border-2 border-slate-300',
|
||||||
>
|
)}
|
||||||
{done ? <Check size={13} strokeWidth={3} /> : i + 1}
|
>
|
||||||
|
{done ? <Check size={13} strokeWidth={3} /> : i + 1}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -58,7 +63,7 @@ export function WorkflowStepper({
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
i < current ? 'bg-emerald-600' : 'bg-slate-200',
|
i < current ? 'bg-emerald-600' : 'bg-slate-200',
|
||||||
vertical ? 'w-0.5 h-[18px] ml-[10px]' : 'flex-1 h-0.5 min-w-4 mt-[11px]',
|
vertical ? 'w-0.5 h-[18px] ml-[10px]' : 'flex-1 h-0.5 min-w-4 mt-[13px]',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
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 { cn } from '../lib/cn';
|
||||||
import { Avatar } from '../components/core';
|
import { Avatar } from '../components/core';
|
||||||
import { AI_EMPLOYEES } from '../api/config';
|
import { canSeeScreen } from '../api/config';
|
||||||
import { useZino } from '../api/provider';
|
import { useZino } from '../api/provider';
|
||||||
import { useLeads } from '../api/leads';
|
import { useLeads } from '../api/leads';
|
||||||
import type { Lead } from '../types';
|
import type { Lead } from '../types';
|
||||||
@ -19,29 +19,13 @@ interface NavItem {
|
|||||||
|
|
||||||
const NAV: NavItem[] = [
|
const NAV: NavItem[] = [
|
||||||
{ to: '/pipeline', label: 'Lead Pipeline', Icon: Users, title: 'Lead pipeline', subtitle: '' },
|
{ 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: '/schedule', label: 'Agent Schedule', Icon: CalendarClock, title: 'Agent schedule', subtitle: 'Sales-agent meetings & booking slots' },
|
||||||
{ to: '/recommend', label: 'Recommend', Icon: Sparkles, title: 'Recommend product', subtitle: 'Build and send a product recommendation' },
|
{ to: '/qualify', label: 'Qualify & Assign', Icon: UserCheck, title: 'Qualification & Assignment', subtitle: 'Qualify new leads and assign them to an agent' },
|
||||||
{ to: '/documents', label: 'Documents', Icon: FileText, title: 'Document collection', subtitle: 'KYC capture with OCR auto-extraction' },
|
{ to: '/engage', label: 'Customer Interaction', Icon: Sparkles, title: 'Customer Interaction', subtitle: 'Log contact, schedule meetings, recommend products' },
|
||||||
{ to: '/underwriting', label: 'Underwriting', Icon: ShieldCheck, title: 'Underwriting', subtitle: 'Risk assessment & verdict' },
|
{ to: '/documents', label: 'Documents', Icon: FileText, title: 'Document & Assessment', subtitle: 'KYC collection, OCR, eligibility assessment' },
|
||||||
{ to: '/issue', label: 'Issue Policy', Icon: FileSignature, title: 'Issue policy', subtitle: 'Generate policy number & dates' },
|
{ 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".
|
// Pipeline nav subtitle, computed live: "N leads · X% hot · Y% AI-handled".
|
||||||
function pipelineSubtitle(leads: Lead[]): string {
|
function pipelineSubtitle(leads: Lead[]): string {
|
||||||
if (!leads.length) return 'No leads yet';
|
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. */
|
/** 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="px-2 py-1">
|
<div className="px-2 py-1">
|
||||||
@ -71,7 +55,7 @@ function SidebarInner({ activeTo, onNav, roster }: { activeTo: string; onNav: (t
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex flex-col gap-1">
|
<nav className="flex flex-col gap-1">
|
||||||
{NAV.map((n) => {
|
{items.map((n) => {
|
||||||
const on = activeTo === n.to;
|
const on = activeTo === n.to;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -90,25 +74,6 @@ function SidebarInner({ activeTo, onNav, roster }: { activeTo: string; onNav: (t
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="mt-auto flex flex-col gap-2.5">
|
|
||||||
<div className="text-[10px] font-bold uppercase tracking-[0.08em] text-on-navy-muted px-2">
|
|
||||||
AI Employees
|
|
||||||
</div>
|
|
||||||
{roster.map((e) => (
|
|
||||||
<div key={e.name} className="flex items-center gap-2.5 p-2 rounded-md bg-[rgba(255,255,255,0.04)]">
|
|
||||||
<Avatar name={e.name} ai size={28} />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-xs font-semibold text-on-navy truncate">{e.name}</div>
|
|
||||||
<div className="text-[10px] text-on-navy-muted">{e.role}</div>
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] font-bold text-emerald-300 flex items-center gap-1">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
|
||||||
{e.active}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -117,7 +82,7 @@ function Topbar({ title, subtitle, onMenu }: { title: string; subtitle: string;
|
|||||||
const { user, logout } = useZino();
|
const { user, logout } = useZino();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
return (
|
return (
|
||||||
<header className="h-16 shrink-0 bg-card border-b border-border-subtle flex items-center px-4 lg:px-7 gap-3 lg:gap-5 font-sans">
|
<header className="sticky top-0 z-30 h-16 shrink-0 bg-card border-b border-border-subtle flex items-center px-4 lg:px-7 gap-3 lg:gap-5 font-sans">
|
||||||
<button
|
<button
|
||||||
onClick={onMenu}
|
onClick={onMenu}
|
||||||
aria-label="Open menu"
|
aria-label="Open menu"
|
||||||
@ -161,12 +126,15 @@ function Topbar({ title, subtitle, onMenu }: { title: string; subtitle: string;
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** App shell — navy sidebar (nav + AI-employee roster) + topbar, with routed content. */
|
/** App shell — navy sidebar (nav) + topbar, with routed content. */
|
||||||
export function Shell() {
|
export function Shell() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { leads } = useLeads();
|
const { leads } = useLeads();
|
||||||
|
const { user } = useZino();
|
||||||
const path = location.pathname;
|
const path = location.pathname;
|
||||||
|
// Nav is RBAC-filtered: a user only sees screens their roles allow.
|
||||||
|
const visibleNav = useMemo(() => NAV.filter((n) => canSeeScreen(user?.roles, n.to)), [user]);
|
||||||
const navMatch = NAV.find((n) => path.startsWith(n.to));
|
const navMatch = NAV.find((n) => path.startsWith(n.to));
|
||||||
// Routes reached contextually (no sidebar entry) still get a topbar title.
|
// Routes reached contextually (no sidebar entry) still get a topbar title.
|
||||||
const active =
|
const active =
|
||||||
@ -177,7 +145,6 @@ export function Shell() {
|
|||||||
? { to: '/new', label: 'New lead', Icon: Users, title: 'Capture lead', subtitle: 'Add a new lead to the pipeline' }
|
? { to: '/new', label: 'New lead', Icon: Users, title: 'Capture lead', subtitle: 'Add a new lead to the pipeline' }
|
||||||
: NAV[0]);
|
: NAV[0]);
|
||||||
|
|
||||||
const roster = useMemo(() => buildRoster(leads), [leads]);
|
|
||||||
const subtitle = active.to === '/pipeline' ? pipelineSubtitle(leads) : active.subtitle;
|
const subtitle = active.to === '/pipeline' ? pipelineSubtitle(leads) : active.subtitle;
|
||||||
|
|
||||||
const [navOpen, setNavOpen] = useState(false);
|
const [navOpen, setNavOpen] = useState(false);
|
||||||
@ -185,10 +152,10 @@ export function Shell() {
|
|||||||
useEffect(() => setNavOpen(false), [path]);
|
useEffect(() => setNavOpen(false), [path]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-app font-sans">
|
<div className="flex min-h-screen bg-app font-sans">
|
||||||
{/* Static rail — lg and up. */}
|
{/* Static rail — lg and up. Pinned; the page scrolls normally beside it. */}
|
||||||
<aside className="hidden lg:flex w-[248px] shrink-0 bg-navy-grad flex-col p-4 gap-6 font-sans">
|
<aside className="hidden lg:flex w-[248px] shrink-0 bg-navy-grad flex-col p-4 gap-6 font-sans sticky top-0 h-screen self-start overflow-y-auto">
|
||||||
<SidebarInner activeTo={navMatch?.to ?? ''} onNav={(to) => navigate(to)} roster={roster} />
|
<SidebarInner items={visibleNav} activeTo={navMatch?.to ?? ''} onNav={(to) => navigate(to)} />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Drawer — below lg. */}
|
{/* Drawer — below lg. */}
|
||||||
@ -203,14 +170,14 @@ export function Shell() {
|
|||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
<SidebarInner activeTo={navMatch?.to ?? ''} onNav={(to) => navigate(to)} roster={roster} />
|
<SidebarInner items={visibleNav} activeTo={navMatch?.to ?? ''} onNav={(to) => navigate(to)} />
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
<Topbar title={active.title} subtitle={subtitle} onMenu={() => setNavOpen(true)} />
|
<Topbar title={active.title} subtitle={subtitle} onMenu={() => setNavOpen(true)} />
|
||||||
<main className="flex-1 overflow-auto p-4 lg:p-7">
|
<main className="flex-1 p-4 lg:p-7">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,9 +1,41 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
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';
|
import { useZino } from '../api/provider';
|
||||||
|
|
||||||
|
const HIGHLIGHTS = [
|
||||||
|
{ Icon: Sparkles, title: 'AI employees do the legwork', body: 'Aria qualifies, engages and schedules leads autonomously.' },
|
||||||
|
{ Icon: ShieldCheck, title: 'Capture → underwrite → issue', body: 'The whole policy lifecycle in one guided console.' },
|
||||||
|
{ Icon: CalendarClock, title: 'Live pipeline & agent scheduling', body: 'Every lead, meeting and decision as it happens.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
trailing,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
trailing?: React.ReactNode;
|
||||||
|
} & React.InputHTMLAttributes<HTMLInputElement>) {
|
||||||
|
return (
|
||||||
|
<label className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-xs font-medium text-on-navy-muted">{label}</span>
|
||||||
|
<div className="flex items-center gap-2.5 h-[48px] px-3.5 rounded-xl bg-white/[0.07] border border-white/15 transition-colors duration-150 focus-within:border-sunrise-400/70 focus-within:bg-white/[0.12]">
|
||||||
|
<span className="text-on-navy-muted shrink-0">{icon}</span>
|
||||||
|
<input
|
||||||
|
className="flex-1 w-full bg-transparent border-none outline-none text-base text-on-navy placeholder:text-white/35"
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
{trailing}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Login() {
|
export function Login() {
|
||||||
const { login } = useZino();
|
const { login } = useZino();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -12,6 +44,7 @@ export function Login() {
|
|||||||
|
|
||||||
const [email, setEmail] = useState('admin@getzino.com');
|
const [email, setEmail] = useState('admin@getzino.com');
|
||||||
const [password, setPassword] = useState('Zino');
|
const [password, setPassword] = useState('Zino');
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
@ -30,42 +63,107 @@ export function Login() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-navy-grad font-sans p-6">
|
<div className="relative min-h-screen overflow-hidden bg-navy-grad font-sans flex items-center justify-center p-6">
|
||||||
<div className="w-full max-w-[400px] bg-card rounded-lg shadow-lg p-8 flex flex-col gap-6">
|
{/* subtle grid, faded toward the edges */}
|
||||||
<div className="flex items-center gap-2.5">
|
<div
|
||||||
<span className="w-9 h-9 rounded-[10px] bg-sunrise shadow-sunrise flex items-center justify-center text-white font-extrabold text-xl font-numeric">
|
className="absolute inset-0 pointer-events-none"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
'linear-gradient(to right, rgba(255,255,255,0.04) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.04) 1px, transparent 1px)',
|
||||||
|
backgroundSize: '52px 52px',
|
||||||
|
maskImage: 'radial-gradient(ellipse 70% 60% at 50% 45%, #000 40%, transparent 100%)',
|
||||||
|
WebkitMaskImage: 'radial-gradient(ellipse 70% 60% at 50% 45%, #000 40%, transparent 100%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* one soft static glow for warmth */}
|
||||||
|
<div className="absolute top-[-15%] left-1/2 -translate-x-1/2 w-[640px] h-[440px] rounded-full bg-sunrise-500/12 blur-[140px] pointer-events-none" />
|
||||||
|
|
||||||
|
{/* one glass container holding the brand story (left) + the form (right) */}
|
||||||
|
<div className="relative w-full max-w-[920px] rounded-2xl bg-white/[0.07] backdrop-blur-2xl border border-white/15 shadow-2xl overflow-hidden grid lg:grid-cols-2">
|
||||||
|
{/* brand story */}
|
||||||
|
<div className="hidden lg:flex flex-col gap-9 p-10 border-r border-white/10">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span className="w-11 h-11 rounded-2xl bg-sunrise shadow-sunrise flex items-center justify-center text-white font-extrabold text-2xl font-numeric">
|
||||||
|
A
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-xl font-extrabold tracking-[-0.02em] text-on-navy">aria</div>
|
||||||
|
<div className="text-xs text-on-navy-muted">Lead-to-policy console</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="m-0 text-[34px] leading-[1.12] font-extrabold tracking-[-0.025em] text-on-navy">
|
||||||
|
The AI-native
|
||||||
|
<br />
|
||||||
|
lead-to-policy console.
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-5 mt-auto">
|
||||||
|
{HIGHLIGHTS.map((h) => (
|
||||||
|
<div key={h.title} className="flex items-start gap-3.5">
|
||||||
|
<span className="w-9 h-9 shrink-0 rounded-[10px] bg-white/10 border border-white/10 flex items-center justify-center text-sunrise-300">
|
||||||
|
<h.Icon size={18} />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-on-navy">{h.title}</div>
|
||||||
|
<div className="text-xs text-on-navy-muted mt-0.5">{h.body}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* form side */}
|
||||||
|
<div className="p-8 lg:p-10 flex flex-col gap-7 justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-3 text-center lg:items-start lg:text-left">
|
||||||
|
<span className="w-12 h-12 rounded-2xl bg-sunrise shadow-sunrise flex items-center justify-center text-white font-extrabold text-2xl font-numeric lg:hidden">
|
||||||
A
|
A
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-lg font-extrabold tracking-[-0.02em] text-strong">aria</div>
|
<div className="text-xl font-extrabold tracking-[-0.02em] text-on-navy">Welcome back</div>
|
||||||
<div className="text-xs text-faint">Lead-to-policy console</div>
|
<div className="text-sm text-on-navy-muted mt-0.5">Sign in to your console</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
||||||
<Input
|
<Field
|
||||||
label="Email"
|
label="Email"
|
||||||
|
icon={<Mail size={16} />}
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
|
placeholder="you@company.com"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Field
|
||||||
label="Password"
|
label="Password"
|
||||||
type="password"
|
icon={<Lock size={16} />}
|
||||||
|
type={show ? 'text' : 'password'}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
trailing={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShow((s) => !s)}
|
||||||
|
aria-label={show ? 'Hide password' : 'Show password'}
|
||||||
|
className="flex items-center justify-center text-on-navy-muted hover:text-on-navy cursor-pointer bg-transparent border-none p-0 shrink-0"
|
||||||
|
>
|
||||||
|
{show ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-sm text-amber-700 bg-escalated-soft border border-amber-300 rounded-md px-3 py-2">
|
<div className="text-sm text-amber-200 bg-ruby-600/20 border border-ruby-400/30 rounded-md px-3 py-2">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button variant="primary" full disabled={busy}>
|
<Button variant="primary" full size="lg" disabled={busy} iconRight={!busy && <ArrowRight size={18} />}>
|
||||||
{busy ? 'Signing in…' : 'Sign in'}
|
{busy ? 'Signing in…' : 'Sign in'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
ChannelBadge,
|
ChannelBadge,
|
||||||
|
formatINR,
|
||||||
RupeeAmount,
|
RupeeAmount,
|
||||||
SegmentBadge,
|
SegmentBadge,
|
||||||
TimelineEntry,
|
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 }) {
|
function ProfileRow({ label, value, mono }: { label: string; value: ReactNode; mono?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between py-2.5 border-b border-border-subtle last:border-b-0">
|
<div className="flex items-center justify-between py-2.5 border-b border-border-subtle last:border-b-0">
|
||||||
@ -95,12 +106,9 @@ export function Lead360Screen() {
|
|||||||
);
|
);
|
||||||
const instanceId = record?.instance_id;
|
const instanceId = record?.instance_id;
|
||||||
|
|
||||||
const decisionsQ = useQuery(
|
// No polling — async AI decisions are pulled on demand via the stream's
|
||||||
() => client.aiDecisions(instanceId!),
|
// refresh button (and after any activity advance), so the view never flickers.
|
||||||
[instanceId],
|
const decisionsQ = useQuery(() => client.aiDecisions(instanceId!), [instanceId], instanceId != null);
|
||||||
instanceId != null,
|
|
||||||
12_000, // poll: async AI decisions land without a manual reload
|
|
||||||
);
|
|
||||||
const auditQ = useQuery(() => client.audit(instanceId!), [instanceId], instanceId != null);
|
const auditQ = useQuery(() => client.audit(instanceId!), [instanceId], instanceId != null);
|
||||||
|
|
||||||
const onAdvanced = () => {
|
const onAdvanced = () => {
|
||||||
@ -108,6 +116,11 @@ export function Lead360Screen() {
|
|||||||
decisionsQ.refetch();
|
decisionsQ.refetch();
|
||||||
auditQ.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) {
|
if (leadsLoading) {
|
||||||
return <div className="p-10 text-center text-sm text-faint">Loading lead…</div>;
|
return <div className="p-10 text-center text-sm text-faint">Loading lead…</div>;
|
||||||
@ -145,87 +158,73 @@ export function Lead360Screen() {
|
|||||||
? meeting.toLocaleString('en-IN', { weekday: 'short', day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit', hour12: true })
|
? meeting.toLocaleString('en-IN', { weekday: 'short', day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit', hour12: true })
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const issued = lead.stage >= STAGES.indexOf('Policy Issued');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-[18px] max-w-[1320px] mx-auto items-start grid-cols-1 lg:grid-cols-[280px_1fr] xl:grid-cols-[300px_1fr_380px]">
|
<div className="mx-auto flex w-full max-w-[1320px] flex-col gap-[18px]">
|
||||||
{/* LEFT — profile */}
|
{/* HERO — identity + lifecycle + headline figures unified into one band so
|
||||||
<div className="flex flex-col gap-[18px]">
|
the page reads as a single record, not a scatter of tiles. */}
|
||||||
<Card>
|
<Card bodyClassName="p-0">
|
||||||
<div className="flex flex-col items-center text-center gap-2.5 pb-1.5">
|
<div className="flex flex-col gap-5 p-5">
|
||||||
<Avatar name={lead.name} size={64} />
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
<div>
|
<Avatar name={lead.name} size={56} className="shrink-0" />
|
||||||
<div className="text-lg font-bold text-strong">{lead.name}</div>
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-xs text-faint mt-0.5">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
#{lead.id} · {lead.city}
|
<span className="text-xl font-bold text-strong truncate">{lead.name}</span>
|
||||||
|
<SegmentBadge segment={lead.segment} />
|
||||||
|
<ChannelBadge channel={lead.channel} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="text-xs text-faint mt-1">
|
||||||
<div className="flex gap-2">
|
#{lead.id} · {lead.city} · owned by {lead.owner}
|
||||||
<SegmentBadge segment={lead.segment} />
|
|
||||||
<ChannelBadge channel={lead.channel} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-center gap-2 mt-1.5">
|
|
||||||
<Button variant="primary" size="sm">
|
|
||||||
Call
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" size="sm">
|
|
||||||
WhatsApp
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Profile">
|
|
||||||
<ProfileRow label="Phone" value={lead.phone} />
|
|
||||||
<ProfileRow label="Email" value={lead.email} />
|
|
||||||
<ProfileRow label="Date of birth" value={lead.age ? `${lead.dob} · ${lead.age} yrs` : lead.dob} />
|
|
||||||
<ProfileRow label="Occupation" value={lead.occupation} />
|
|
||||||
<ProfileRow label="Annual income" value={<RupeeAmount value={lead.income} size="sm" />} />
|
|
||||||
<ProfileRow label="Preferred language" value={lead.language} />
|
|
||||||
<div className="flex items-center justify-between pt-3">
|
|
||||||
<span className="text-xs text-faint">Product interest</span>
|
|
||||||
<Badge tone="accent">{lead.interest}</Badge>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CENTER — workflow + current activity. min-w-0 lets the 1fr track shrink
|
|
||||||
instead of forcing the grid wider than its container. */}
|
|
||||||
<div className="flex flex-col gap-[18px] min-w-0">
|
|
||||||
<Card title="Workflow">
|
|
||||||
<div className="overflow-x-auto pb-1">
|
|
||||||
<WorkflowStepper stages={STAGES} current={Math.max(0, lead.stage)} />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card
|
|
||||||
title="Current activity"
|
|
||||||
action={<Badge tone={lead.stage >= STAGES.indexOf('Policy Issued') ? 'success' : 'warning'} dot>{record.current_state_name ?? '—'}</Badge>}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3.5 mb-4">
|
|
||||||
<Avatar name={lead.owner} ai={lead.ownerAi} size={44} />
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-md font-bold text-strong">{record.current_state_name ?? 'In progress'}</div>
|
|
||||||
<div className="text-xs text-muted mt-0.5">
|
|
||||||
Owned by {lead.owner}
|
|
||||||
{meetingLabel ? ` · meeting ${meetingLabel}` : ''}
|
{meetingLabel ? ` · meeting ${meetingLabel}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Badge tone={issued ? 'success' : 'warning'} dot>
|
||||||
|
{record.current_state_name ?? '—'}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="bg-slate-50 rounded-md px-3.5 py-3">
|
|
||||||
<div className="text-2xs text-faint mb-1.5">Sum assured</div>
|
|
||||||
<RupeeAmount value={cover} size="lg" tone="navy" />
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-50 rounded-md px-3.5 py-3">
|
|
||||||
<div className="text-2xs text-faint mb-1.5">Premium</div>
|
|
||||||
<RupeeAmount value={premium} size="lg" tone="accent" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 pt-4 border-t border-border-subtle">
|
|
||||||
<NextActionPanel record={record} onAdvanced={onAdvanced} />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{onboarded && (
|
{/* lifecycle stepper */}
|
||||||
|
<div className="overflow-x-auto pb-1">
|
||||||
|
<WorkflowStepper stages={STAGES} current={Math.max(0, lead.stage)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* headline figures */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
<div className="min-w-0 bg-slate-50 rounded-md px-3.5 py-3">
|
||||||
|
<div className="text-2xs text-faint mb-1.5">Sum assured</div>
|
||||||
|
<div className="font-numeric text-2xl font-extrabold leading-none text-strong nums truncate">{compactINR(cover)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 bg-slate-50 rounded-md px-3.5 py-3">
|
||||||
|
<div className="text-2xs text-faint mb-1.5">Premium</div>
|
||||||
|
<div className="font-numeric text-2xl font-extrabold leading-none text-sunrise-600 nums truncate">{compactINR(premium)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 bg-slate-50 rounded-md px-3.5 py-3 col-span-2 sm:col-span-1">
|
||||||
|
<div className="text-2xs text-faint mb-1.5">Annual income</div>
|
||||||
|
<div className="font-numeric text-2xl font-extrabold leading-none text-strong nums truncate">{compactINR(lead.income)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* BODY — primary work on the left, live AI feed + reference on the right. */}
|
||||||
|
<div className="grid items-start gap-[18px] grid-cols-1 lg:grid-cols-[1fr_360px]">
|
||||||
|
{/* MAIN */}
|
||||||
|
<div className="flex flex-col gap-[18px] min-w-0">
|
||||||
|
<Card title="Next action">
|
||||||
|
<div className="flex items-center gap-3.5 mb-4">
|
||||||
|
<Avatar name={lead.owner} ai={lead.ownerAi} size={40} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-md font-bold text-strong">{record.current_state_name ?? 'In progress'}</div>
|
||||||
|
<div className="text-xs text-muted mt-0.5">Owned by {lead.owner}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-4 border-t border-border-subtle">
|
||||||
|
<NextActionPanel record={record} onAdvanced={onAdvanced} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{onboarded && (
|
||||||
<Card
|
<Card
|
||||||
title="Policy & onboarding"
|
title="Policy & onboarding"
|
||||||
action={
|
action={
|
||||||
@ -280,32 +279,51 @@ export function Lead360Screen() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card title="Activity log">
|
{/* Activity log + profile reference sit side by side — profile is
|
||||||
{timeline.length ? (
|
context, not a footer, so it reads next to the audit trail. */}
|
||||||
timeline.map((t, i) => (
|
<div className="grid items-start gap-[18px] grid-cols-1 md:grid-cols-[1fr_300px]">
|
||||||
<TimelineEntry
|
<Card title="Activity log">
|
||||||
key={i}
|
{timeline.length ? (
|
||||||
actor={t.actor}
|
timeline.map((t, i) => (
|
||||||
ai={t.ai}
|
<TimelineEntry
|
||||||
action={t.action}
|
key={i}
|
||||||
time={t.time}
|
actor={t.actor}
|
||||||
tone={t.tone}
|
ai={t.ai}
|
||||||
last={i === timeline.length - 1}
|
action={t.action}
|
||||||
/>
|
time={t.time}
|
||||||
))
|
tone={t.tone}
|
||||||
) : (
|
last={i === timeline.length - 1}
|
||||||
<div className="text-sm text-faint">No activity recorded yet.</div>
|
/>
|
||||||
)}
|
))
|
||||||
</Card>
|
) : (
|
||||||
</div>
|
<div className="text-sm text-faint">No activity recorded yet.</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* RIGHT — AI decision stream + escalations.
|
<Card title="Profile">
|
||||||
At lg it drops to a full-width row beneath profile + center; at xl it's the 3rd column. */}
|
<ProfileRow label="Phone" value={lead.phone} />
|
||||||
<AIDecisionStream
|
<ProfileRow label="Email" value={lead.email} />
|
||||||
decisions={decisions}
|
<ProfileRow label="Date of birth" value={lead.age ? `${lead.dob} · ${lead.age} yrs` : lead.dob} />
|
||||||
loading={decisionsQ.loading}
|
<ProfileRow label="Occupation" value={lead.occupation} />
|
||||||
className="min-w-0 lg:col-span-2 xl:col-span-1"
|
<ProfileRow label="Annual income" value={<RupeeAmount value={lead.income} size="sm" />} />
|
||||||
/>
|
<ProfileRow label="Preferred language" value={lead.language} />
|
||||||
|
<div className="flex items-center justify-between pt-3">
|
||||||
|
<span className="text-xs text-faint">Product interest</span>
|
||||||
|
<Badge tone="accent">{lead.interest}</Badge>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RAIL — live AI decision feed (manual refresh, no polling). */}
|
||||||
|
<div className="flex flex-col gap-[18px] min-w-0">
|
||||||
|
<AIDecisionStream
|
||||||
|
decisions={decisions}
|
||||||
|
loading={decisionsQ.loading}
|
||||||
|
onRefresh={refreshFeed}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { Card } from '../components';
|
|
||||||
import { ActivityForm } from '../components/form';
|
|
||||||
import { useLeads } from '../api/leads';
|
|
||||||
import { ACTIVITY_META } from '../api/forms';
|
|
||||||
|
|
||||||
/** Capture Lead — the INIT activity. Creates a new instance via /start. */
|
|
||||||
export function NewLeadScreen() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { refetch } = useLeads();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-[820px] mx-auto">
|
|
||||||
<Card title="New lead">
|
|
||||||
<ActivityForm
|
|
||||||
meta={ACTIVITY_META.capture}
|
|
||||||
successMessage="Lead created — opening the lead…"
|
|
||||||
onSuccess={(res) => {
|
|
||||||
refetch();
|
|
||||||
const id = res.instance_id;
|
|
||||||
if (id != null) navigate(`/leads/${id}`);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,21 +1,27 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { cn } from '../lib/cn';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
|
||||||
Card,
|
Card,
|
||||||
ChannelBadge,
|
ChannelBadge,
|
||||||
KpiCard,
|
KpiCard,
|
||||||
LeadRow,
|
LeadRow,
|
||||||
LEAD_GRID,
|
LEAD_GRID,
|
||||||
|
Pagination,
|
||||||
SegmentBadge,
|
SegmentBadge,
|
||||||
} from '../components';
|
} from '../components';
|
||||||
import type { Kpi, Lead, SegmentDatum } from '../types';
|
import type { Kpi, Lead, SegmentDatum } from '../types';
|
||||||
import { useLeads } from '../api/leads';
|
import { useLeads } from '../api/leads';
|
||||||
import { STAGES } from '../api/config';
|
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 {
|
function scoreColor(score: number): string {
|
||||||
if (score >= 75) return 'var(--sunrise-600)';
|
if (score >= 75) return 'var(--sunrise-600)';
|
||||||
@ -212,10 +218,25 @@ export function PipelineScreen() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const openLead = (l: Lead) => navigate(`/leads/${l.id}`);
|
const openLead = (l: Lead) => navigate(`/leads/${l.id}`);
|
||||||
|
|
||||||
const { leads, loading, error } = useLeads();
|
const { leads, records, fields, loading, error, refetch } = useLeads();
|
||||||
|
const [filters, setFilters] = useState<FilterState>(EMPTY_FILTER);
|
||||||
|
|
||||||
|
// KPIs / funnel / segments summarize the whole pipeline; the list below is
|
||||||
|
// what the filter narrows.
|
||||||
const kpis = useMemo(() => buildKpis(leads), [leads]);
|
const kpis = useMemo(() => buildKpis(leads), [leads]);
|
||||||
const funnel = useMemo(() => buildFunnel(leads), [leads]);
|
const funnel = useMemo(() => buildFunnel(leads), [leads]);
|
||||||
const segments = useMemo(() => buildSegments(leads), [leads]);
|
const segments = useMemo(() => buildSegments(leads), [leads]);
|
||||||
|
const visibleLeads = useMemo(
|
||||||
|
() => applyFilters(records, filters, fields).map(recordToLead),
|
||||||
|
[records, filters, fields],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Table-view pagination (the kanban board shows every stage at once). Page is
|
||||||
|
// clamped in <Pagination> and reset when the filter set changes.
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
useEffect(() => setPage(1), [filters]);
|
||||||
|
const start = (Math.min(page, Math.max(1, Math.ceil(visibleLeads.length / PAGE_SIZE))) - 1) * PAGE_SIZE;
|
||||||
|
const pageLeads = visibleLeads.slice(start, start + PAGE_SIZE);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5 max-w-[1240px] mx-auto">
|
<div className="flex flex-col gap-5 max-w-[1240px] mx-auto">
|
||||||
@ -232,12 +253,13 @@ export function PipelineScreen() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* charts */}
|
{/* charts — cards stay equal height (aligned bottoms); the shorter
|
||||||
|
segments content is vertically centered so there's no dead space. */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[1.7fr_1fr] gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-[1.7fr_1fr] gap-4">
|
||||||
<Card title="Pipeline funnel" action={<Badge tone="neutral">Live</Badge>}>
|
<Card title="Pipeline funnel" action={<Badge tone="neutral">Live</Badge>}>
|
||||||
<FunnelChart data={funnel} />
|
<FunnelChart data={funnel} />
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="Lead segments">
|
<Card title="Lead segments" className="flex flex-col" bodyClassName="flex-1 flex items-center">
|
||||||
<SegmentDonut segments={segments} />
|
<SegmentDonut segments={segments} />
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@ -249,18 +271,11 @@ export function PipelineScreen() {
|
|||||||
action={
|
action={
|
||||||
<div className="flex gap-2.5 items-center">
|
<div className="flex gap-2.5 items-center">
|
||||||
<ViewToggle view={view} onChange={setView} />
|
<ViewToggle view={view} onChange={setView} />
|
||||||
<Button variant="secondary" size="sm">
|
<AddLeadButton onDone={refetch} />
|
||||||
Filter
|
|
||||||
</Button>
|
|
||||||
<Button variant="primary" size="sm" onClick={() => navigate('/new')}>
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<Plus size={15} />
|
|
||||||
New lead
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<LeadFilters records={records} fields={fields} value={filters} onChange={setFilters} />
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="p-10 text-center text-sm text-faint">Loading leads…</div>
|
<div className="p-10 text-center text-sm text-faint">Loading leads…</div>
|
||||||
) : view === 'table' ? (
|
) : view === 'table' ? (
|
||||||
@ -279,18 +294,21 @@ export function PipelineScreen() {
|
|||||||
<span>Owner</span>
|
<span>Owner</span>
|
||||||
<span>Last action</span>
|
<span>Last action</span>
|
||||||
</div>
|
</div>
|
||||||
{leads.length ? (
|
{pageLeads.length ? (
|
||||||
leads.map((l) => <LeadRow key={l.id} lead={l} onClick={() => openLead(l)} />)
|
pageLeads.map((l) => <LeadRow key={l.id} lead={l} onClick={() => openLead(l)} />)
|
||||||
) : (
|
) : (
|
||||||
<div className="p-10 text-center text-sm text-faint">No leads yet.</div>
|
<div className="p-10 text-center text-sm text-faint">No leads match these filters.</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<KanbanBoard leads={leads} onOpenLead={openLead} />
|
<KanbanBoard leads={visibleLeads} onOpenLead={openLead} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{view === 'table' && !loading && (
|
||||||
|
<Pagination page={page} pageSize={PAGE_SIZE} total={visibleLeads.length} onPage={setPage} />
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
325
src/screens/ScheduleScreen.tsx
Normal file
325
src/screens/ScheduleScreen.tsx
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import { Building2, CalendarClock, Home, MapPin, Phone, Search, Video } from 'lucide-react';
|
||||||
|
import { cn } from '../lib/cn';
|
||||||
|
import { Avatar, Badge } from '../components/core';
|
||||||
|
import { formatTime } from '../api/adapters';
|
||||||
|
import { useZino } from '../api/provider';
|
||||||
|
import { useMeetings, type Meeting } from '../api/meetings';
|
||||||
|
|
||||||
|
// Meeting-mode presentation. Falls back to a pin for unknown modes.
|
||||||
|
const MODE_META: Record<string, { Icon: LucideIcon; label: string; cls: string }> = {
|
||||||
|
video: { Icon: Video, label: 'Video call', cls: 'text-sky-600 bg-sky-100' },
|
||||||
|
phone: { Icon: Phone, label: 'Phone call', cls: 'text-emerald-600 bg-emerald-100' },
|
||||||
|
branch: { Icon: Building2, label: 'Branch visit', cls: 'text-sunrise-600 bg-sunrise-100' },
|
||||||
|
home_visit: { Icon: Home, label: 'Home visit', cls: 'text-amber-600 bg-amber-100' },
|
||||||
|
};
|
||||||
|
function modeMeta(mode: string) {
|
||||||
|
return MODE_META[mode] ?? { Icon: MapPin, label: mode || 'Meeting', cls: 'text-slate-600 bg-slate-100' };
|
||||||
|
}
|
||||||
|
|
||||||
|
type Range = 'today' | 'week' | 'all';
|
||||||
|
const RANGES: Array<[Range, string]> = [
|
||||||
|
['today', 'Today'],
|
||||||
|
['week', 'This week'],
|
||||||
|
['all', 'All'],
|
||||||
|
];
|
||||||
|
|
||||||
|
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
|
||||||
|
function startOfDay(d: Date): Date {
|
||||||
|
const x = new Date(d);
|
||||||
|
x.setHours(0, 0, 0, 0);
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
function dayKey(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
||||||
|
}
|
||||||
|
function dayLabel(iso: string, todayKey: string, tomorrowKey: string): string {
|
||||||
|
const k = dayKey(iso);
|
||||||
|
if (k === todayKey) return 'Today';
|
||||||
|
if (k === tomorrowKey) return 'Tomorrow';
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `${DAYS[d.getDay()]} ${d.getDate()} ${MONTHS[d.getMonth()]}`;
|
||||||
|
}
|
||||||
|
function inRange(iso: string, range: Range, now: Date): boolean {
|
||||||
|
if (range === 'all') return true;
|
||||||
|
const t = new Date(iso).getTime();
|
||||||
|
const start = startOfDay(now).getTime();
|
||||||
|
if (range === 'today') return t >= start && t < start + 86400000;
|
||||||
|
return t >= start && t < start + 7 * 86400000; // this week = next 7 days
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short "when is the next one" label for the agent rail.
|
||||||
|
function nextLabel(iso: string, now: Date, todayKey: string, tomorrowKey: string): string {
|
||||||
|
const diff = new Date(iso).getTime() - now.getTime();
|
||||||
|
if (diff >= 0 && diff < 3600000) return `in ${Math.max(1, Math.round(diff / 60000))}m`;
|
||||||
|
const k = dayKey(iso);
|
||||||
|
if (k === todayKey) return formatTime(iso);
|
||||||
|
if (k === tomorrowKey) return `Tom ${formatTime(iso)}`;
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `${d.getDate()} ${MONTHS[d.getMonth()]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RosterEntry {
|
||||||
|
agent: string;
|
||||||
|
upcoming: number;
|
||||||
|
total: number;
|
||||||
|
next: string | null; // ISO of next upcoming meeting
|
||||||
|
}
|
||||||
|
|
||||||
|
function MeetingRow({ m, past, onClick }: { m: Meeting; past: boolean; onClick: () => void }) {
|
||||||
|
const meta = modeMeta(m.mode);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-3 px-3 py-2.5 rounded-md border border-border-subtle bg-card text-left',
|
||||||
|
'cursor-pointer transition-all duration-150 hover:shadow-sm hover:-translate-y-px',
|
||||||
|
past && 'opacity-60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-numeric font-bold text-sm text-strong nums w-[68px] shrink-0">
|
||||||
|
{formatTime(m.datetime) || '—'}
|
||||||
|
</span>
|
||||||
|
<span className={cn('w-7 h-7 rounded-md flex items-center justify-center shrink-0', meta.cls)} title={meta.label}>
|
||||||
|
<meta.Icon size={15} />
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-strong truncate">{m.leadName}</div>
|
||||||
|
<div className="text-2xs text-faint truncate">
|
||||||
|
{meta.label}
|
||||||
|
{m.address ? ` · ${m.address}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge tone="neutral" size="sm" className="shrink-0">
|
||||||
|
{m.stage}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Left rail: searchable agent roster, sorted by upcoming load. */
|
||||||
|
function AgentRail({
|
||||||
|
roster,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
now,
|
||||||
|
todayKey,
|
||||||
|
tomorrowKey,
|
||||||
|
}: {
|
||||||
|
roster: RosterEntry[];
|
||||||
|
selected: string | null;
|
||||||
|
onSelect: (a: string) => void;
|
||||||
|
now: Date;
|
||||||
|
todayKey: string;
|
||||||
|
tomorrowKey: string;
|
||||||
|
}) {
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const s = q.trim().toLowerCase();
|
||||||
|
return s ? roster.filter((r) => r.agent.toLowerCase().includes(s)) : roster;
|
||||||
|
}, [roster, q]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col rounded-lg border border-border-subtle bg-card overflow-hidden md:h-[calc(100vh-180px)]">
|
||||||
|
<div className="px-3 py-2.5 border-b border-border-subtle">
|
||||||
|
<div className="flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-slate-100">
|
||||||
|
<Search size={14} className="text-faint shrink-0" />
|
||||||
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="Search agents"
|
||||||
|
className="bg-transparent border-none outline-none text-sm text-body w-full placeholder:text-faint"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-1.5 flex flex-col gap-0.5">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="text-2xs text-faint text-center py-6">No agents match.</div>
|
||||||
|
) : (
|
||||||
|
items.map((r) => {
|
||||||
|
const on = r.agent === selected;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={r.agent}
|
||||||
|
onClick={() => onSelect(r.agent)}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md border-none cursor-pointer text-left transition-colors duration-150',
|
||||||
|
on ? 'bg-[rgba(251,169,76,0.14)]' : 'bg-transparent hover:bg-slate-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Avatar name={r.agent} size={30} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className={cn('text-sm truncate', on ? 'font-bold text-strong' : 'font-semibold text-body')}>
|
||||||
|
{r.agent}
|
||||||
|
</div>
|
||||||
|
<div className="text-2xs text-faint truncate">
|
||||||
|
{r.next ? `Next · ${nextLabel(r.next, now, todayKey, tomorrowKey)}` : 'No upcoming'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-2xs font-bold rounded-pill min-w-5 text-center px-2 py-0.5 nums shrink-0',
|
||||||
|
r.upcoming > 0 ? 'bg-sunrise-100 text-sunrise-600' : 'bg-slate-100 text-faint',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{r.upcoming}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScheduleScreen() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useZino();
|
||||||
|
const { meetings, loading, error } = useMeetings();
|
||||||
|
const [range, setRange] = useState<Range>('week');
|
||||||
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const now = useMemo(() => new Date(), []);
|
||||||
|
const todayKey = dayKey(now.toISOString());
|
||||||
|
const tomorrowKey = dayKey(new Date(startOfDay(now).getTime() + 86400000).toISOString());
|
||||||
|
|
||||||
|
// Roster: one entry per agent, with upcoming load + next-meeting, load-sorted.
|
||||||
|
const roster = useMemo<RosterEntry[]>(() => {
|
||||||
|
const m = new Map<string, RosterEntry>();
|
||||||
|
for (const mt of meetings) {
|
||||||
|
const e = m.get(mt.agent) ?? { agent: mt.agent, upcoming: 0, total: 0, next: null };
|
||||||
|
e.total += 1;
|
||||||
|
if (new Date(mt.datetime).getTime() >= now.getTime()) {
|
||||||
|
e.upcoming += 1;
|
||||||
|
if (!e.next || new Date(mt.datetime).getTime() < new Date(e.next).getTime()) e.next = mt.datetime;
|
||||||
|
}
|
||||||
|
m.set(mt.agent, e);
|
||||||
|
}
|
||||||
|
return [...m.values()].sort((a, b) => {
|
||||||
|
if (a.agent === 'Unassigned') return 1;
|
||||||
|
if (b.agent === 'Unassigned') return -1;
|
||||||
|
return b.upcoming - a.upcoming || b.total - a.total;
|
||||||
|
});
|
||||||
|
}, [meetings, now]);
|
||||||
|
|
||||||
|
// Default selection: the logged-in user if they're an agent, else top of roster.
|
||||||
|
useEffect(() => {
|
||||||
|
if (selected || roster.length === 0) return;
|
||||||
|
const mine = user?.name ? roster.find((r) => r.agent.toLowerCase() === user.name.toLowerCase()) : undefined;
|
||||||
|
setSelected(mine?.agent ?? roster[0].agent);
|
||||||
|
}, [roster, selected, user]);
|
||||||
|
|
||||||
|
const detail = useMemo(() => {
|
||||||
|
if (!selected) return [];
|
||||||
|
return meetings
|
||||||
|
.filter((m) => m.agent === selected && inRange(m.datetime, range, now))
|
||||||
|
.sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime());
|
||||||
|
}, [meetings, selected, range, now]);
|
||||||
|
|
||||||
|
// Group the selected agent's meetings by day.
|
||||||
|
const days = useMemo(() => {
|
||||||
|
const byDay = new Map<string, Meeting[]>();
|
||||||
|
for (const m of detail) {
|
||||||
|
const k = dayKey(m.datetime);
|
||||||
|
byDay.set(k, [...(byDay.get(k) ?? []), m]);
|
||||||
|
}
|
||||||
|
return [...byDay.entries()].sort((a, b) => new Date(a[1][0].datetime).getTime() - new Date(b[1][0].datetime).getTime());
|
||||||
|
}, [detail]);
|
||||||
|
|
||||||
|
const sel = roster.find((r) => r.agent === selected);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 max-w-[1180px] mx-auto">
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-amber-700 bg-escalated-soft border border-amber-300 rounded-md px-4 py-3">
|
||||||
|
Couldn’t load meetings: {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-10 text-center text-sm text-faint">Loading meetings…</div>
|
||||||
|
) : roster.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-16 text-center">
|
||||||
|
<CalendarClock size={28} className="text-faint" />
|
||||||
|
<div className="text-sm font-semibold text-muted">No meetings scheduled yet</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-[270px_1fr] gap-4">
|
||||||
|
<AgentRail
|
||||||
|
roster={roster}
|
||||||
|
selected={selected}
|
||||||
|
onSelect={setSelected}
|
||||||
|
now={now}
|
||||||
|
todayKey={todayKey}
|
||||||
|
tomorrowKey={tomorrowKey}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Detail: selected agent's agenda */}
|
||||||
|
<div className="flex flex-col gap-4 min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-3 justify-between">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<Avatar name={selected ?? ''} size={40} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="m-0 text-md font-bold text-strong truncate">{selected}</h2>
|
||||||
|
<div className="text-2xs text-faint">
|
||||||
|
{sel?.upcoming ?? 0} upcoming · {sel?.total ?? 0} total
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-0.5 bg-slate-100 rounded-md p-[3px]">
|
||||||
|
{RANGES.map(([id, label]) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => setRange(id)}
|
||||||
|
className={cn(
|
||||||
|
'border-none cursor-pointer font-sans text-xs font-semibold px-3.5 py-1.5 rounded-sm transition-all duration-150',
|
||||||
|
range === id ? 'bg-card text-strong shadow-xs' : 'bg-transparent text-muted',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{days.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-14 text-center rounded-lg border border-border-subtle bg-card">
|
||||||
|
<CalendarClock size={26} className="text-faint" />
|
||||||
|
<div className="text-sm font-semibold text-muted">No meetings in this range</div>
|
||||||
|
<div className="text-2xs text-faint">Try “All”, or pick another agent.</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
{days.map(([k, ms]) => (
|
||||||
|
<div key={k} className="flex flex-col gap-2.5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h3 className="m-0 text-sm font-bold text-strong">{dayLabel(ms[0].datetime, todayKey, tomorrowKey)}</h3>
|
||||||
|
<div className="flex-1 h-px bg-border-subtle" />
|
||||||
|
<span className="text-2xs text-faint font-semibold nums">{ms.length} meetings</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{ms.map((m) => (
|
||||||
|
<MeetingRow
|
||||||
|
key={m.leadId}
|
||||||
|
m={m}
|
||||||
|
past={new Date(m.datetime).getTime() < now.getTime()}
|
||||||
|
onClick={() => navigate(`/leads/${m.leadId}`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
src/screens/worklists.tsx
Normal file
84
src/screens/worklists.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
// The four operational worklist screens. Each lists the leads at the lifecycle
|
||||||
|
// states it owns; every row carries its one primary action (+ Disqualify),
|
||||||
|
// launched in a modal. Each screen shows a few compact stat tiles over its rows.
|
||||||
|
// (The Lead Pipeline screen is the read-only all-leads dashboard.)
|
||||||
|
|
||||||
|
import { Worklist, type TileSpec } from '../components/activities';
|
||||||
|
import { formatINR } from '../components';
|
||||||
|
import type { LeadRecord } from '../api/types';
|
||||||
|
|
||||||
|
const count = (rows: LeadRecord[]) => String(rows.length);
|
||||||
|
const countWhere = (pred: (r: LeadRecord) => boolean) => (rows: LeadRecord[]) => String(rows.filter(pred).length);
|
||||||
|
const sumINR = (col: keyof LeadRecord) => (rows: LeadRecord[]) =>
|
||||||
|
`₹${formatINR(rows.reduce((s, r) => s + (Number(r[col]) || 0), 0))}`;
|
||||||
|
const avg = (col: keyof LeadRecord) => (rows: LeadRecord[]) => {
|
||||||
|
const vals = rows.map((r) => Number(r[col])).filter((n) => !Number.isNaN(n));
|
||||||
|
return vals.length ? String(Math.round(vals.reduce((a, b) => a + b, 0) / vals.length)) : '—';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Qualify (New Lead) + Assign (Qualified). Entry point — hosts Add Lead. */
|
||||||
|
const QUALIFY_TILES: TileSpec[] = [
|
||||||
|
{ label: 'In queue', value: count },
|
||||||
|
{ label: 'Avg lead score', value: avg('lead_score') },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function QualificationScreen() {
|
||||||
|
return (
|
||||||
|
<Worklist
|
||||||
|
states={['New Lead', 'Qualified']}
|
||||||
|
showAddLead
|
||||||
|
tiles={QUALIFY_TILES}
|
||||||
|
emptyHint="No new or qualified leads in the queue."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Log Contact (Assigned) · Schedule (Contacted) · Recommend (Meeting Scheduled). */
|
||||||
|
const ENGAGE_TILES: TileSpec[] = [
|
||||||
|
{ label: 'In queue', value: count },
|
||||||
|
{ label: 'Meetings booked', value: countWhere((r) => !!r.meeting_mode) },
|
||||||
|
{ label: 'Total premium', value: sumINR('premium_amount') },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function InteractionScreen() {
|
||||||
|
return (
|
||||||
|
<Worklist
|
||||||
|
states={['Assigned', 'Contacted', 'Meeting Scheduled']}
|
||||||
|
tiles={ENGAGE_TILES}
|
||||||
|
emptyHint="No leads awaiting contact, scheduling, or a recommendation."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect Documents (Product Recommended) · Assess Eligibility (Documents Collected). */
|
||||||
|
const DOCS_TILES: TileSpec[] = [
|
||||||
|
{ label: 'In queue', value: count },
|
||||||
|
{ label: 'Docs collected', value: countWhere((r) => r.current_state_name === 'Documents Collected') },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DocAssessmentScreen() {
|
||||||
|
return (
|
||||||
|
<Worklist
|
||||||
|
states={['Product Recommended', 'Documents Collected']}
|
||||||
|
tiles={DOCS_TILES}
|
||||||
|
emptyHint="No leads awaiting document collection or assessment."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Underwrite (Eligibility Assessed) · Issue (Underwriting) · Onboard (Policy Issued). */
|
||||||
|
const UW_TILES: TileSpec[] = [
|
||||||
|
{ label: 'In queue', value: count },
|
||||||
|
{ label: 'Policies issued', value: countWhere((r) => r.current_state_name === 'Policy Issued') },
|
||||||
|
{ label: 'Total premium', value: sumINR('premium_amount') },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function UnderwritingPolicyScreen() {
|
||||||
|
return (
|
||||||
|
<Worklist
|
||||||
|
states={['Eligibility Assessed', 'Underwriting', 'Policy Issued']}
|
||||||
|
tiles={UW_TILES}
|
||||||
|
emptyHint="No leads in underwriting, issuance, or onboarding."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -123,10 +123,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
html,
|
html {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
body,
|
body,
|
||||||
#root {
|
#root {
|
||||||
height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user