diff --git a/cleanup-phantom-agents.sql b/cleanup-phantom-agents.sql new file mode 100644 index 0000000..70177bf --- /dev/null +++ b/cleanup-phantom-agents.sql @@ -0,0 +1,42 @@ +-- Cleanup phantom Schedule-page agents (app 385, sandbox) +-- Junk test rows where owner_agent is a bare placeholder string instead of {id,name}. +-- Effect: nulls owner_agent -> these leads collapse into "Unassigned" on the Schedule page. +-- Affected instances: 4489 (Sandy), 4490 (Alex), 4502 (Owner Agent), 4511/4512 (Owner Agent*) +-- +-- NOTE: recordview reads from the denormalized companion table +-- tbl_wf_385_lead_to_policy, which is kept in sync only via the app write path. +-- A raw UPDATE to tbl_appdata_workflow_instances does NOT propagate, so we must +-- clean BOTH tables. + +BEGIN; + +-- Preview before committing +SELECT 'main' AS tbl, instance_id, data->'owner_agent' AS owner_agent +FROM public.tbl_appdata_workflow_instances +WHERE instance_id IN (4489, 4490, 4502, 4511, 4512) +UNION ALL +SELECT 'companion', instance_id, owner_agent +FROM public.tbl_wf_385_lead_to_policy +WHERE instance_id IN (4489, 4490, 4502, 4511, 4512); + +-- 1. Main instance table: drop the bad owner_agent key (only bare-string placeholders) +UPDATE public.tbl_appdata_workflow_instances +SET data = data - 'owner_agent', + updated_at = now() +WHERE instance_id IN (4489, 4490, 4502, 4511, 4512) + AND jsonb_typeof(data->'owner_agent') = 'string'; + +-- 2. Companion recordview table: null out the bad owner_agent (only bare-string placeholders) +UPDATE public.tbl_wf_385_lead_to_policy +SET owner_agent = NULL +WHERE instance_id IN (4489, 4490, 4502, 4511, 4512) + AND jsonb_typeof(owner_agent) = 'string'; + +-- Verify (should return 0 rows) +SELECT 'main' AS tbl, instance_id FROM public.tbl_appdata_workflow_instances +WHERE instance_id IN (4489, 4490, 4502, 4511, 4512) AND data ? 'owner_agent' +UNION ALL +SELECT 'companion', instance_id FROM public.tbl_wf_385_lead_to_policy +WHERE instance_id IN (4489, 4490, 4502, 4511, 4512) AND owner_agent IS NOT NULL; + +COMMIT; diff --git a/flow-findings.md b/flow-findings.md new file mode 100644 index 0000000..f9dbf56 --- /dev/null +++ b/flow-findings.md @@ -0,0 +1,85 @@ +# Lead-to-Policy (Aria) — Live Flow Findings + +App **385**, workflow `e29c3c33` (db `workflow_id=29851`), org 53. Sandbox DB verified (`postgres-sandbox`, read-only) + AI decisions via `https://sandbox.getzino.in/monitor/decisions`. + +Loop: `/loop 4m` (cron `d62147e6`). Each tick re-checks the latest instance, observes what the AI did, recommends the next human payload. + +--- + +## State graph (state → activity → next state) + +| # | State | Human/forward activity | → next state | Disqualify? | +|---|-------|------------------------|--------------|-------------| +| 1 | New Lead | Capture Lead `cc1a73d5` (INIT) | Qualified (via Qualify Lead) | yes | +| 2 | Qualified | **Assign Lead `da61aaa1`** | Assigned | yes | +| 3 | Assigned | Log First Contact `fea6b3a8` | Contacted | yes | +| 4 | Contacted | Schedule Meeting `c444d32f` | Meeting Scheduled | yes | +| 5 | Meeting Scheduled | Recommend Product `c0e2004e` (→ stage-gate `…5a01`) | Product Recommended | yes | +| 6 | Product Recommended | Collect Documents `a8eb0faa` | Documents Collected | yes | +| 7 | Documents Collected | Assess Eligibility `ba3d3e7f` (→ gate `…0a01`) | Eligibility Assessed | yes | +| 8 | Eligibility Assessed | Submit Underwriting `ed8ec0dc` (→ gate `…0b02`) | Underwriting | yes | +| 9 | Underwriting | Issue Policy `7b8fb734` (→ gate `…0c03`) | Policy Issued | yes | +| 10 | Policy Issued | Onboard Customer `ae415f4e` | Customer 360 (END) | — | + +`Disqualify ea99fc60` is allowed in every non-terminal state → **Disqualified** (END). +`AI Product Recommendation 14d734da` is a self-loop on Meeting Scheduled (AI-only, no state change). + +--- + +## AI employees & triggers (verified in `activity_triggers`) + +Trigger = bound to a **source** activity; fires *after* a human performs it, then the bound AI employee autonomously performs the **next** workflow activity. + +| Trigger on activity | AI employee | user_id | Effect | +|---------------------|-------------|---------|--------| +| Capture Lead `cc1a73d5` | Insurance AI (Aria) #6 | **29180** | auto-performs **Qualify Lead** → Qualified | +| Assign Lead `da61aaa1` | Aria (Engagement) #7 | **29181** | auto-performs next (Log First Contact) | +| Log First Contact `fea6b3a8` | Aria (Engagement) #7 | **29181** | auto-advances toward Schedule Meeting | +| Schedule Meeting `c444d32f` | Aria Advisor #9 | **29188** | logs **AI Product Recommendation** decision (self-loop) before human Recommend Product | +| Collect Documents `a8eb0faa` | Aria (Underwriting) #8 | **29182** | auto-advances underwriting steps | +| Onboard Customer `ae415f4e` | *(no AI)* automation pipeline | — | customJS Compute Maturity → generateDoc Policy Schedule → sendEmail welcome | + +Note: Qualify Lead, Recommend Product, Assess Eligibility, Submit Underwriting, Issue Policy have **no** trigger bound → they are human/manual gates (no auto-advance). + +--- + +## Observation log + +### Tick 1 — 2026-06-26 ~07:25 (instance 5017, lead "Parikshith Takkar") + +Flow observed (verified DB + monitor): +- `07:20:46` — **Capture Lead** by human **29183** (Sales Agent). Lead: Bengaluru / Karnataka, Lawyer, ₹23L income, DOB 1976-05-25 (age 50), web, term interest, English. +- Capture trigger fired → **Insurance AI (Aria) 29180** auto-performed **Qualify Lead** at `07:21:31`: + - confidence **0.95**, status **submitted**, model `claude-sonnet-4-5`, 5 LLM calls, cost ~$0.124. + - set lead_score **95**, segment **hot**, annual_income 23,00,000, DOB confirmed. + - knowledge sources: Lead Qualification Guide, ABSLI Product Catalog, ABSLI Underwriting Rules. + - reasoning: age 50 within 18-65 entry; income supports ₹2.76–3.45 cr coverage; Lawyer = stable; no disqualifiers. +- **Current state: Qualified** (`41c7a19f`). No trigger on Qualify Lead → flow waits for human. + +**→ Next human activity = Assign Lead.** Recommended payload below. + +--- + +## ⭐ Recommended payload — next activity: **Assign Lead** (`da61aaa1`) + +Fields (from `activity_data_fields`; field_rules cascade city → region → agent): + +```json +{ + "assign_city": "Bengaluru", + "assigned_region_input": "South", + "owner_agent_input": { "id": 1, "name": "Asha Rao" } +} +``` + +- `assign_city` — select, mapped `city`. Lead already in Bengaluru. Drives region filter. +- `assigned_region_input` — **mandatory** lookup `tbl_branches.region`. Bengaluru branch (id 7, "Bengaluru MG Road") → region **South**. Filtered by city via rule `rule_region_by_city`. +- `owner_agent_input` — optional lookup `tbl_sales_agents` (stores id+name), filtered to region=South via `rule_agent_by_region`. South agents available: **Asha Rao (1)**, Karthik Iyer (7), Divya Menon (8). Pick any; Asha Rao recommended. + +**After you submit Assign Lead:** trigger fires → **Aria (Engagement) 29181** auto-performs the next step (Log First Contact → Contacted). Watch `/monitor/decisions?instance_id=5017` for user `29181`. + +--- + +### Tick 2 — 2026-06-26 ~07:29 — no change + +Instance 5017 still in **Qualified** (`updated_at` 07:21:31, unchanged). Assign Lead not yet submitted. Recommendation above unchanged. diff --git a/src/api/adapters.ts b/src/api/adapters.ts index c4c07a9..ce8061e 100644 --- a/src/api/adapters.ts +++ b/src/api/adapters.ts @@ -5,6 +5,7 @@ import { AI_EMPLOYEES, AI_RECOMMENDATION, STATE_NAME_BY_UID, + SUPER_ROLES, aiEmployeeForState, stageOfState, } from './config'; @@ -246,6 +247,17 @@ export interface TimelineItem { tone: Tone; } +// Audit entries carry no username — only user_id + human-readable roles. Show +// the person's role ("Sales Agent", "Underwriter") instead of a raw "User 5"; +// admins surface as "Admin". +function humanActor(userId: string, roles: string[] | undefined): string { + const r = roles ?? []; + const sup = r.find((role) => SUPER_ROLES.includes(role)); + if (sup) return 'Admin'; + if (r[0]) return r[0]; + return userId === '1' ? 'Admin' : 'Team member'; +} + export function auditToTimeline(entries: AuditEntry[]): TimelineItem[] { const items = [...entries] .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) @@ -259,7 +271,7 @@ export function auditToTimeline(entries: AuditEntry[]): TimelineItem[] { ? `advanced to ${stateName}` : 'performed an activity'; return { - actor: ai?.name ?? (e.user_id === '1' ? 'Admin' : `User ${e.user_id}`), + actor: ai?.name ?? humanActor(e.user_id, e.user_roles), ai: !!ai, action, time: formatTime(e.created_at), diff --git a/src/api/forms.ts b/src/api/forms.ts index fa08e63..8cd8877 100644 --- a/src/api/forms.ts +++ b/src/api/forms.ts @@ -34,6 +34,12 @@ export interface FieldDef { label: string; type: FieldType; required?: boolean; + /** Designer `field_defaults[id].hidden` — render nothing, but still seed + + * submit its mapped value (used for filter-only inputs like assign_city). */ + hidden?: boolean; + /** Designer `field_defaults[id].disabled` — render read-only; the value is + * computed (e.g. premium_amount written by the custom_js). */ + disabled?: boolean; options?: FieldOption[]; prefix?: string; placeholder?: string; diff --git a/src/api/leads.tsx b/src/api/leads.tsx index 2c72b7d..da3c865 100644 --- a/src/api/leads.tsx +++ b/src/api/leads.tsx @@ -28,7 +28,7 @@ const LeadsContext = createContext(null); export function LeadsProvider({ children }: { children: ReactNode }) { const { client } = useZino(); const { data, loading, error, refetch } = useQuery( - () => client.recordView(RECORD_VIEW_IDS.ALL_LEADS, { limit: 200, sortBy: 'instance_id', sortDir: 'desc' }), + () => client.recordView(RECORD_VIEW_IDS.ALL_LEADS, { limit: 200, sortBy: 'updated_at', sortDir: 'desc' }), [], ); diff --git a/src/api/schema.ts b/src/api/schema.ts index 2604e61..6a6cca4 100644 --- a/src/api/schema.ts +++ b/src/api/schema.ts @@ -65,7 +65,14 @@ export function schemaToFields(resp: FormScreenResponse): FieldDef[] { .filter((f): f is FormScreenField => !!f) : resp.fields; - return ordered.map(toFieldDef).filter((d): d is FieldDef => d !== null); + return ordered + .map(toFieldDef) + .filter((d): d is FieldDef => d !== null) + .map((d) => ({ + ...d, + hidden: !!resp.field_defaults?.[d.id]?.hidden, + disabled: !!resp.field_defaults?.[d.id]?.disabled, + })); } /** Single field's def by id — for bespoke screens that need one field's @@ -75,5 +82,12 @@ export function fieldFromSchema( id: string, ): FieldDef | undefined { const f = resp?.fields.find((x) => x.id === id); - return f ? toFieldDef(f) ?? undefined : undefined; + if (!f) return undefined; + const def = toFieldDef(f); + if (!def) return undefined; + return { + ...def, + hidden: !!resp?.field_defaults?.[id]?.hidden, + disabled: !!resp?.field_defaults?.[id]?.disabled, + }; } diff --git a/src/api/types.ts b/src/api/types.ts index 6aba1d1..7f8e73d 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -82,11 +82,49 @@ export interface FormScreenGridItem { y: number; } +/** One condition of a field_rule filter_config. The lookup row's + * `target_column` is compared (currently only `equals`) against the current + * value of `form_field`. When the source field is itself a lookup/object, + * `source_value_key` names the sub-key to read off it (e.g. a region lookup's + * "region" column); empty means use the form value directly. */ +export interface FieldRuleCondition { + operator: string; + form_field: string; + target_column: string; + source_value_key?: string; +} + +/** A designer field_rule. We honour `action: "filter_options"`, which scopes a + * target field's options/lookup rows by another field's current value. */ +export interface FieldRule { + id: string; + action: string; + active?: boolean; + target_field: string; + filter_config?: { + logic?: string; // "AND" | "OR" + conditions?: FieldRuleCondition[]; + } | null; +} + +/** Per-field designer defaults, keyed by field id. `hidden` drops the field + * from the form UI while its mapped value is still seeded + submitted. */ +export interface FieldDefault { + hidden?: boolean; + /** Read-only in the form — value is computed (e.g. premium_amount by custom_js). */ + disabled?: boolean; + value?: unknown; +} + export interface FormScreenResponse { activity_uid: string; activity_name: string; fields: FormScreenField[]; grid_config?: FormScreenGridItem[]; + /** Designer field_rules (cascading filter_options etc.). */ + field_rules?: FieldRule[]; + /** Per-field designer defaults (e.g. `{ assign_city: { hidden: true } }`). */ + field_defaults?: Record; } // --- Audit (GET /app/{appId}/view/audit) --- @@ -208,5 +246,9 @@ export interface LeadRecord { // Written by the Onboard trigger (5d), read off the lead record. welcome_email_status?: string | null; policy_doc?: PolicyDoc[] | null; + // Recordview system columns — last-activity / creation time, used to sort + // worklists latest-first. + created_at?: string | null; + updated_at?: string | null; [key: string]: unknown; } diff --git a/src/components/activities/AssignBody.tsx b/src/components/activities/AssignBody.tsx index 713da3b..fdd31b9 100644 --- a/src/components/activities/AssignBody.tsx +++ b/src/components/activities/AssignBody.tsx @@ -1,8 +1,10 @@ import { useEffect, useMemo, useState } from 'react'; import { CheckCircle2, UserCheck } from 'lucide-react'; -import { AIDecisionCard, Button, Card } from '../'; -import { LookupField } from '../form'; +import { AiSuggestionPanel, Button, Card } from '../'; +import { LookupField, SchemaGuard } from '../form'; import type { LookupValue } from '../form'; +import { SelectField } from '../core/SelectField'; +import { Input } from '../core/Input'; import { useQuery, useZino } from '../../api/provider'; import { decisionToCard } from '../../api/adapters'; import { fieldFromSchema } from '../../api/schema'; @@ -15,17 +17,27 @@ export function AssignBody({ instanceId, record, onSuccess }: ActivityBodyProps) const { client } = useZino(); const schemaQ = useQuery(() => client.formSchema(DEF.uid, instanceId), [instanceId]); + const CITY = useMemo(() => fieldFromSchema(schemaQ.data, 'assign_city'), [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 [city, setCity] = useState(''); const [region, setRegion] = useState(null); const [agent, setAgent] = useState(null); useEffect(() => { if (!record) return; + setCity(String((record as Record).city ?? '')); setRegion((record.assigned_region as LookupValue) ?? null); setAgent((record.owner_agent as LookupValue) ?? null); }, [record]); + // Form values keyed exactly as the rules' `form_field`s reference them. + const formData: Record = { + assign_city: city, + assigned_region_input: region, + owner_agent_input: agent, + }; + const [submitting, setSubmitting] = useState(false); const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null); @@ -51,6 +63,7 @@ export function AssignBody({ instanceId, record, onSuccess }: ActivityBodyProps) } return ( +
{result && ( @@ -68,6 +81,36 @@ export function AssignBody({ instanceId, record, onSuccess }: ActivityBodyProps)
+ {/* assign_city is filter-only: when the designer marks it hidden + (field_defaults.assign_city.hidden), we don't render it but still + seed it from the lead's city and send it so region filters. */} + {CITY && + !CITY.hidden && + (CITY.options?.length ? ( + { + setCity(v); + setRegion(null); // city drives the region list — drop stale picks + setAgent(null); + }} + options={CITY.options} + placeholder={CITY.required ? 'Select…' : '— None —'} + /> + ) : ( + { + setCity(e.target.value); + setRegion(null); + setAgent(null); + }} + /> + ))} {REGION && ( )} {OWNER && ( @@ -96,10 +140,9 @@ export function AssignBody({ instanceId, record, onSuccess }: ActivityBodyProps) instanceId={instanceId} value={agent} onChange={setAgent} - formData={{ assigned_region_input: region }} + formData={formData} /> )} - {schemaQ.loading &&
Loading form…
}
+ ); } @@ -138,11 +182,5 @@ function AssignAiSuggestion({ instanceId }: { instanceId: number | string }) { const q = useQuery(() => client.aiDecisions(instanceId as number), [instanceId]); const latest = (q.data ?? [])[0]; if (!latest) return null; - const card = decisionToCard(latest); - return ( - <> -
AI suggestion
- - - ); + return ; } diff --git a/src/components/activities/DocumentsBody.tsx b/src/components/activities/DocumentsBody.tsx index d2bfe33..9796854 100644 --- a/src/components/activities/DocumentsBody.tsx +++ b/src/components/activities/DocumentsBody.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { AlertTriangle, CheckCircle2, FileText, Info, ShieldCheck, UploadCloud } from 'lucide-react'; import { Avatar, Badge, Button, Card, DocumentUploadCard, formatINR, Input, SelectField } from '../'; import type { BadgeTone, DocStatus, OcrField } from '../'; +import { SchemaGuard } from '../form'; import { useQuery, useZino } from '../../api/provider'; import { fieldFromSchema } from '../../api/schema'; import { ACTIVITIES } from '../../api/config'; @@ -51,6 +52,15 @@ function OcrCapture({ const [fields, setFields] = useState([]); const [error, setError] = useState(null); + function reset() { + setStatus('empty'); + setFileName(null); + setFields([]); + setError(null); + onFile(null); + onExtract(docKey, { name: null, dob: null }); // clear this doc's KYC cross-check + } + async function handlePick(file: File) { setFileName(file.name); setStatus('processing'); @@ -81,7 +91,7 @@ function OcrCapture({ } return ( -
+
inputRef.current?.click()} onConfirm={() => setStatus('verified')} + onReplace={() => inputRef.current?.click()} + onRemove={reset} /> {error &&
{error}
}
@@ -188,6 +200,11 @@ export function DocumentsBody({ instanceId, record, onSuccess }: ActivityBodyPro const completion = Math.round((checklist.filter((c) => c.done).length / checklist.length) * 100); return ( +
@@ -354,6 +371,7 @@ export function DocumentsBody({ instanceId, record, onSuccess }: ActivityBodyPro ) : null}
+ ); } diff --git a/src/components/activities/IssuePolicyBody.tsx b/src/components/activities/IssuePolicyBody.tsx index 660d645..102e086 100644 --- a/src/components/activities/IssuePolicyBody.tsx +++ b/src/components/activities/IssuePolicyBody.tsx @@ -1,7 +1,8 @@ import { useEffect, useMemo, useState } from 'react'; import { CheckCircle2 } from 'lucide-react'; import { Button, Card, formatINR, Input } from '../'; -import { useZino } from '../../api/provider'; +import { SchemaGuard } from '../form'; +import { useQuery, useZino } from '../../api/provider'; import { ACTIVITIES } from '../../api/config'; import type { ActivityBodyProps } from './types'; @@ -22,6 +23,11 @@ function addYears(iso: string, years: number): string { export function IssuePolicyBody({ instanceId, record, onSuccess }: ActivityBodyProps) { const { client } = useZino(); + // Fields are hand-built (no schema-driven options), but we still fetch the + // form schema so an RBAC denial gates the form up front instead of only + // surfacing on submit. + const schemaQ = useQuery(() => client.formSchema(ACTIVITIES.ISSUE_POLICY.uid, instanceId), [instanceId]); + const [issueDate, setIssueDate] = useState(todayISO()); const [term, setTerm] = useState(20); const [premium, setPremium] = useState(0); @@ -64,6 +70,7 @@ export function IssuePolicyBody({ instanceId, record, onSuccess }: ActivityBodyP const lead = record; return ( +
{result && ( @@ -147,5 +154,6 @@ export function IssuePolicyBody({ instanceId, record, onSuccess }: ActivityBodyP
+
); } diff --git a/src/components/activities/RecommendBody.tsx b/src/components/activities/RecommendBody.tsx index 6fee624..7713a4d 100644 --- a/src/components/activities/RecommendBody.tsx +++ b/src/components/activities/RecommendBody.tsx @@ -1,18 +1,17 @@ import { useEffect, useMemo, useState } from 'react'; -import { CheckCircle2, ChevronRight, Sparkles, Wand2 } from 'lucide-react'; +import { CheckCircle2, ChevronRight, ShieldCheck, Sparkles, Wand2 } from 'lucide-react'; import { - AIDecisionCard, + AiSuggestionPanel, Avatar, Button, Card, - ConfidenceRing, formatINR, Input, MultiSelect, RupeeAmount, SelectField, } from '../'; -import { LookupField } from '../form'; +import { LookupField, SchemaGuard } from '../form'; import type { LookupValue } from '../form'; import { useQuery, useZino } from '../../api/provider'; import { decisionToCard, recommendationFromDecisions } from '../../api/adapters'; @@ -41,6 +40,9 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro const productField = useMemo(() => fieldFromSchema(schemaQ.data, F.product), [schemaQ.data]); const freqOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.premiumFrequency)?.options ?? [], [schemaQ.data]); const riderOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.riders)?.options ?? [], [schemaQ.data]); + // Premium Amount is designer-disabled (field_defaults.premium_amount_input.disabled): + // the custom_js computes it from the rate-card, so it's read-only, never typed. + const premiumDisabled = useMemo(() => fieldFromSchema(schemaQ.data, F.premiumAmount)?.disabled ?? false, [schemaQ.data]); const [product, setProduct] = useState(null); const [sum, setSum] = useState(2000000); @@ -67,7 +69,9 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro // Mirror the designer custom_js: rate-card premium + sa_valid (the stage-gate // field). Without sa_valid the lead can't advance past Meeting Scheduled. const calc = recommendCalc(product, record?.date_of_birth, sum, record?.annual_income); - const effectivePremium = premiumTouched ? premium : calc.premium; + // When the field is designer-disabled, the premium is always the computed + // rate-card value — a manual override (premiumTouched) can't apply. + const effectivePremium = !premiumDisabled && premiumTouched ? premium : calc.premium; const issues = saValidIssues(calc); const [submitting, setSubmitting] = useState(false); @@ -146,7 +150,17 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro } } + // Product / frequency / riders render from the live schema. Without a guard a + // failed schema fetch (most often an RBAC denial — only some roles can + // recommend) silently drops those fields, leaving a broken half-form with no + // explanation. SchemaGuard surfaces loading + permission/error instead. return ( +
{result && ( @@ -162,7 +176,7 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro
)} - +
{productField && ( { + if (premiumDisabled) return; setPremium(Number(e.target.value) || 0); setPremiumTouched(true); }} @@ -268,6 +294,7 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro />
+
); } @@ -317,12 +344,7 @@ function RecommendAiSuggestion({ instanceId, onApply, applying, applyNote }: Rec if (!rec) { const latest = decisions[0]; if (!latest) return null; - return ( - <> -
AI suggestion
- - - ); + return ; } return ( @@ -345,71 +367,77 @@ function AriaRecommendationCard({ applyNote: string | null; }) { const [open, setOpen] = useState(false); + const autonomous = rec.confidence >= 0.7; + const accent = autonomous ? 'var(--status-autonomous)' : 'var(--status-escalated)'; + const pct = Math.round(Math.max(0, Math.min(1, rec.confidence)) * 100); + return ( -
- -
-
-
- -
-
- {rec.employee} - - {rec.role} - -
-
- Recommended before your meeting - {rec.time && · {rec.time}} -
-
-
- - {/* what it recommended */} -
-
- Recommends -
-
{rec.product}
- {rec.sumAssured != null && ( -
- Suggested cover - ₹{formatINR(rec.sumAssured)} -
- )} -
- - {/* what it thought */} - {rec.rationale && ( -
- - {open && ( -

- {rec.rationale} -

- )} -
- )} - -
- - {applyNote &&
{applyNote}
} +
+ {/* header — identity + confidence */} +
+ +
+
{rec.employee}
+
+ Pre-meeting suggestion{rec.time && · {rec.time}}
+ + + {pct}% + +
-
- +
+ {/* recommended product — navy hero tile, echoes the premium card */} +
+
+ + + +
+
+ Recommends +
+
{rec.product}
+
+
+ {rec.sumAssured != null && ( +
+ Suggested cover + ₹{formatINR(rec.sumAssured)} +
+ )} +
+ + {/* why it recommended this — expands inline, no inner scrollbar */} + {rec.rationale && ( +
+ + {open && ( +

+ {rec.rationale} +

+ )} +
+ )} + +
+ + {applyNote &&
{applyNote}
}
diff --git a/src/components/activities/UnderwritingBody.tsx b/src/components/activities/UnderwritingBody.tsx index 6f75be1..84d21df 100644 --- a/src/components/activities/UnderwritingBody.tsx +++ b/src/components/activities/UnderwritingBody.tsx @@ -1,10 +1,10 @@ import { useEffect, useMemo, useState } from 'react'; -import { CheckCircle2 } from 'lucide-react'; -import { Button, Card, formatINR, SelectField } from '../'; +import { AlertTriangle, CheckCircle2, Sparkles } from 'lucide-react'; +import { Button, Card, SelectField } from '../'; +import { SchemaGuard } from '../form'; import { useQuery, useZino } from '../../api/provider'; import { fieldFromSchema } from '../../api/schema'; import { ACTIVITIES } from '../../api/config'; -import { assess } from '../../lib/underwriting'; import type { ActivityBodyProps } from './types'; const F = ACTIVITIES.SUBMIT_UNDERWRITING.fields; @@ -12,7 +12,7 @@ const F = ACTIVITIES.SUBMIT_UNDERWRITING.fields; export function UnderwritingBody({ instanceId, record, onSuccess }: ActivityBodyProps) { const { client } = useZino(); - const schemaQ = useQuery(() => client.formSchema(ACTIVITIES.SUBMIT_UNDERWRITING.uid, instanceId), []); + const schemaQ = useQuery(() => client.formSchema(ACTIVITIES.SUBMIT_UNDERWRITING.uid, instanceId), [instanceId]); const statusOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.status)?.options ?? [], [schemaQ.data]); const [status, setStatus] = useState('approved'); @@ -20,8 +20,6 @@ export function UnderwritingBody({ instanceId, record, onSuccess }: ActivityBody if (record?.underwriting_status) setStatus(String(record.underwriting_status)); }, [record]); - const a = useMemo(() => (record ? assess(record) : null), [record]); - const [submitting, setSubmitting] = useState(false); const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null); @@ -44,9 +42,12 @@ export function UnderwritingBody({ instanceId, record, onSuccess }: ActivityBody } } - const lead = record; - return ( +
{result && ( @@ -63,11 +64,6 @@ export function UnderwritingBody({ instanceId, record, onSuccess }: ActivityBody )} - {lead?.eligibility_notes && ( -
- Eligibility notes: {lead.eligibility_notes} -
- )}
-
-
- Underwriting assessment + {record?.eligibility_notes ? ( + + ) : ( +
+ No eligibility assessment on file yet — Aria Eligibility writes it when documents are collected.
- {a && a.ok ? ( - <> -
- Applicant age - {a.age ?? '—'} -
-
- Sum assured - ₹{formatINR(a.sumAssured)} -
-
- Eligibility - {lead?.eligibility_status ?? '—'} -
- -
- -
-
- Financial -
-
- {a.financialText} -
-
- -
-
- Medical -
-
- {a.medicalText} -
-
- -
- Route - {a.route.map((r) => ( - - {r} - - ))} -
- - ) : ( -
- {record ? 'Not enough data (DOB / income / sum assured) to assess.' : 'Not enough data to assess.'} -
- )} -
-
- Assessment is advisory and computed in-app from the lead's data. NML limits are - placeholder values pending the real ABSLI grid. -
+ )}
+ + ); +} + +// --- Eligibility notes (AI-generated, free-form) ------------------------------- +// Aria Eligibility writes eligibility_notes as semi-structured prose: +// "DOCUMENTS: …", "ELIGIBILITY CHECK (…):", "✓ Age …", "REFERRAL REASON: …". +// We parse it leniently into headings / check-items / bullets / paragraphs. +// Nothing is guaranteed — if no structure is detected we fall back to the raw +// text exactly as the model emitted it. Rendered as React children (escaped), +// never dangerouslySetInnerHTML. + +type NoteBlock = + | { kind: 'heading'; text: string; inline?: string } + | { kind: 'check'; ok: boolean; text: string } + | { kind: 'bullet'; text: string } + | { kind: 'para'; text: string }; + +function parseNotes(raw: string): NoteBlock[] { + const blocks: NoteBlock[] = []; + for (const line of raw.split(/\r?\n/)) { + const t = line.trim(); + if (!t) continue; + let m: RegExpMatchArray | null; + if ((m = t.match(/^[✓✔☑]\s*(.+)$/))) { + blocks.push({ kind: 'check', ok: true, text: m[1] }); + continue; + } + if ((m = t.match(/^[✗✘×✕☒]\s*(.+)$/))) { + blocks.push({ kind: 'check', ok: false, text: m[1] }); + continue; + } + if ((m = t.match(/^[-•*]\s+(.+)$/))) { + blocks.push({ kind: 'bullet', text: m[1] }); + continue; + } + // Heading: short prefix before a colon whose first word is ALL-CAPS + // (DOCUMENTS, ELIGIBILITY, REFERRAL). Keeps mixed-case tails like + // "(ABSLI DigiShield Plan)" inside the heading. + const colon = t.indexOf(':'); + if (colon > 1 && colon <= 60) { + const head = t.slice(0, colon).trim(); + const firstWord = (head.split(/\s+/)[0] || '').replace(/[^A-Za-z]/g, ''); + if (firstWord.length >= 2 && firstWord === firstWord.toUpperCase()) { + const inline = t.slice(colon + 1).trim(); + blocks.push({ kind: 'heading', text: head, inline: inline || undefined }); + continue; + } + } + blocks.push({ kind: 'para', text: t }); + } + return blocks; +} + +function EligibilityNotes({ notes, status }: { notes: string; status?: string | null }) { + const blocks = useMemo(() => parseNotes(notes), [notes]); + const structured = blocks.some((b) => b.kind !== 'para'); + + const s = (status ?? '').toLowerCase(); + // On the navy card, status reads as a tinted text on a translucent chip. + const chipText = + s === 'eligible' ? 'text-emerald-300' : s === 'ineligible' ? 'text-ruby-500' : 'text-amber-300'; // refer / unknown + + let first = true; + + return ( +
+
+
+ + Eligibility assessment · Aria Eligibility +
+ {status && ( + + {status} + + )} +
+ + {structured ? ( +
+ {blocks.map((b, i) => { + if (b.kind === 'heading') { + const el = ( +
+
{b.text}
+ {b.inline &&
{b.inline}
} +
+ ); + first = false; + return el; + } + if (b.kind === 'check') { + return ( +
+ {b.ok ? ( + + ) : ( + + )} + {b.text} +
+ ); + } + if (b.kind === 'bullet') { + return ( +
+ + {b.text} +
+ ); + } + return ( +

+ {b.text} +

+ ); + })} +
+ ) : ( + // Fallback — render the model's output verbatim, line breaks preserved. +

{notes}

+ )} +
); } diff --git a/src/components/activities/Worklist.tsx b/src/components/activities/Worklist.tsx index c640bf0..40cbd87 100644 --- a/src/components/activities/Worklist.tsx +++ b/src/components/activities/Worklist.tsx @@ -11,6 +11,13 @@ import { LeadActions, AddLeadButton } from './ActivityActions'; const PAGE_SIZE = 10; +/** Epoch ms for a recordview timestamp; 0 (oldest) when missing/unparseable. */ +function ms(ts?: string | null): number { + if (!ts) return 0; + const t = new Date(ts).getTime(); + return Number.isNaN(t) ? 0 : t; +} + /** A compact stat tile computed over this screen's (already state-filtered) rows. */ export interface TileSpec { label: string; @@ -34,14 +41,16 @@ export function Worklist({ states, emptyHint, showAddLead, tiles }: WorklistProp const { records, fields, loading, refetch } = useLeads(); const [filters, setFilters] = useState(EMPTY_FILTER); - const order = useMemo(() => new Map(states.map((s, i) => [s, i])), [states]); - // Scope to this screen's states, then apply the search/filter bar. + const allowed = useMemo(() => new Set(states), [states]); + // Scope to this screen's states, latest-first: most recently actioned + // (updated_at) at the top, instance_id as the tiebreaker. Sorting by state + // would bury a freshly-updated lead under older ones in an earlier state. 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], + .filter((r) => allowed.has(r.current_state_name ?? '')) + .sort((a, b) => ms(b.updated_at) - ms(a.updated_at) || Number(b.instance_id) - Number(a.instance_id)), + [records, allowed], ); const recs = useMemo(() => applyFilters(scoped, filters, fields), [scoped, filters, fields]); const rows = useMemo(() => recs.map((record) => ({ record, lead: recordToLead(record) })), [recs]); diff --git a/src/components/core/Avatar.tsx b/src/components/core/Avatar.tsx index be97292..a9135c9 100644 --- a/src/components/core/Avatar.tsx +++ b/src/components/core/Avatar.tsx @@ -1,5 +1,5 @@ import type { CSSProperties } from 'react'; -import { Sparkles } from 'lucide-react'; +import { Lightbulb, Phone, ShieldCheck, Sparkles, type LucideIcon } from 'lucide-react'; import { cn } from '../../lib/cn'; export interface AvatarProps { @@ -26,6 +26,25 @@ function initials(name = ''): string { return ((p[0]?.[0] || '') + (p[1]?.[0] || '')).toUpperCase(); } +// Each AI employee does something distinct, so each gets its own glyph + glow +// instead of one shared orange bot. Matched by name; unrecognised AI actors +// (and plain "Aria", the Qualifier) fall back to the sunrise Sparkles look. +type AiPersona = { Icon: LucideIcon; bg: string; shadow: string; color: string }; +const AI_PERSONAS: Array<{ match: RegExp } & AiPersona> = [ + { match: /engage/i, Icon: Phone, bg: 'linear-gradient(135deg,#19C2A8,#0E9466)', shadow: '0 2px 10px rgba(14,148,102,.45)', color: '#0E9466' }, + { match: /underwrite/i, Icon: ShieldCheck, bg: 'linear-gradient(135deg,#4F9BF5,#2480DB)', shadow: '0 2px 10px rgba(36,128,219,.45)', color: '#2480DB' }, + { match: /advisor/i, Icon: Lightbulb, bg: 'linear-gradient(135deg,#9B7BE8,#7C5BD6)', shadow: '0 2px 10px rgba(124,91,214,.45)', color: '#7C5BD6' }, +]; +function aiPersona(name: string): AiPersona | null { + return AI_PERSONAS.find((p) => p.match.test(name)) ?? null; +} + +/** Solid accent color for an AI employee, matched by name. Falls back to the + * shared sunrise orange for unrecognised actors (and plain "Aria"). */ +export function aiEmployeeColor(name = ''): string { + return aiPersona(name)?.color ?? 'var(--sunrise-600, #F26B3A)'; +} + /** Circular avatar with deterministic color from name; supports AI-employee glyph. */ export function Avatar({ name = '', src, size = 40, ai = false, className, style }: AvatarProps) { const dim: CSSProperties = { width: size, height: size, fontSize: Math.round(size * 0.4) }; @@ -41,16 +60,24 @@ export function Avatar({ name = '', src, size = 40, ai = false, className, style ); } + const persona = ai ? aiPersona(name) : null; + const AiIcon = persona?.Icon ?? Sparkles; + return ( - {ai ? : initials(name)} + {ai ? : initials(name)} ); } diff --git a/src/components/core/Input.tsx b/src/components/core/Input.tsx index 6a68bd4..f084131 100644 --- a/src/components/core/Input.tsx +++ b/src/components/core/Input.tsx @@ -22,10 +22,11 @@ export function Input({ suffix, required, className, + disabled, ...rest }: InputProps) { return ( -
, document.body, diff --git a/src/components/core/index.ts b/src/components/core/index.ts index 5a09525..c2197a4 100644 --- a/src/components/core/index.ts +++ b/src/components/core/index.ts @@ -4,7 +4,7 @@ export { Card } from './Card'; export type { CardProps, Elevation } from './Card'; export { Badge } from './Badge'; export type { BadgeProps, BadgeTone } from './Badge'; -export { Avatar } from './Avatar'; +export { Avatar, aiEmployeeColor } from './Avatar'; export type { AvatarProps } from './Avatar'; export { Input } from './Input'; export type { InputProps } from './Input'; diff --git a/src/components/form/ActivityForm.tsx b/src/components/form/ActivityForm.tsx index b01ccd6..f7a1486 100644 --- a/src/components/form/ActivityForm.tsx +++ b/src/components/form/ActivityForm.tsx @@ -1,11 +1,12 @@ -import { useEffect, useMemo, useState } from 'react'; -import { CheckCircle2, Lock } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { CheckCircle2, FileText, UploadCloud } from 'lucide-react'; import { Button } from '../core/Button'; import { Input } from '../core/Input'; import { SelectField } from '../core/SelectField'; import { MultiSelect } from '../core/MultiSelect'; import { LookupField } from './LookupField'; import type { LookupValue } from './LookupField'; +import { isPermissionError, SchemaGuard } from './SchemaGuard'; import { useQuery, useZino } from '../../api/provider'; import { schemaToFields } from '../../api/schema'; import type { ActivityMeta, FieldDef } from '../../api/forms'; @@ -45,17 +46,13 @@ function seedFor(field: FieldDef, record?: LeadRecord | null): unknown { return []; case 'boolean': return false; + case 'file': + return null; // holds a File once picked; uploaded on submit default: return ''; } } -/** True for a backend RBAC denial — so we can show a human line, not the raw - * "user N does not have permission for activity ". */ -export function isPermissionError(msg?: string | null): boolean { - return !!msg && /permission|not authoriz|unauthoriz|forbidden|\b403\b/i.test(msg); -} - /** Renders an activity's fields from the live form-screens schema and submits * via performActivity (or startInstance when no instanceId is given). */ export function ActivityForm({ meta, instanceId, record, onSuccess, successMessage }: ActivityFormProps) { @@ -86,7 +83,7 @@ export function ActivityForm({ meta, instanceId, record, onSuccess, successMessa const missing = useMemo( () => fields.filter((f) => { - if (!f.required) return false; + if (!f.required || f.hidden) return false; // hidden fields are auto-seeded, not user-entered const v = values[f.id]; if (Array.isArray(v)) return v.length === 0; return v === '' || v === null || v === undefined; @@ -102,17 +99,23 @@ export function ActivityForm({ meta, instanceId, record, onSuccess, successMessa setSubmitting(true); setResult(null); - // Drop empty optional values so we don't overwrite with blanks. - const data: Record = {}; - for (const f of fields) { - const v = values[f.id]; - if (v === '' || v === null || v === undefined) continue; - if (Array.isArray(v) && v.length === 0) continue; - data[f.id] = - f.type === 'number' ? Number(v) || 0 : f.type === 'datetime' ? toRfc3339(String(v)) : v; - } - try { + // Drop empty optional values so we don't overwrite with blanks. + const data: Record = {}; + for (const f of fields) { + const v = values[f.id]; + if (v === '' || v === null || v === undefined) continue; + if (Array.isArray(v) && v.length === 0) continue; + if (f.type === 'file') { + if (!(v instanceof File)) continue; + const fileMeta = await client.uploadFile(v, { activityId: meta.uid, fieldId: f.id, instanceId }); + data[f.id] = [fileMeta]; + continue; + } + data[f.id] = + f.type === 'number' ? Number(v) || 0 : f.type === 'datetime' ? toRfc3339(String(v)) : v; + } + const res = instanceId != null ? await client.performActivity(instanceId, meta.uid, data) @@ -130,34 +133,16 @@ export function ActivityForm({ meta, instanceId, record, onSuccess, successMessa } } - if (schemaQ.loading) { - return
Loading form…
; - } - if (schemaQ.error || !fields.length) { - if (isPermissionError(schemaQ.error)) { - return ( -
- -
-
You don’t have permission for this action
-
- Your role can’t {meta.name.toLowerCase()}. Ask an admin to grant your role access to this activity. -
-
-
- ); - } - return ( -
- {schemaQ.error ? `Couldn't load the form: ${schemaQ.error}` : 'This activity has no input fields.'} -
- ); - } - return ( +
- {fields.map((f) => ( + {fields.filter((f) => !f.hidden).map((f) => ( set(f.id, v)} activityId={meta.uid} instanceId={instanceId} + formData={values} /> ))}
@@ -188,6 +174,69 @@ export function ActivityForm({ meta, instanceId, record, onSuccess, successMessa
+ + ); +} + +/** File picker for schema `file` fields. Holds the chosen File in form state; + * ActivityForm uploads it on submit and sends the returned [meta]. */ +function FileInput({ + label, + required, + value, + onChange, + className, +}: { + label: string; + required?: boolean; + value: unknown; + onChange: (f: File | null) => void; + className?: string; +}) { + const ref = useRef(null); + const file = value instanceof File ? value : null; + return ( +
+ + {label} + {required && *} + + { + onChange(e.target.files?.[0] ?? null); + e.target.value = ''; + }} + /> + +
); } @@ -197,17 +246,34 @@ function Field({ onChange, activityId, instanceId, + formData, }: { field: FieldDef; value: unknown; onChange: (v: unknown) => void; activityId: string; instanceId?: number | string; + formData?: Record; }) { - const full = field.type === 'longtext' || field.type === 'multiselect' || field.type === 'lookup'; + const full = + field.type === 'longtext' || + field.type === 'multiselect' || + field.type === 'lookup' || + field.type === 'file'; const wrap = full ? 'col-span-full' : ''; switch (field.type) { + case 'file': + return ( + + ); + case 'longtext': return (
); diff --git a/src/components/form/LookupField.tsx b/src/components/form/LookupField.tsx index e21b199..66a8795 100644 --- a/src/components/form/LookupField.tsx +++ b/src/components/form/LookupField.tsx @@ -19,8 +19,11 @@ export interface LookupFieldProps { instanceId?: number | string; value: LookupValue | null; onChange: (v: LookupValue | null) => void; - /** Current form values, so the server can apply field_rules filter_options - * (cascading lookups — e.g. owner agents scoped to the picked region). */ + /** Current form values — sent to the lookup endpoint so the server applies + * the activity's field_rules filter_options (cascading lookups, e.g. owner + * agents scoped to the picked region, regions scoped to the picked city). + * Keys MUST match the rules' `form_field`s; lookup values stay objects so + * the server can read a rule's `source_value_key` sub-column. */ formData?: Record; } @@ -81,6 +84,9 @@ export function LookupField({ client .lookupRecords(templateUid, { activityId, fieldId, instanceId, search, limit: 50, formData }) .then((r) => { + // The server scopes rows via filter_options using the form_data we + // sent (target columns like `city` aren't selected into the rows, so + // they can only be filtered server-side). if (live) setRows(dedupe(r.records ?? [])); }) .catch(() => { diff --git a/src/components/form/SchemaGuard.tsx b/src/components/form/SchemaGuard.tsx new file mode 100644 index 0000000..f0a7a4f --- /dev/null +++ b/src/components/form/SchemaGuard.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from 'react'; +import { Lock } from 'lucide-react'; + +/** True for a backend RBAC denial — so we can show a human line, not the raw + * "user N does not have permission for activity ". */ +export function isPermissionError(msg?: string | null): boolean { + return !!msg && /permission|not authoriz|unauthoriz|forbidden|\b403\b/i.test(msg); +} + +interface SchemaGuardProps { + loading: boolean; + error: string | null; + /** Verb phrase for the denial copy, e.g. "collect documents for this lead". */ + action: string; + /** True when the schema loaded but yielded no usable fields. */ + empty?: boolean; + children: ReactNode; +} + +/** Gate any activity body on its form-screens fetch. Until the schema resolves + * cleanly we never render the form: an RBAC denial (or any load error) shows a + * human-readable card instead of a live-looking form that 403s only on submit. + * Bespoke bodies and the generic share this so the guard can't + * drift out of sync between them. */ +export function SchemaGuard({ loading, error, action, empty, children }: SchemaGuardProps) { + if (loading) { + return
Loading form…
; + } + if (error || empty) { + if (isPermissionError(error)) { + return ( +
+ +
+
You don’t have permission for this action
+
+ Your role can’t {action}. Ask an admin to grant your role access to this activity, or + sign in as a role that can. +
+
+
+ ); + } + return ( +
+ {error ? `Couldn’t load the form: ${error}` : 'This activity has no input fields.'} +
+ ); + } + return <>{children}; +} diff --git a/src/components/form/index.ts b/src/components/form/index.ts index a2a033c..b27d705 100644 --- a/src/components/form/index.ts +++ b/src/components/form/index.ts @@ -1,2 +1,3 @@ export * from './ActivityForm'; export * from './LookupField'; +export * from './SchemaGuard'; diff --git a/src/components/insurance/AIDecisionCard.tsx b/src/components/insurance/AIDecisionCard.tsx index 1346f57..46ec349 100644 --- a/src/components/insurance/AIDecisionCard.tsx +++ b/src/components/insurance/AIDecisionCard.tsx @@ -60,81 +60,80 @@ export function AIDecisionCard({ style={autonomous ? undefined : { background: 'var(--status-escalated)' }} /> -
- {/* left: identity + content */} -
-
- -
-
- {employee} - - {role} - -
-
- {activity} - {time && · {time}} -
-
-
- - {/* status pill */} -
- - {autonomous ? 'AUTONOMOUS DECISION' : 'ESCALATED TO HUMAN'} + {/* header band — identity + confidence, tinted by autonomy state */} +
+ +
+
+ {employee} + + {role} +
+
+ {activity} + {time && · {time}} +
+
+
+ +
+
- {/* one-line reasoning summary */} -

{summary}

- - {/* expandable full reasoning */} - {reasoning && ( -
- - {open && ( -
- -
- )} -
- )} - - {/* citation chips */} - {citations.length > 0 && ( -
-
- Cited knowledge -
-
- {citations.map((c, i) => ( - - - {typeof c === 'string' ? c : c.title} - - ))} -
-
- )} +
+ {/* status pill */} +
+ + {autonomous ? 'AUTONOMOUS DECISION' : 'ESCALATED TO HUMAN'}
- {/* right: confidence ring */} -
- -
+ {/* one-line reasoning summary */} +

{summary}

+ + {/* expandable full reasoning */} + {reasoning && ( +
+ + {open && ( +
+ +
+ )} +
+ )} + + {/* citation chips */} + {citations.length > 0 && ( +
+
+ Cited knowledge +
+
+ {citations.map((c, i) => ( + + + {typeof c === 'string' ? c : c.title} + + ))} +
+
+ )}
); diff --git a/src/components/insurance/AIDecisionStream.tsx b/src/components/insurance/AIDecisionStream.tsx index f4720a8..e27e697 100644 --- a/src/components/insurance/AIDecisionStream.tsx +++ b/src/components/insurance/AIDecisionStream.tsx @@ -163,7 +163,7 @@ function DecisionRow({ {open && (d.reasoning || d.citations.length > 0) && (
{d.reasoning && ( -
+
)} diff --git a/src/components/insurance/AiSuggestionPanel.tsx b/src/components/insurance/AiSuggestionPanel.tsx new file mode 100644 index 0000000..210f0c5 --- /dev/null +++ b/src/components/insurance/AiSuggestionPanel.tsx @@ -0,0 +1,92 @@ +import { useState } from 'react'; +import { Sparkles, ChevronDown, ChevronUp, Check, AlertTriangle } from 'lucide-react'; +import { cn } from '../../lib/cn'; +import { Avatar } from '../core/Avatar'; +import { AIDecisionCard } from './AIDecisionCard'; +import type { AIDecisionCardProps } from './AIDecisionCard'; + +export interface AiSuggestionPanelProps extends AIDecisionCardProps { + /** Eyebrow heading above the card. @default "AI suggestion" */ + label?: string; + /** Start as a one-line strip, expandable to the full card. @default true */ + collapsible?: boolean; + className?: string; +} + +/** + * Sidebar framing for an AI employee's decision inside an activity form. + * Defaults to a compact one-line strip (avatar · name · confidence · summary) + * so it sits comfortably beside small forms, and expands to the full + * AIDecisionCard on click. Pass `collapsible={false}` to always show the card. + */ +export function AiSuggestionPanel({ + label = 'AI suggestion', + collapsible = true, + className, + ...card +}: AiSuggestionPanelProps) { + const [open, setOpen] = useState(!collapsible); + + const { + employee = 'Aria', + confidence = 0.85, + threshold = 0.7, + summary, + } = card; + const autonomous = confidence >= threshold; + const accent = autonomous ? 'var(--status-autonomous)' : 'var(--status-escalated)'; + const accentSoft = autonomous ? 'var(--status-autonomous-soft)' : 'var(--status-escalated-soft)'; + const pct = Math.round(Math.max(0, Math.min(1, confidence)) * 100); + + return ( +
+
+
+ + {label} +
+ {collapsible && open && ( + + )} +
+ + {open ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/components/insurance/DocumentUploadCard.tsx b/src/components/insurance/DocumentUploadCard.tsx index ba13228..24ac26d 100644 --- a/src/components/insurance/DocumentUploadCard.tsx +++ b/src/components/insurance/DocumentUploadCard.tsx @@ -20,6 +20,10 @@ export interface DocumentUploadCardProps { fileName?: string | null; onUpload?: () => void; onConfirm?: () => void; + /** Pick a different file (re-runs OCR). Shown once a doc is uploaded. */ + onReplace?: () => void; + /** Clear the uploaded doc + its extracted fields. */ + onRemove?: () => void; className?: string; } @@ -53,12 +57,14 @@ export function DocumentUploadCard({ fileName = null, onUpload, onConfirm, + onReplace, + onRemove, className, }: DocumentUploadCardProps) { const isEmpty = status === 'empty'; return ( -
+
@@ -72,19 +78,19 @@ export function DocumentUploadCard({ {STATUS_BADGE[status]}
-
+
{isEmpty ? ( ) : ( -
+
OCR-extracted fields
@@ -119,15 +125,34 @@ export function DocumentUploadCard({
))} - {status === 'extracted' && ( - - )} + {/* mt-auto keeps the action row pinned to the card bottom, so both + cards' Confirm/Replace align even with differing field counts. */} +
+ {status === 'extracted' && ( + + )} + {(onReplace || onRemove) && status !== 'processing' && ( +
+ {onReplace && ( + + )} + {onReplace && onRemove && ·} + {onRemove && ( + + )} +
+ )} +
)}
diff --git a/src/components/insurance/ReasoningBody.tsx b/src/components/insurance/ReasoningBody.tsx index 12f2563..33885b1 100644 --- a/src/components/insurance/ReasoningBody.tsx +++ b/src/components/insurance/ReasoningBody.tsx @@ -46,6 +46,21 @@ export function parseReasoning(raw: string): ReasoningParts { return { intro: text, steps: [] }; } +/** + * Split a prose blob into sentence-sized facts for scannable rendering. + * Only breaks on terminal punctuation that follows a word/paren/quote and + * precedes a capital — so decimals (₹2.76), ranges (18-65), and multiples + * (10-20x) never split mid-fact. Newlines are flattened first. + */ +function splitSentences(raw: string): string[] { + return (raw ?? '') + .replace(/\s*\n+\s*/g, ' ') + .trim() + .split(/(?<=[a-zA-Z)\]%"'])[.!?]+\s+(?=[A-Z"'(])/) + .map((s) => s.trim()) + .filter(Boolean); +} + export interface ReasoningBodyProps { text: string; /** Accent color for the step badges (matches the decision's autonomy state). */ @@ -63,17 +78,27 @@ export function ReasoningBody({ }: ReasoningBodyProps) { const { intro, steps } = parseReasoning(text); - // No enumeration — render as prose paragraphs. + // No enumeration — split the prose into sentence-sized facts and render them + // as a scannable list, so a dense wall of reasoning reads point-by-point. if (steps.length === 0) { - const paras = text.split(/\n+/).map((p) => p.trim()).filter(Boolean); + const facts = splitSentences(text); + if (facts.length < 2) { + return ( +

{text.trim()}

+ ); + } return ( -
- {paras.map((p, i) => ( -

0 && 'mt-2')}> - {p} -

+
    + {facts.map((f, i) => ( +
  • + + {f} +
  • ))} -
+ ); } diff --git a/src/components/insurance/RupeeAmount.tsx b/src/components/insurance/RupeeAmount.tsx index 01b9332..3348580 100644 --- a/src/components/insurance/RupeeAmount.tsx +++ b/src/components/insurance/RupeeAmount.tsx @@ -38,19 +38,30 @@ const TONES: Record = { onNavy: 'var(--text-on-navy)', }; -/** Oversized confident rupee numeral via Inter Tight. */ +// Large sizes shrink for long figures so a crore-scale number never spills out +// of its container. Keyed off the formatted length (digits + grouping commas). +function fitScale(size: RupeeSize, len: number): number { + if (size !== 'hero' && size !== 'xl') return 1; + if (len > 12) return 0.6; // ₹12,00,00,000+ + if (len > 10) return 0.72; // ₹2,76,00,000 + if (len > 8) return 0.85; // ₹20,00,000 + return 1; +} + +/** Oversized confident rupee numeral via Inter Tight. Auto-fits long figures. */ export function RupeeAmount({ value, size = 'md', tone = 'default', sub, className, style }: RupeeAmountProps) { - const fs = SIZES[size]; + const text = formatINR(value); + const fs = SIZES[size] * fitScale(size, text.length); return ( - + - + - {formatINR(value)} + {text} {sub && {sub}} diff --git a/src/components/insurance/TimelineEntry.tsx b/src/components/insurance/TimelineEntry.tsx index 21482f1..7c08dd9 100644 --- a/src/components/insurance/TimelineEntry.tsx +++ b/src/components/insurance/TimelineEntry.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import { cn } from '../../lib/cn'; +import { aiEmployeeColor } from '../core/Avatar'; import type { Tone } from '../../types'; export interface TimelineEntryProps { @@ -35,21 +36,24 @@ export function TimelineEntry({ last = false, className, }: TimelineEntryProps) { + // Match the AI-employee color used by their Avatar + decision cards, so each + // employee reads the same hue everywhere (timeline dot + actor name). + const aiColor = ai ? aiEmployeeColor(actor) : undefined; return (
{!last && }
- {actor} + {actor} {action} {time}
diff --git a/src/components/insurance/index.ts b/src/components/insurance/index.ts index a86df4f..7df13f0 100644 --- a/src/components/insurance/index.ts +++ b/src/components/insurance/index.ts @@ -14,6 +14,8 @@ export { TimelineEntry } from './TimelineEntry'; export type { TimelineEntryProps } from './TimelineEntry'; export { AIDecisionCard } from './AIDecisionCard'; export type { AIDecisionCardProps, Citation } from './AIDecisionCard'; +export { AiSuggestionPanel } from './AiSuggestionPanel'; +export type { AiSuggestionPanelProps } from './AiSuggestionPanel'; export { AIDecisionStream } from './AIDecisionStream'; export type { AIDecisionStreamProps } from './AIDecisionStream'; export { ReasoningBody, parseReasoning } from './ReasoningBody'; diff --git a/src/layout/Shell.tsx b/src/layout/Shell.tsx index 802bf4d..d94ba36 100644 --- a/src/layout/Shell.tsx +++ b/src/layout/Shell.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import type { LucideIcon } from 'lucide-react'; -import { Bell, CalendarClock, FileText, LogOut, Menu, Search, ShieldCheck, Sparkles, UserCheck, Users, X } from 'lucide-react'; +import { ArrowLeft, Bell, CalendarClock, FileText, LogOut, Menu, Search, ShieldCheck, Sparkles, UserCheck, Users, X } from 'lucide-react'; import { cn } from '../lib/cn'; import { Avatar } from '../components/core'; import { canSeeScreen } from '../api/config'; @@ -90,6 +90,14 @@ function Topbar({ title, subtitle, onMenu }: { title: string; subtitle: string; > +

{title}

{subtitle &&
{subtitle}
} diff --git a/src/screens/PipelineScreen.tsx b/src/screens/PipelineScreen.tsx index e7e52c4..0b1a4f7 100644 --- a/src/screens/PipelineScreen.tsx +++ b/src/screens/PipelineScreen.tsx @@ -2,17 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Columns3, Table } from 'lucide-react'; import { cn } from '../lib/cn'; -import { - Avatar, - Badge, - Card, - ChannelBadge, - KpiCard, - LeadRow, - LEAD_GRID, - Pagination, - SegmentBadge, -} from '../components'; +import { Badge, Card, KpiCard, LeadRow, LEAD_GRID, Pagination } from '../components'; import type { Kpi, Lead, SegmentDatum } from '../types'; import { useLeads } from '../api/leads'; import { STAGES } from '../api/config'; @@ -23,12 +13,6 @@ import { applyFilters, EMPTY_FILTER, type FilterState } from '../api/filters'; const PAGE_SIZE = 10; -function scoreColor(score: number): string { - if (score >= 75) return 'var(--sunrise-600)'; - if (score >= 50) return 'var(--amber-600)'; - return 'var(--slate-500)'; -} - // Funnel milestones (subset of STAGES) — count = leads that have *reached* it. const FUNNEL_STEPS: Array<{ label: string; stage: string }> = [ { label: 'New Lead', stage: 'New Lead' }, @@ -133,29 +117,30 @@ function SegmentDonut({ segments }: { segments: SegmentDatum[] }) { ); } +// Minimal card — monochrome text with a single segment dot as the only accent +// (segment already encodes hot/warm/cold, so the score stays neutral). function KanbanCard({ lead, onClick }: { lead: Lead; onClick: () => void }) { return (
-
- -
+
+
{lead.name}
-
{lead.city}
+
+ {[lead.city, lead.channel].filter(Boolean).join(' · ')} +
- - {lead.score} - + {lead.score}
-
- - -
-
- - {lead.lastAction} +
+ + {lead.lastAction}
); @@ -164,7 +149,7 @@ function KanbanCard({ lead, onClick }: { lead: Lead; onClick: () => void }) { function KanbanBoard({ leads, onOpenLead }: { leads: Lead[]; onOpenLead: (l: Lead) => void }) { const cols = STAGES.slice(0, 8); return ( -
+
{cols.map((stage, idx) => { const items = leads.filter((l) => l.stage === idx); return ( @@ -175,7 +160,9 @@ function KanbanBoard({ leads, onOpenLead }: { leads: Lead[]; onOpenLead: (l: Lea {items.length}
-
+ {/* Card list caps at viewport height and scrolls per-column so a + busy stage doesn't blow up the whole board's height. */} +
{items.length ? ( items.map((l) => onOpenLead(l)} />) ) : ( diff --git a/src/screens/ScheduleScreen.tsx b/src/screens/ScheduleScreen.tsx index d8d7089..3335d96 100644 --- a/src/screens/ScheduleScreen.tsx +++ b/src/screens/ScheduleScreen.tsx @@ -137,7 +137,7 @@ function AgentRail({ />
-
+
{items.length === 0 ? (
No agents match.
) : ( @@ -260,9 +260,10 @@ export function ScheduleScreen() { tomorrowKey={tomorrowKey} /> - {/* Detail: selected agent's agenda */} -
-
+ {/* Detail: selected agent's agenda — capped to the viewport so a long + agenda scrolls internally instead of growing the whole page. */} +
+
@@ -295,7 +296,7 @@ export function ScheduleScreen() {
Try “All”, or pick another agent.
) : ( -
+
{days.map(([k, ms]) => (
diff --git a/src/screens/worklists.tsx b/src/screens/worklists.tsx index a37731f..b46d069 100644 --- a/src/screens/worklists.tsx +++ b/src/screens/worklists.tsx @@ -16,7 +16,7 @@ const avg = (col: keyof LeadRecord) => (rows: LeadRecord[]) => { 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. */ +/** Qualify (New Lead) + Assign (Qualified). */ const QUALIFY_TILES: TileSpec[] = [ { label: 'In queue', value: count }, { label: 'Avg lead score', value: avg('lead_score') }, @@ -26,7 +26,6 @@ export function QualificationScreen() { return ( diff --git a/src/styles/styles.css b/src/styles/styles.css index 3f02df6..e454eab 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -159,4 +159,27 @@ border-color: var(--sunrise-500); box-shadow: var(--shadow-focus); } + /* Slim, theme-tinted scrollbar for in-panel scroll areas (agenda, rail, + modal body). The 2px transparent border + padding-box clip makes the thumb + read as a thin rounded pill with breathing room; darkens on hover. */ + .scrollbar-slim { + scrollbar-width: thin; + scrollbar-color: var(--border-default) transparent; + } + .scrollbar-slim::-webkit-scrollbar { + width: 10px; + height: 10px; + } + .scrollbar-slim::-webkit-scrollbar-track { + background: transparent; + } + .scrollbar-slim::-webkit-scrollbar-thumb { + background-color: var(--border-default); + background-clip: padding-box; + border: 3px solid transparent; + border-radius: 9999px; + } + .scrollbar-slim:hover::-webkit-scrollbar-thumb { + background-color: var(--text-faint); + } }