Compare commits
No commits in common. "52a691fd757b0a3c1bd410dd0ddf60930097e450" and "1a622b1267bbecc6c760345644999adf256bb7cc" have entirely different histories.
52a691fd75
...
1a622b1267
3
.gitignore
vendored
3
.gitignore
vendored
@ -19,6 +19,3 @@ yarn-error.log*
|
|||||||
.env
|
.env
|
||||||
|
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Local MCP config (contains credentials)
|
|
||||||
.mcp.json
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -3,9 +3,7 @@ import type { Channel, Decision, Lead, Segment, Tone } from '../types';
|
|||||||
import {
|
import {
|
||||||
ACTIVITY_NAME_BY_UID,
|
ACTIVITY_NAME_BY_UID,
|
||||||
AI_EMPLOYEES,
|
AI_EMPLOYEES,
|
||||||
AI_RECOMMENDATION,
|
|
||||||
STATE_NAME_BY_UID,
|
STATE_NAME_BY_UID,
|
||||||
SUPER_ROLES,
|
|
||||||
aiEmployeeForState,
|
aiEmployeeForState,
|
||||||
stageOfState,
|
stageOfState,
|
||||||
} from './config';
|
} from './config';
|
||||||
@ -163,8 +161,7 @@ function firstSentence(text: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function decisionToCard(d: AiDecision): Decision {
|
export function decisionToCard(d: AiDecision): Decision {
|
||||||
const known = AI_EMPLOYEES[d.ai_user_id];
|
const emp = AI_EMPLOYEES[d.ai_user_id] ?? { name: `AI ${d.ai_user_id}`, role: 'AI Employee' };
|
||||||
const emp = known ?? { name: d.employee_name?.trim() || `AI ${d.ai_user_id}`, role: 'AI Employee' };
|
|
||||||
const reasoning = (d.reasoning ?? '').trim();
|
const reasoning = (d.reasoning ?? '').trim();
|
||||||
const activityName =
|
const activityName =
|
||||||
ACTIVITY_NAME_BY_UID[d.activity_id] ??
|
ACTIVITY_NAME_BY_UID[d.activity_id] ??
|
||||||
@ -183,60 +180,6 @@ export function decisionToCard(d: AiDecision): Decision {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Aria Advisor product recommendation ---
|
|
||||||
|
|
||||||
export interface AiRecommendation {
|
|
||||||
/** Product name as the AI named it, e.g. "ABSLI DigiShield Term Plan". */
|
|
||||||
product: string;
|
|
||||||
sumAssured: number | null;
|
|
||||||
rationale: string;
|
|
||||||
/** 0–1 (the decision confidence, or the ai_* field / 100). */
|
|
||||||
confidence: number;
|
|
||||||
employee: string;
|
|
||||||
role: string;
|
|
||||||
time: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function num(v: unknown): number | null {
|
|
||||||
if (v == null || v === '') return null;
|
|
||||||
const n = Number(v);
|
|
||||||
return Number.isFinite(n) ? n : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pull Aria Advisor's pre-activity product recommendation out of the decision
|
|
||||||
* stream (it fires after Schedule Meeting). Returns null if she hasn't run. */
|
|
||||||
export function recommendationFromDecisions(decisions: AiDecision[]): AiRecommendation | null {
|
|
||||||
const F = AI_RECOMMENDATION.fields;
|
|
||||||
// A recommendation decision = the right activity/employee, carrying a product
|
|
||||||
// name, and actually *submitted* (skip failed retries, which other employees
|
|
||||||
// log at low confidence). Prefer the real Aria Advisor over any stand-in.
|
|
||||||
const candidates = decisions.filter(
|
|
||||||
(x) =>
|
|
||||||
x.status === 'submitted' &&
|
|
||||||
typeof x.data?.[F.product] === 'string' &&
|
|
||||||
(x.data[F.product] as string).trim() !== '' &&
|
|
||||||
(x.activity_id === AI_RECOMMENDATION.activityUid || x.ai_user_id === AI_RECOMMENDATION.aiUserId),
|
|
||||||
);
|
|
||||||
const d =
|
|
||||||
candidates.find((x) => x.ai_user_id === AI_RECOMMENDATION.aiUserId) ?? candidates[0];
|
|
||||||
const product = d?.data?.[F.product];
|
|
||||||
if (!d || typeof product !== 'string' || !product.trim()) return null;
|
|
||||||
const emp = AI_EMPLOYEES[d.ai_user_id] ?? {
|
|
||||||
name: d.employee_name?.trim() || 'Aria Advisor',
|
|
||||||
role: 'Product Recommendation',
|
|
||||||
};
|
|
||||||
const pct = num(d.data?.[F.confidence]);
|
|
||||||
return {
|
|
||||||
product: product.trim(),
|
|
||||||
sumAssured: num(d.data?.[F.sumAssured]),
|
|
||||||
rationale: (typeof d.data?.[F.rationale] === 'string' ? (d.data[F.rationale] as string) : d.reasoning ?? '').trim(),
|
|
||||||
confidence: typeof d.confidence === 'number' && d.confidence > 0 ? d.confidence : pct != null ? pct / 100 : 0,
|
|
||||||
employee: emp.name,
|
|
||||||
role: emp.role,
|
|
||||||
time: d.created_at ? formatTime(d.created_at) : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- audit timeline ---
|
// --- audit timeline ---
|
||||||
|
|
||||||
export interface TimelineItem {
|
export interface TimelineItem {
|
||||||
@ -247,17 +190,6 @@ export interface TimelineItem {
|
|||||||
tone: Tone;
|
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[] {
|
export function auditToTimeline(entries: AuditEntry[]): TimelineItem[] {
|
||||||
const items = [...entries]
|
const items = [...entries]
|
||||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||||
@ -271,7 +203,7 @@ export function auditToTimeline(entries: AuditEntry[]): TimelineItem[] {
|
|||||||
? `advanced to ${stateName}`
|
? `advanced to ${stateName}`
|
||||||
: 'performed an activity';
|
: 'performed an activity';
|
||||||
return {
|
return {
|
||||||
actor: ai?.name ?? humanActor(e.user_id, e.user_roles),
|
actor: ai?.name ?? (e.user_id === '1' ? 'Admin' : `User ${e.user_id}`),
|
||||||
ai: !!ai,
|
ai: !!ai,
|
||||||
action,
|
action,
|
||||||
time: formatTime(e.created_at),
|
time: formatTime(e.created_at),
|
||||||
|
|||||||
@ -206,20 +206,10 @@ export class ZinoClient {
|
|||||||
|
|
||||||
// --- RDBMS lookup records (owner-agent picker etc.) ---
|
// --- RDBMS lookup records (owner-agent picker etc.) ---
|
||||||
|
|
||||||
/** Search an RDBMS lookup template. Returns the matching rows. `formData`
|
/** Search an RDBMS lookup template. Returns the matching rows. */
|
||||||
* carries the current form values so the server can apply the activity's
|
|
||||||
* `field_rules` filter_options (dependent/cascading lookups, e.g. owner
|
|
||||||
* agents scoped to the chosen region). */
|
|
||||||
lookupRecords(
|
lookupRecords(
|
||||||
templateUid: string,
|
templateUid: string,
|
||||||
opts: {
|
opts: { activityId: string; fieldId: string; search?: string; instanceId?: number | string; limit?: number },
|
||||||
activityId: string;
|
|
||||||
fieldId: string;
|
|
||||||
search?: string;
|
|
||||||
instanceId?: number | string;
|
|
||||||
limit?: number;
|
|
||||||
formData?: Record<string, unknown>;
|
|
||||||
},
|
|
||||||
): Promise<{ records: Array<Record<string, unknown>> }> {
|
): Promise<{ records: Array<Record<string, unknown>> }> {
|
||||||
return this.request('POST', `/app/${APP_ID}/rdbms-templates/${templateUid}/records`, {
|
return this.request('POST', `/app/${APP_ID}/rdbms-templates/${templateUid}/records`, {
|
||||||
workflow_uuid: WORKFLOW_UUID,
|
workflow_uuid: WORKFLOW_UUID,
|
||||||
@ -229,7 +219,7 @@ export class ZinoClient {
|
|||||||
search: opts.search ?? '',
|
search: opts.search ?? '',
|
||||||
limit: opts.limit ?? 50,
|
limit: opts.limit ?? 50,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
form_data: opts.formData ?? {},
|
form_data: {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -32,7 +32,6 @@ export const ACTIVITIES = {
|
|||||||
premiumFrequency: 'premium_frequency_input', // select
|
premiumFrequency: 'premium_frequency_input', // select
|
||||||
riders: 'riders_input', // multiselect
|
riders: 'riders_input', // multiselect
|
||||||
notes: 'recommendation_notes', // longtext
|
notes: 'recommendation_notes', // longtext
|
||||||
saValid: 'sa_valid_input', // boolean — computed (see lib/recommend.ts); gates Product Recommended
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
COLLECT_DOCUMENTS: {
|
COLLECT_DOCUMENTS: {
|
||||||
@ -130,24 +129,8 @@ export const AI_EMPLOYEES: Record<string, { name: string; role: string }> = {
|
|||||||
'29180': { name: 'Aria', role: 'Lead Qualifier' },
|
'29180': { name: 'Aria', role: 'Lead Qualifier' },
|
||||||
'29181': { name: 'Aria Engage', role: 'Calls & Scheduling' },
|
'29181': { name: 'Aria Engage', role: 'Calls & Scheduling' },
|
||||||
'29182': { name: 'Aria Underwrite', role: 'Underwriting' },
|
'29182': { name: 'Aria Underwrite', role: 'Underwriting' },
|
||||||
'29188': { name: 'Aria Advisor', role: 'Product Recommendation' },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Aria Advisor's internal recommendation activity. It fires from the Schedule
|
|
||||||
// Meeting trigger (after the meeting is booked) and records the product
|
|
||||||
// recommendation as a decision — surfaced in the Recommend Product form. Its
|
|
||||||
// data{} carries the ai_* fields below.
|
|
||||||
export const AI_RECOMMENDATION = {
|
|
||||||
activityUid: '14d734da-f1e2-49af-9715-2146d26c0e1a',
|
|
||||||
aiUserId: '29188',
|
|
||||||
fields: {
|
|
||||||
product: 'ai_recommended_product_input', // product name (string)
|
|
||||||
sumAssured: 'ai_suggested_sum_assured_input', // number
|
|
||||||
rationale: 'ai_recommendation_rationale_input', // text
|
|
||||||
confidence: 'ai_recommendation_confidence_input', // 0–100
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// Which AI employee is driving a given state (for owner attribution when no
|
// Which AI employee is driving a given state (for owner attribution when no
|
||||||
// human sales agent is set).
|
// human sales agent is set).
|
||||||
export function aiEmployeeForState(stateName?: string): { name: string; role: string } | null {
|
export function aiEmployeeForState(stateName?: string): { name: string; role: string } | null {
|
||||||
|
|||||||
@ -34,12 +34,6 @@ export interface FieldDef {
|
|||||||
label: string;
|
label: string;
|
||||||
type: FieldType;
|
type: FieldType;
|
||||||
required?: boolean;
|
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[];
|
options?: FieldOption[];
|
||||||
prefix?: string;
|
prefix?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
|||||||
@ -28,7 +28,7 @@ const LeadsContext = createContext<LeadsContextValue | null>(null);
|
|||||||
export function LeadsProvider({ children }: { children: ReactNode }) {
|
export function LeadsProvider({ children }: { children: ReactNode }) {
|
||||||
const { client } = useZino();
|
const { client } = useZino();
|
||||||
const { data, loading, error, refetch } = useQuery(
|
const { data, loading, error, refetch } = useQuery(
|
||||||
() => client.recordView(RECORD_VIEW_IDS.ALL_LEADS, { limit: 200, sortBy: 'updated_at', sortDir: 'desc' }),
|
() => client.recordView(RECORD_VIEW_IDS.ALL_LEADS, { limit: 200, sortBy: 'instance_id', sortDir: 'desc' }),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -65,14 +65,7 @@ export function schemaToFields(resp: FormScreenResponse): FieldDef[] {
|
|||||||
.filter((f): f is FormScreenField => !!f)
|
.filter((f): f is FormScreenField => !!f)
|
||||||
: resp.fields;
|
: resp.fields;
|
||||||
|
|
||||||
return ordered
|
return ordered.map(toFieldDef).filter((d): d is FieldDef => d !== null);
|
||||||
.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
|
/** Single field's def by id — for bespoke screens that need one field's
|
||||||
@ -82,12 +75,5 @@ export function fieldFromSchema(
|
|||||||
id: string,
|
id: string,
|
||||||
): FieldDef | undefined {
|
): FieldDef | undefined {
|
||||||
const f = resp?.fields.find((x) => x.id === id);
|
const f = resp?.fields.find((x) => x.id === id);
|
||||||
if (!f) return undefined;
|
return f ? toFieldDef(f) ?? undefined : undefined;
|
||||||
const def = toFieldDef(f);
|
|
||||||
if (!def) return undefined;
|
|
||||||
return {
|
|
||||||
...def,
|
|
||||||
hidden: !!resp?.field_defaults?.[id]?.hidden,
|
|
||||||
disabled: !!resp?.field_defaults?.[id]?.disabled,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -82,49 +82,11 @@ export interface FormScreenGridItem {
|
|||||||
y: number;
|
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 {
|
export interface FormScreenResponse {
|
||||||
activity_uid: string;
|
activity_uid: string;
|
||||||
activity_name: string;
|
activity_name: string;
|
||||||
fields: FormScreenField[];
|
fields: FormScreenField[];
|
||||||
grid_config?: FormScreenGridItem[];
|
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<string, FieldDefault>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Audit (GET /app/{appId}/view/audit) ---
|
// --- Audit (GET /app/{appId}/view/audit) ---
|
||||||
@ -147,19 +109,12 @@ export interface AiDecision {
|
|||||||
instance_id: string;
|
instance_id: string;
|
||||||
activity_id: string;
|
activity_id: string;
|
||||||
activity_name?: string;
|
activity_name?: string;
|
||||||
/** Server-resolved display name for the AI employee (preferred over the
|
|
||||||
* static AI_EMPLOYEES map). */
|
|
||||||
employee_name?: string;
|
|
||||||
reasoning: string;
|
reasoning: string;
|
||||||
confidence: number;
|
confidence: number;
|
||||||
status: string;
|
status: string;
|
||||||
instance_state?: string;
|
instance_state?: string;
|
||||||
knowledge_sources?: unknown;
|
knowledge_sources?: unknown;
|
||||||
tool_calls?: unknown;
|
tool_calls?: unknown;
|
||||||
/** The activity payload the AI employee submitted — e.g. the Aria Advisor
|
|
||||||
* product recommendation carries ai_recommended_product_input /
|
|
||||||
* ai_suggested_sum_assured_input / ai_recommendation_confidence_input here. */
|
|
||||||
data?: Record<string, unknown>;
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -246,9 +201,5 @@ export interface LeadRecord {
|
|||||||
// Written by the Onboard trigger (5d), read off the lead record.
|
// Written by the Onboard trigger (5d), read off the lead record.
|
||||||
welcome_email_status?: string | null;
|
welcome_email_status?: string | null;
|
||||||
policy_doc?: PolicyDoc[] | 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;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { CheckCircle2, UserCheck } from 'lucide-react';
|
import { CheckCircle2, UserCheck } from 'lucide-react';
|
||||||
import { AiSuggestionPanel, Button, Card } from '../';
|
import { AIDecisionCard, Button, Card } from '../';
|
||||||
import { LookupField, SchemaGuard } from '../form';
|
import { LookupField } from '../form';
|
||||||
import type { LookupValue } from '../form';
|
import type { LookupValue } from '../form';
|
||||||
import { SelectField } from '../core/SelectField';
|
|
||||||
import { Input } from '../core/Input';
|
|
||||||
import { useQuery, useZino } from '../../api/provider';
|
import { useQuery, useZino } from '../../api/provider';
|
||||||
import { decisionToCard } from '../../api/adapters';
|
import { decisionToCard } from '../../api/adapters';
|
||||||
import { fieldFromSchema } from '../../api/schema';
|
import { fieldFromSchema } from '../../api/schema';
|
||||||
@ -17,27 +15,17 @@ export function AssignBody({ instanceId, record, onSuccess }: ActivityBodyProps)
|
|||||||
const { client } = useZino();
|
const { client } = useZino();
|
||||||
|
|
||||||
const schemaQ = useQuery(() => client.formSchema(DEF.uid, instanceId), [instanceId]);
|
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 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]);
|
||||||
|
|
||||||
const [city, setCity] = useState('');
|
|
||||||
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(() => {
|
||||||
if (!record) return;
|
if (!record) return;
|
||||||
setCity(String((record as Record<string, unknown>).city ?? ''));
|
|
||||||
setRegion((record.assigned_region as LookupValue) ?? null);
|
setRegion((record.assigned_region as LookupValue) ?? null);
|
||||||
setAgent((record.owner_agent as LookupValue) ?? null);
|
setAgent((record.owner_agent as LookupValue) ?? null);
|
||||||
}, [record]);
|
}, [record]);
|
||||||
|
|
||||||
// Form values keyed exactly as the rules' `form_field`s reference them.
|
|
||||||
const formData: Record<string, unknown> = {
|
|
||||||
assign_city: city,
|
|
||||||
assigned_region_input: region,
|
|
||||||
owner_agent_input: agent,
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
@ -63,7 +51,6 @@ export function AssignBody({ instanceId, record, onSuccess }: ActivityBodyProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SchemaGuard loading={schemaQ.loading} error={schemaQ.error} action="assign this lead">
|
|
||||||
<div className="grid gap-[18px] 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">
|
||||||
{result && (
|
{result && (
|
||||||
@ -81,36 +68,6 @@ export function AssignBody({ instanceId, record, onSuccess }: ActivityBodyProps)
|
|||||||
|
|
||||||
<Card title="Assign to agent">
|
<Card title="Assign to agent">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{/* 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 ? (
|
|
||||||
<SelectField
|
|
||||||
label={CITY.label}
|
|
||||||
required={CITY.required}
|
|
||||||
value={city}
|
|
||||||
onChange={(v) => {
|
|
||||||
setCity(v);
|
|
||||||
setRegion(null); // city drives the region list — drop stale picks
|
|
||||||
setAgent(null);
|
|
||||||
}}
|
|
||||||
options={CITY.options}
|
|
||||||
placeholder={CITY.required ? 'Select…' : '— None —'}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
label={CITY.label}
|
|
||||||
required={CITY.required}
|
|
||||||
value={city}
|
|
||||||
onChange={(e) => {
|
|
||||||
setCity(e.target.value);
|
|
||||||
setRegion(null);
|
|
||||||
setAgent(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{REGION && (
|
{REGION && (
|
||||||
<LookupField
|
<LookupField
|
||||||
label={REGION.label}
|
label={REGION.label}
|
||||||
@ -122,11 +79,7 @@ export function AssignBody({ instanceId, record, onSuccess }: ActivityBodyProps)
|
|||||||
fieldId={REGION.id}
|
fieldId={REGION.id}
|
||||||
instanceId={instanceId}
|
instanceId={instanceId}
|
||||||
value={region}
|
value={region}
|
||||||
onChange={(v) => {
|
onChange={setRegion}
|
||||||
setRegion(v);
|
|
||||||
setAgent(null); // region drives the agent list — drop a stale pick
|
|
||||||
}}
|
|
||||||
formData={formData}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{OWNER && (
|
{OWNER && (
|
||||||
@ -140,9 +93,9 @@ export function AssignBody({ instanceId, record, onSuccess }: ActivityBodyProps)
|
|||||||
instanceId={instanceId}
|
instanceId={instanceId}
|
||||||
value={agent}
|
value={agent}
|
||||||
onChange={setAgent}
|
onChange={setAgent}
|
||||||
formData={formData}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{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={submitting} onClick={submit}>
|
<Button variant="primary" disabled={submitting} onClick={submit}>
|
||||||
@ -173,7 +126,6 @@ export function AssignBody({ instanceId, record, onSuccess }: ActivityBodyProps)
|
|||||||
<AssignAiSuggestion instanceId={instanceId} />
|
<AssignAiSuggestion instanceId={instanceId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SchemaGuard>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -182,5 +134,11 @@ function AssignAiSuggestion({ instanceId }: { instanceId: number | string }) {
|
|||||||
const q = useQuery(() => client.aiDecisions(instanceId as number), [instanceId]);
|
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;
|
||||||
return <AiSuggestionPanel {...decisionToCard(latest)} />;
|
const card = decisionToCard(latest);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-faint">AI suggestion</div>
|
||||||
|
<AIDecisionCard {...card} defaultExpanded />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
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 '../';
|
import { Avatar, Badge, Button, Card, DocumentUploadCard, formatINR, Input, SelectField } from '../';
|
||||||
import type { BadgeTone, DocStatus, OcrField } from '../';
|
import type { BadgeTone, DocStatus, OcrField } from '../';
|
||||||
import { SchemaGuard } from '../form';
|
|
||||||
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';
|
||||||
@ -52,15 +51,6 @@ function OcrCapture({
|
|||||||
const [fields, setFields] = useState<OcrField[]>([]);
|
const [fields, setFields] = useState<OcrField[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(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) {
|
async function handlePick(file: File) {
|
||||||
setFileName(file.name);
|
setFileName(file.name);
|
||||||
setStatus('processing');
|
setStatus('processing');
|
||||||
@ -91,7 +81,7 @@ function OcrCapture({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full">
|
<div>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@ -110,8 +100,6 @@ function OcrCapture({
|
|||||||
fileName={fileName}
|
fileName={fileName}
|
||||||
onUpload={() => inputRef.current?.click()}
|
onUpload={() => inputRef.current?.click()}
|
||||||
onConfirm={() => setStatus('verified')}
|
onConfirm={() => setStatus('verified')}
|
||||||
onReplace={() => inputRef.current?.click()}
|
|
||||||
onRemove={reset}
|
|
||||||
/>
|
/>
|
||||||
{error && <div className="text-2xs text-amber-700 mt-1.5">{error}</div>}
|
{error && <div className="text-2xs text-amber-700 mt-1.5">{error}</div>}
|
||||||
</div>
|
</div>
|
||||||
@ -200,11 +188,6 @@ export function DocumentsBody({ instanceId, record, onSuccess }: ActivityBodyPro
|
|||||||
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 (
|
||||||
<SchemaGuard
|
|
||||||
loading={schemaQ.loading}
|
|
||||||
error={schemaQ.error}
|
|
||||||
action="collect documents for this lead"
|
|
||||||
>
|
|
||||||
<div className="grid gap-[18px] 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">
|
||||||
@ -371,7 +354,6 @@ export function DocumentsBody({ instanceId, record, onSuccess }: ActivityBodyPro
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SchemaGuard>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { CheckCircle2 } from 'lucide-react';
|
import { CheckCircle2 } from 'lucide-react';
|
||||||
import { Button, Card, formatINR, Input } from '../';
|
import { Button, Card, formatINR, Input } from '../';
|
||||||
import { SchemaGuard } from '../form';
|
import { useZino } from '../../api/provider';
|
||||||
import { useQuery, useZino } from '../../api/provider';
|
|
||||||
import { ACTIVITIES } from '../../api/config';
|
import { ACTIVITIES } from '../../api/config';
|
||||||
import type { ActivityBodyProps } from './types';
|
import type { ActivityBodyProps } from './types';
|
||||||
|
|
||||||
@ -23,11 +22,6 @@ function addYears(iso: string, years: number): string {
|
|||||||
export function IssuePolicyBody({ instanceId, record, onSuccess }: ActivityBodyProps) {
|
export function IssuePolicyBody({ instanceId, record, onSuccess }: ActivityBodyProps) {
|
||||||
const { client } = useZino();
|
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 [issueDate, setIssueDate] = useState(todayISO());
|
||||||
const [term, setTerm] = useState(20);
|
const [term, setTerm] = useState(20);
|
||||||
const [premium, setPremium] = useState(0);
|
const [premium, setPremium] = useState(0);
|
||||||
@ -70,7 +64,6 @@ export function IssuePolicyBody({ instanceId, record, onSuccess }: ActivityBodyP
|
|||||||
const lead = record;
|
const lead = record;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SchemaGuard loading={schemaQ.loading} error={schemaQ.error} action="issue this policy">
|
|
||||||
<div className="grid gap-[18px] 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">
|
||||||
{result && (
|
{result && (
|
||||||
@ -154,6 +147,5 @@ export function IssuePolicyBody({ instanceId, record, onSuccess }: ActivityBodyP
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SchemaGuard>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { CheckCircle2, ChevronRight, ShieldCheck, Sparkles, Wand2 } from 'lucide-react';
|
import { CheckCircle2 } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
AiSuggestionPanel,
|
AIDecisionCard,
|
||||||
Avatar,
|
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
formatINR,
|
formatINR,
|
||||||
@ -11,19 +10,31 @@ import {
|
|||||||
RupeeAmount,
|
RupeeAmount,
|
||||||
SelectField,
|
SelectField,
|
||||||
} from '../';
|
} from '../';
|
||||||
import { LookupField, SchemaGuard } from '../form';
|
import { LookupField } from '../form';
|
||||||
import type { LookupValue } from '../form';
|
import type { LookupValue } from '../form';
|
||||||
import { useQuery, useZino } from '../../api/provider';
|
import { useQuery, useZino } from '../../api/provider';
|
||||||
import { decisionToCard, recommendationFromDecisions } from '../../api/adapters';
|
import { decisionToCard } from '../../api/adapters';
|
||||||
import type { AiRecommendation } from '../../api/adapters';
|
|
||||||
import { fieldFromSchema } from '../../api/schema';
|
import { fieldFromSchema } from '../../api/schema';
|
||||||
import { ACTIVITIES } from '../../api/config';
|
import { ACTIVITIES } from '../../api/config';
|
||||||
import { recommendCalc, saValidIssues } from '../../lib/recommend';
|
|
||||||
import type { ActivityBodyProps } from './types';
|
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;
|
||||||
|
|
||||||
|
function indicativePremium(sum: number, freq: string): number {
|
||||||
|
const base = Math.round(sum * 0.00142);
|
||||||
|
switch (freq) {
|
||||||
|
case 'monthly':
|
||||||
|
return Math.round((base / 12) * 1.04);
|
||||||
|
case 'half_yearly':
|
||||||
|
return Math.round((base / 2) * 1.02);
|
||||||
|
case 'quarterly':
|
||||||
|
return Math.round((base / 4) * 1.03);
|
||||||
|
default:
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toRiderValues(raw: LeadRecord['riders']): string[] {
|
function toRiderValues(raw: LeadRecord['riders']): string[] {
|
||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
if (Array.isArray(raw)) return raw as string[];
|
if (Array.isArray(raw)) return raw as string[];
|
||||||
@ -40,9 +51,6 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro
|
|||||||
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]);
|
||||||
// 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<LookupValue | null>(null);
|
const [product, setProduct] = useState<LookupValue | null>(null);
|
||||||
const [sum, setSum] = useState(2000000);
|
const [sum, setSum] = useState(2000000);
|
||||||
@ -66,57 +74,12 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro
|
|||||||
}
|
}
|
||||||
}, [record]);
|
}, [record]);
|
||||||
|
|
||||||
// Mirror the designer custom_js: rate-card premium + sa_valid (the stage-gate
|
const computed = indicativePremium(sum, freq);
|
||||||
// field). Without sa_valid the lead can't advance past Meeting Scheduled.
|
const effectivePremium = premiumTouched ? premium : computed;
|
||||||
const calc = recommendCalc(product, record?.date_of_birth, sum, record?.annual_income);
|
|
||||||
// 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);
|
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);
|
||||||
|
|
||||||
// One-click apply of Aria Advisor's pre-activity recommendation: sets the sum
|
|
||||||
// assured straight off, and resolves her product *name* against the live
|
|
||||||
// catalogue into the lookup value the field needs. The agent can still edit.
|
|
||||||
const [applying, setApplying] = useState(false);
|
|
||||||
const [applyNote, setApplyNote] = useState<string | null>(null);
|
|
||||||
|
|
||||||
async function applyRecommendation(rec: AiRecommendation) {
|
|
||||||
setApplyNote(null);
|
|
||||||
if (rec.sumAssured && rec.sumAssured > 0) {
|
|
||||||
setSum(rec.sumAssured);
|
|
||||||
setPremiumTouched(false); // let the rate-card premium recompute
|
|
||||||
}
|
|
||||||
if (!productField?.lookupTemplate) return;
|
|
||||||
setApplying(true);
|
|
||||||
try {
|
|
||||||
const { records } = await client.lookupRecords(productField.lookupTemplate, {
|
|
||||||
activityId: ACTIVITIES.RECOMMEND_PRODUCT.uid,
|
|
||||||
fieldId: F.product,
|
|
||||||
instanceId,
|
|
||||||
search: '',
|
|
||||||
limit: 50,
|
|
||||||
});
|
|
||||||
const match = matchProduct(rec.product, records, productField.lookupDisplay ?? []);
|
|
||||||
if (match) {
|
|
||||||
const stored: LookupValue = {};
|
|
||||||
(productField.lookupStorage ?? []).forEach((c) => (stored[c] = match[c] ?? null));
|
|
||||||
(productField.lookupDisplay ?? []).forEach((c) => {
|
|
||||||
if (!(c in stored)) stored[c] = match[c] ?? null;
|
|
||||||
});
|
|
||||||
setProduct(stored);
|
|
||||||
} else {
|
|
||||||
setApplyNote(`Couldn’t match “${rec.product}” to the catalogue — pick the product manually.`);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setApplyNote('Couldn’t load the product catalogue — pick the product manually.');
|
|
||||||
} finally {
|
|
||||||
setApplying(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const incomeMultiple = record?.annual_income ? (sum / record.annual_income).toFixed(1) : '—';
|
const incomeMultiple = record?.annual_income ? (sum / record.annual_income).toFixed(1) : '—';
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
@ -134,14 +97,8 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro
|
|||||||
[F.premiumFrequency]: freq,
|
[F.premiumFrequency]: freq,
|
||||||
[F.riders]: riders,
|
[F.riders]: riders,
|
||||||
[F.notes]: notes,
|
[F.notes]: notes,
|
||||||
[F.saValid]: calc.saValid,
|
|
||||||
});
|
|
||||||
setResult({
|
|
||||||
ok: true,
|
|
||||||
msg: calc.saValid
|
|
||||||
? 'Recommendation submitted — lead advanced to Product Recommended.'
|
|
||||||
: 'Recommendation saved, but the sum assured is outside the plan’s eligible range — the lead stays at Meeting Scheduled until corrected.',
|
|
||||||
});
|
});
|
||||||
|
setResult({ ok: true, msg: 'Recommendation submitted — lead advanced to Product Recommended.' });
|
||||||
onSuccess?.(res);
|
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') });
|
||||||
@ -150,17 +107,7 @@ 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 (
|
return (
|
||||||
<SchemaGuard
|
|
||||||
loading={schemaQ.loading}
|
|
||||||
error={schemaQ.error}
|
|
||||||
empty={!productField}
|
|
||||||
action="recommend a product for this lead"
|
|
||||||
>
|
|
||||||
<div className="grid gap-[18px] 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">
|
||||||
{result && (
|
{result && (
|
||||||
@ -176,7 +123,7 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card title="Recommend product">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{productField && (
|
{productField && (
|
||||||
<LookupField
|
<LookupField
|
||||||
@ -210,20 +157,8 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro
|
|||||||
prefix="₹"
|
prefix="₹"
|
||||||
type="number"
|
type="number"
|
||||||
value={effectivePremium}
|
value={effectivePremium}
|
||||||
disabled={premiumDisabled}
|
hint={premiumTouched ? undefined : `Indicative — ₹${formatINR(computed)}`}
|
||||||
hint={
|
|
||||||
premiumDisabled
|
|
||||||
? calc.evaluated
|
|
||||||
? 'Auto-calculated from the plan’s rate-card'
|
|
||||||
: 'Pick a product to compute the premium'
|
|
||||||
: premiumTouched
|
|
||||||
? undefined
|
|
||||||
: calc.evaluated
|
|
||||||
? `Rate-card — ₹${formatINR(calc.premium)}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (premiumDisabled) return;
|
|
||||||
setPremium(Number(e.target.value) || 0);
|
setPremium(Number(e.target.value) || 0);
|
||||||
setPremiumTouched(true);
|
setPremiumTouched(true);
|
||||||
}}
|
}}
|
||||||
@ -249,15 +184,6 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{issues.length > 0 && (
|
|
||||||
<div className="mt-4 flex items-start gap-2.5 rounded-md border border-amber-300 bg-escalated-soft px-4 py-3 text-sm text-amber-800">
|
|
||||||
<CheckCircle2 size={16} className="mt-0.5 shrink-0 rotate-45 text-amber-600" />
|
|
||||||
<div>
|
|
||||||
<div className="font-semibold">Sum assured not yet eligible — lead won’t advance</div>
|
|
||||||
<div className="mt-0.5 text-xs text-amber-700">{issues.join('; ')}.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2.5 mt-5">
|
<div className="flex gap-2.5 mt-5">
|
||||||
<Button variant="primary" disabled={submitting} onClick={submit}>
|
<Button variant="primary" disabled={submitting} onClick={submit}>
|
||||||
{submitting ? 'Submitting…' : 'Send recommendation'}
|
{submitting ? 'Submitting…' : 'Send recommendation'}
|
||||||
@ -286,160 +212,22 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RecommendAiSuggestion
|
<RecommendAiSuggestion instanceId={instanceId} />
|
||||||
instanceId={instanceId}
|
|
||||||
onApply={applyRecommendation}
|
|
||||||
applying={applying}
|
|
||||||
applyNote={applyNote}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SchemaGuard>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Match Aria's free-text product name to a catalogue row by case/punctuation-
|
function RecommendAiSuggestion({ instanceId }: { instanceId: number | string }) {
|
||||||
* insensitive containment (longest matching label wins). */
|
|
||||||
function matchProduct(
|
|
||||||
aiName: string,
|
|
||||||
rows: Array<Record<string, unknown>>,
|
|
||||||
displayCols: string[],
|
|
||||||
): Record<string, unknown> | null {
|
|
||||||
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
||||||
const ai = norm(aiName);
|
|
||||||
if (!ai) return null;
|
|
||||||
let best: Record<string, unknown> | null = null;
|
|
||||||
let bestLen = 0;
|
|
||||||
for (const row of rows) {
|
|
||||||
// Try each display column on its own *and* the joined label — a product
|
|
||||||
// name like "DigiShield" must still match a multi-column "DigiShield · term".
|
|
||||||
const candidates = [...displayCols.map((c) => row[c]), displayCols.map((c) => row[c]).join(' ')]
|
|
||||||
.map((v) => (v == null ? '' : norm(String(v))))
|
|
||||||
.filter(Boolean);
|
|
||||||
for (const n of candidates) {
|
|
||||||
if ((ai.includes(n) || n.includes(ai)) && n.length > bestLen) {
|
|
||||||
best = row;
|
|
||||||
bestLen = n.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RecommendAiSuggestionProps {
|
|
||||||
instanceId: number | string;
|
|
||||||
onApply: (rec: AiRecommendation) => void;
|
|
||||||
applying: boolean;
|
|
||||||
applyNote: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RecommendAiSuggestion({ instanceId, onApply, applying, applyNote }: RecommendAiSuggestionProps) {
|
|
||||||
const { client } = useZino();
|
const { client } = useZino();
|
||||||
const q = useQuery(() => client.aiDecisions(Number(instanceId)), [instanceId]);
|
const q = useQuery(() => client.aiDecisions(Number(instanceId)), [instanceId]);
|
||||||
const decisions = q.data ?? [];
|
const latest = (q.data ?? [])[0];
|
||||||
const rec = recommendationFromDecisions(decisions);
|
if (!latest) return null;
|
||||||
|
const card = decisionToCard(latest);
|
||||||
// No structured recommendation yet → fall back to the generic latest-decision
|
|
||||||
// card so the agent still sees whatever Aria last did.
|
|
||||||
if (!rec) {
|
|
||||||
const latest = decisions[0];
|
|
||||||
if (!latest) return null;
|
|
||||||
return <AiSuggestionPanel {...decisionToCard(latest)} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-faint">Aria’s recommendation</div>
|
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-faint">AI suggestion</div>
|
||||||
<AriaRecommendationCard rec={rec} onApply={() => onApply(rec)} applying={applying} applyNote={applyNote} />
|
<AIDecisionCard {...card} defaultExpanded />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AriaRecommendationCard({
|
|
||||||
rec,
|
|
||||||
onApply,
|
|
||||||
applying,
|
|
||||||
applyNote,
|
|
||||||
}: {
|
|
||||||
rec: AiRecommendation;
|
|
||||||
onApply: () => void;
|
|
||||||
applying: boolean;
|
|
||||||
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 (
|
|
||||||
<article className="bg-card rounded-2xl border border-border-subtle shadow-sm overflow-hidden font-sans">
|
|
||||||
{/* header — identity + confidence */}
|
|
||||||
<header className="flex items-center gap-3 px-5 pt-[18px] pb-3.5">
|
|
||||||
<Avatar name={rec.employee} ai size={36} className="shrink-0" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-sm font-bold text-strong leading-tight truncate">{rec.employee}</div>
|
|
||||||
<div className="text-xs text-faint mt-0.5 truncate">
|
|
||||||
Pre-meeting suggestion{rec.time && <span> · {rec.time}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className="shrink-0 inline-flex items-center gap-1.5 rounded-pill px-2.5 py-1 text-2xs font-bold tracking-[0.02em] nums"
|
|
||||||
style={{ background: 'transparent', color: accent }}
|
|
||||||
title={autonomous ? 'Autonomous' : 'Needs review'}
|
|
||||||
>
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full" style={{ background: accent }} />
|
|
||||||
{pct}%
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="px-5 pb-5">
|
|
||||||
{/* recommended product — navy hero tile, echoes the premium card */}
|
|
||||||
<div className="rounded-xl bg-navy-grad p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<span className="shrink-0 w-11 h-11 rounded-xl bg-sunrise text-white flex items-center justify-center shadow-sunrise">
|
|
||||||
<ShieldCheck size={22} />
|
|
||||||
</span>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-1 text-2xs font-bold uppercase tracking-[0.06em] text-sunrise-300">
|
|
||||||
<Sparkles size={11} /> Recommends
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-lg font-bold text-on-navy leading-snug break-words">{rec.product}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{rec.sumAssured != null && (
|
|
||||||
<div className="mt-3.5 flex items-baseline justify-between gap-2 border-t border-[rgba(255,255,255,0.12)] pt-3.5">
|
|
||||||
<span className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy-muted shrink-0">Suggested cover</span>
|
|
||||||
<span className="font-numeric text-xl font-extrabold text-on-navy nums truncate">₹{formatINR(rec.sumAssured)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* why it recommended this — expands inline, no inner scrollbar */}
|
|
||||||
{rec.rationale && (
|
|
||||||
<div className="mt-3.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen((o) => !o)}
|
|
||||||
className="inline-flex items-center gap-1.5 bg-none border-none p-0 cursor-pointer font-sans text-xs font-semibold text-link"
|
|
||||||
>
|
|
||||||
<ChevronRight size={14} className={open ? 'rotate-90 transition-transform duration-200' : 'transition-transform duration-200'} />
|
|
||||||
{open ? 'Hide reasoning' : 'Why this product'}
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<p className="mt-2.5 px-4 py-3.5 bg-sunk rounded-xl text-sm leading-relaxed text-body whitespace-pre-line">
|
|
||||||
{rec.rationale}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-3.5">
|
|
||||||
<Button variant="primary" size="sm" full disabled={applying} onClick={onApply} iconLeft={<Wand2 size={14} />}>
|
|
||||||
{applying ? 'Applying…' : 'Use this suggestion'}
|
|
||||||
</Button>
|
|
||||||
{applyNote && <div className="mt-2 text-xs text-amber-700">{applyNote}</div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { AlertTriangle, CheckCircle2, Sparkles } from 'lucide-react';
|
import { CheckCircle2 } from 'lucide-react';
|
||||||
import { Button, Card, SelectField } from '../';
|
import { Button, Card, formatINR, SelectField } from '../';
|
||||||
import { SchemaGuard } from '../form';
|
|
||||||
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 type { ActivityBodyProps } from './types';
|
import type { ActivityBodyProps } from './types';
|
||||||
|
|
||||||
const F = ACTIVITIES.SUBMIT_UNDERWRITING.fields;
|
const F = ACTIVITIES.SUBMIT_UNDERWRITING.fields;
|
||||||
@ -12,7 +12,7 @@ const F = ACTIVITIES.SUBMIT_UNDERWRITING.fields;
|
|||||||
export function UnderwritingBody({ instanceId, record, onSuccess }: ActivityBodyProps) {
|
export function UnderwritingBody({ instanceId, record, onSuccess }: ActivityBodyProps) {
|
||||||
const { client } = useZino();
|
const { client } = useZino();
|
||||||
|
|
||||||
const schemaQ = useQuery(() => client.formSchema(ACTIVITIES.SUBMIT_UNDERWRITING.uid, instanceId), [instanceId]);
|
const schemaQ = useQuery(() => client.formSchema(ACTIVITIES.SUBMIT_UNDERWRITING.uid, instanceId), []);
|
||||||
const statusOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.status)?.options ?? [], [schemaQ.data]);
|
const statusOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.status)?.options ?? [], [schemaQ.data]);
|
||||||
|
|
||||||
const [status, setStatus] = useState('approved');
|
const [status, setStatus] = useState('approved');
|
||||||
@ -20,6 +20,8 @@ export function UnderwritingBody({ instanceId, record, onSuccess }: ActivityBody
|
|||||||
if (record?.underwriting_status) setStatus(String(record.underwriting_status));
|
if (record?.underwriting_status) setStatus(String(record.underwriting_status));
|
||||||
}, [record]);
|
}, [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);
|
||||||
|
|
||||||
@ -42,12 +44,9 @@ export function UnderwritingBody({ instanceId, record, onSuccess }: ActivityBody
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lead = record;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SchemaGuard
|
|
||||||
loading={schemaQ.loading}
|
|
||||||
error={schemaQ.error}
|
|
||||||
action="submit underwriting for this lead"
|
|
||||||
>
|
|
||||||
<div className="grid gap-[18px] 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">
|
||||||
{result && (
|
{result && (
|
||||||
@ -64,6 +63,11 @@ export function UnderwritingBody({ instanceId, record, onSuccess }: ActivityBody
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Card title="Underwriting decision">
|
<Card title="Underwriting decision">
|
||||||
|
{lead?.eligibility_notes && (
|
||||||
|
<div className="text-sm text-muted bg-app border border-border-subtle rounded-md px-3.5 py-2.5 mb-4">
|
||||||
|
<span className="font-semibold text-strong">Eligibility notes:</span> {lead.eligibility_notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<SelectField
|
<SelectField
|
||||||
label="Underwriting status"
|
label="Underwriting status"
|
||||||
@ -84,138 +88,68 @@ export function UnderwritingBody({ instanceId, record, onSuccess }: ActivityBody
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{record?.eligibility_notes ? (
|
<div className="bg-navy-grad rounded-lg px-5 py-[22px] shadow-md">
|
||||||
<EligibilityNotes notes={String(record.eligibility_notes)} status={record.eligibility_status} />
|
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy-muted mb-3.5">
|
||||||
) : (
|
Underwriting assessment
|
||||||
<div className="bg-navy-grad rounded-lg px-5 py-[22px] shadow-md text-sm text-on-navy-muted">
|
|
||||||
No eligibility assessment on file yet — Aria Eligibility writes it when documents are collected.
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{a && a.ok ? (
|
||||||
</div>
|
<>
|
||||||
</div>
|
<div className="flex justify-between mb-2">
|
||||||
</SchemaGuard>
|
<span className="text-sm text-on-navy-muted">Applicant age</span>
|
||||||
);
|
<span className="text-sm font-semibold text-on-navy nums">{a.age ?? '—'}</span>
|
||||||
}
|
</div>
|
||||||
|
<div className="flex justify-between mb-2">
|
||||||
|
<span className="text-sm text-on-navy-muted">Sum assured</span>
|
||||||
|
<span className="text-sm font-semibold text-on-navy nums">₹{formatINR(a.sumAssured)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-on-navy-muted">Eligibility</span>
|
||||||
|
<span className="text-sm font-semibold text-on-navy">{lead?.eligibility_status ?? '—'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
// --- Eligibility notes (AI-generated, free-form) -------------------------------
|
<div className="h-px bg-[rgba(255,255,255,0.12)] my-4" />
|
||||||
// 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 =
|
<div className="mb-3">
|
||||||
| { kind: 'heading'; text: string; inline?: string }
|
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy-muted mb-1">
|
||||||
| { kind: 'check'; ok: boolean; text: string }
|
Financial
|
||||||
| { kind: 'bullet'; text: string }
|
</div>
|
||||||
| { kind: 'para'; text: string };
|
<div className={a.financialRequired ? 'text-sm font-semibold text-sunrise-300' : 'text-sm text-emerald-300'}>
|
||||||
|
{a.financialText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
function parseNotes(raw: string): NoteBlock[] {
|
<div className="mb-3">
|
||||||
const blocks: NoteBlock[] = [];
|
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy-muted mb-1">
|
||||||
for (const line of raw.split(/\r?\n/)) {
|
Medical
|
||||||
const t = line.trim();
|
</div>
|
||||||
if (!t) continue;
|
<div className={a.medicalRequired ? 'text-sm font-semibold text-sunrise-300' : 'text-sm text-emerald-300'}>
|
||||||
let m: RegExpMatchArray | null;
|
{a.medicalText}
|
||||||
if ((m = t.match(/^[✓✔☑]\s*(.+)$/))) {
|
</div>
|
||||||
blocks.push({ kind: 'check', ok: true, text: m[1] });
|
</div>
|
||||||
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 }) {
|
<div className="flex items-center gap-2 mt-4">
|
||||||
const blocks = useMemo(() => parseNotes(notes), [notes]);
|
<span className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy-muted">Route</span>
|
||||||
const structured = blocks.some((b) => b.kind !== 'para');
|
{a.route.map((r) => (
|
||||||
|
<span
|
||||||
const s = (status ?? '').toLowerCase();
|
key={r}
|
||||||
// On the navy card, status reads as a tinted text on a translucent chip.
|
className="text-xs font-semibold text-on-navy bg-[rgba(255,255,255,0.1)] rounded px-2 py-0.5"
|
||||||
const chipText =
|
>
|
||||||
s === 'eligible' ? 'text-emerald-300' : s === 'ineligible' ? 'text-ruby-500' : 'text-amber-300'; // refer / unknown
|
{r}
|
||||||
|
</span>
|
||||||
let first = true;
|
))}
|
||||||
|
</div>
|
||||||
return (
|
</>
|
||||||
<div className="bg-navy-grad rounded-lg px-5 py-[22px] shadow-md">
|
) : (
|
||||||
<div className="flex items-center justify-between gap-2 mb-3.5">
|
<div className="text-sm text-on-navy-muted">
|
||||||
<div className="flex items-center gap-1.5 text-2xs font-bold uppercase tracking-[0.06em] text-on-navy-muted">
|
{record ? 'Not enough data (DOB / income / sum assured) to assess.' : 'Not enough data to assess.'}
|
||||||
<Sparkles size={12} className="text-sunrise-300" />
|
</div>
|
||||||
Eligibility assessment · Aria Eligibility
|
)}
|
||||||
</div>
|
</div>
|
||||||
{status && (
|
<div className="text-xs text-faint px-1">
|
||||||
<span className={`shrink-0 rounded-pill bg-white/10 px-2 py-0.5 text-2xs font-bold ${chipText}`}>
|
Assessment is advisory and computed in-app from the lead's data. NML limits are
|
||||||
{status}
|
placeholder values pending the real ABSLI grid.
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{structured ? (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
{blocks.map((b, i) => {
|
|
||||||
if (b.kind === 'heading') {
|
|
||||||
const el = (
|
|
||||||
<div key={i} className={first ? '' : 'mt-3 pt-3 border-t border-white/10'}>
|
|
||||||
<div className="text-2xs font-bold uppercase tracking-[0.04em] text-on-navy-muted">{b.text}</div>
|
|
||||||
{b.inline && <div className="mt-1 text-sm text-on-navy leading-relaxed">{b.inline}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
first = false;
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
if (b.kind === 'check') {
|
|
||||||
return (
|
|
||||||
<div key={i} className="flex items-start gap-2 text-sm text-on-navy">
|
|
||||||
{b.ok ? (
|
|
||||||
<CheckCircle2 size={15} className="mt-0.5 shrink-0 text-emerald-300" />
|
|
||||||
) : (
|
|
||||||
<AlertTriangle size={15} className="mt-0.5 shrink-0 text-amber-300" />
|
|
||||||
)}
|
|
||||||
<span>{b.text}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (b.kind === 'bullet') {
|
|
||||||
return (
|
|
||||||
<div key={i} className="flex items-start gap-2 text-sm text-on-navy">
|
|
||||||
<span className="mt-1.5 shrink-0 w-1 h-1 rounded-full bg-on-navy-muted" />
|
|
||||||
<span>{b.text}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<p key={i} className="text-sm text-on-navy leading-relaxed">
|
|
||||||
{b.text}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
// Fallback — render the model's output verbatim, line breaks preserved.
|
|
||||||
<p className="text-sm text-on-navy leading-relaxed whitespace-pre-line">{notes}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,13 +11,6 @@ import { LeadActions, AddLeadButton } from './ActivityActions';
|
|||||||
|
|
||||||
const PAGE_SIZE = 10;
|
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. */
|
/** A compact stat tile computed over this screen's (already state-filtered) rows. */
|
||||||
export interface TileSpec {
|
export interface TileSpec {
|
||||||
label: string;
|
label: string;
|
||||||
@ -41,16 +34,14 @@ export function Worklist({ states, emptyHint, showAddLead, tiles }: WorklistProp
|
|||||||
const { records, fields, loading, refetch } = useLeads();
|
const { records, fields, loading, refetch } = useLeads();
|
||||||
const [filters, setFilters] = useState<FilterState>(EMPTY_FILTER);
|
const [filters, setFilters] = useState<FilterState>(EMPTY_FILTER);
|
||||||
|
|
||||||
const allowed = useMemo(() => new Set(states), [states]);
|
const order = useMemo(() => new Map(states.map((s, i) => [s, i])), [states]);
|
||||||
// Scope to this screen's states, latest-first: most recently actioned
|
// Scope to this screen's states, then apply the search/filter bar.
|
||||||
// (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(
|
const scoped = useMemo(
|
||||||
() =>
|
() =>
|
||||||
records
|
records
|
||||||
.filter((r) => allowed.has(r.current_state_name ?? ''))
|
.filter((r) => order.has(r.current_state_name ?? ''))
|
||||||
.sort((a, b) => ms(b.updated_at) - ms(a.updated_at) || Number(b.instance_id) - Number(a.instance_id)),
|
.sort((a, b) => order.get(a.current_state_name ?? '')! - order.get(b.current_state_name ?? '')!),
|
||||||
[records, allowed],
|
[records, order],
|
||||||
);
|
);
|
||||||
const recs = useMemo(() => applyFilters(scoped, filters, fields), [scoped, filters, fields]);
|
const recs = useMemo(() => applyFilters(scoped, filters, fields), [scoped, filters, fields]);
|
||||||
const rows = useMemo(() => recs.map((record) => ({ record, lead: recordToLead(record) })), [recs]);
|
const rows = useMemo(() => recs.map((record) => ({ record, lead: recordToLead(record) })), [recs]);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
import { Lightbulb, Phone, ShieldCheck, Sparkles, type LucideIcon } from 'lucide-react';
|
import { Sparkles } from 'lucide-react';
|
||||||
import { cn } from '../../lib/cn';
|
import { cn } from '../../lib/cn';
|
||||||
|
|
||||||
export interface AvatarProps {
|
export interface AvatarProps {
|
||||||
@ -26,25 +26,6 @@ function initials(name = ''): string {
|
|||||||
return ((p[0]?.[0] || '') + (p[1]?.[0] || '')).toUpperCase();
|
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. */
|
/** Circular avatar with deterministic color from name; supports AI-employee glyph. */
|
||||||
export function Avatar({ name = '', src, size = 40, ai = false, className, style }: AvatarProps) {
|
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) };
|
const dim: CSSProperties = { width: size, height: size, fontSize: Math.round(size * 0.4) };
|
||||||
@ -60,24 +41,16 @@ export function Avatar({ name = '', src, size = 40, ai = false, className, style
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const persona = ai ? aiPersona(name) : null;
|
|
||||||
const AiIcon = persona?.Icon ?? Sparkles;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-full inline-flex items-center justify-center font-sans font-bold text-white shrink-0',
|
'rounded-full inline-flex items-center justify-center font-sans font-bold text-white shrink-0',
|
||||||
ai && !persona && 'bg-sunrise shadow-sunrise',
|
ai && 'bg-sunrise shadow-sunrise',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{ ...dim, background: ai ? undefined : COLORS[hashIdx(name)], ...style }}
|
||||||
...dim,
|
|
||||||
background: ai ? persona?.bg : COLORS[hashIdx(name)],
|
|
||||||
boxShadow: persona?.shadow,
|
|
||||||
...style,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{ai ? <AiIcon size={Math.round(size * 0.46)} /> : initials(name)}
|
{ai ? <Sparkles size={Math.round(size * 0.46)} /> : initials(name)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,11 +22,10 @@ export function Input({
|
|||||||
suffix,
|
suffix,
|
||||||
required,
|
required,
|
||||||
className,
|
className,
|
||||||
disabled,
|
|
||||||
...rest
|
...rest
|
||||||
}: InputProps) {
|
}: InputProps) {
|
||||||
return (
|
return (
|
||||||
<label className={cn('flex flex-col gap-1.5 font-sans', disabled && 'cursor-not-allowed', className)}>
|
<label className={cn('flex flex-col gap-1.5 font-sans', className)}>
|
||||||
{label && (
|
{label && (
|
||||||
<span className="text-sm font-medium text-muted">
|
<span className="text-sm font-medium text-muted">
|
||||||
{label}
|
{label}
|
||||||
@ -35,19 +34,14 @@ export function Input({
|
|||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 rounded-md px-3 h-[42px] border transition-[border-color,box-shadow] duration-150',
|
'flex items-center gap-2 bg-card rounded-md px-3 h-[42px] border transition-[border-color,box-shadow] duration-150',
|
||||||
disabled ? 'bg-sunk border-border-subtle' : 'bg-card',
|
error ? 'border-ruby-600' : 'border-border-default focus-ring',
|
||||||
error ? 'border-ruby-600' : !disabled && 'border-border-default focus-ring',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{prefix && <span className={cn('text-base font-semibold', disabled ? 'text-faint' : 'text-faint')}>{prefix}</span>}
|
{prefix && <span className="text-faint text-base font-semibold">{prefix}</span>}
|
||||||
<input
|
<input
|
||||||
required={required}
|
required={required}
|
||||||
disabled={disabled}
|
className="flex-1 w-full border-none outline-none bg-transparent font-sans text-base text-strong"
|
||||||
className={cn(
|
|
||||||
'flex-1 w-full border-none outline-none bg-transparent font-sans text-base',
|
|
||||||
disabled ? 'text-muted cursor-not-allowed' : 'text-strong',
|
|
||||||
)}
|
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
{suffix && <span className="text-faint text-sm">{suffix}</span>}
|
{suffix && <span className="text-faint text-sm">{suffix}</span>}
|
||||||
|
|||||||
@ -52,15 +52,11 @@ export function Modal({ open, onClose, title, subtitle, width = 'md', children }
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
// Capped well below the viewport so the box stays a contained dialog
|
|
||||||
// (visible backdrop margin) — the body scrolls internally rather than
|
|
||||||
// the whole dialog growing with content and taking over the screen.
|
|
||||||
'w-full bg-card rounded-lg border border-border-subtle shadow-lg my-auto',
|
'w-full bg-card rounded-lg border border-border-subtle shadow-lg my-auto',
|
||||||
'flex flex-col max-h-[85vh]',
|
|
||||||
WIDTH[width],
|
WIDTH[width],
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<header className="shrink-0 flex items-start justify-between gap-3 px-5 py-4 border-b border-border-subtle">
|
<header className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border-subtle">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{title && <h3 className="m-0 text-md font-semibold text-strong truncate">{title}</h3>}
|
{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>}
|
{subtitle && <div className="text-xs text-faint mt-0.5">{subtitle}</div>}
|
||||||
@ -73,7 +69,7 @@ export function Modal({ open, onClose, title, subtitle, width = 'md', children }
|
|||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto p-5 scrollbar-slim">{children}</div>
|
<div className="p-5">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
|
|||||||
@ -4,7 +4,7 @@ export { Card } from './Card';
|
|||||||
export type { CardProps, Elevation } from './Card';
|
export type { CardProps, Elevation } from './Card';
|
||||||
export { Badge } from './Badge';
|
export { Badge } from './Badge';
|
||||||
export type { BadgeProps, BadgeTone } from './Badge';
|
export type { BadgeProps, BadgeTone } from './Badge';
|
||||||
export { Avatar, aiEmployeeColor } from './Avatar';
|
export { Avatar } from './Avatar';
|
||||||
export type { AvatarProps } from './Avatar';
|
export type { AvatarProps } from './Avatar';
|
||||||
export { Input } from './Input';
|
export { Input } from './Input';
|
||||||
export type { InputProps } from './Input';
|
export type { InputProps } from './Input';
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { CheckCircle2, FileText, UploadCloud } 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';
|
||||||
import { MultiSelect } from '../core/MultiSelect';
|
import { MultiSelect } from '../core/MultiSelect';
|
||||||
import { LookupField } from './LookupField';
|
import { LookupField } from './LookupField';
|
||||||
import type { LookupValue } from './LookupField';
|
import type { LookupValue } from './LookupField';
|
||||||
import { isPermissionError, SchemaGuard } from './SchemaGuard';
|
|
||||||
import { useQuery, useZino } from '../../api/provider';
|
import { useQuery, useZino } from '../../api/provider';
|
||||||
import { schemaToFields } from '../../api/schema';
|
import { schemaToFields } from '../../api/schema';
|
||||||
import type { ActivityMeta, FieldDef } from '../../api/forms';
|
import type { ActivityMeta, FieldDef } from '../../api/forms';
|
||||||
@ -46,13 +45,17 @@ function seedFor(field: FieldDef, record?: LeadRecord | null): unknown {
|
|||||||
return [];
|
return [];
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
return false;
|
return false;
|
||||||
case 'file':
|
|
||||||
return null; // holds a File once picked; uploaded on submit
|
|
||||||
default:
|
default:
|
||||||
return '';
|
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 <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) {
|
||||||
@ -83,7 +86,7 @@ export function ActivityForm({ meta, instanceId, record, onSuccess, successMessa
|
|||||||
const missing = useMemo(
|
const missing = useMemo(
|
||||||
() =>
|
() =>
|
||||||
fields.filter((f) => {
|
fields.filter((f) => {
|
||||||
if (!f.required || f.hidden) return false; // hidden fields are auto-seeded, not user-entered
|
if (!f.required) return false;
|
||||||
const v = values[f.id];
|
const v = values[f.id];
|
||||||
if (Array.isArray(v)) return v.length === 0;
|
if (Array.isArray(v)) return v.length === 0;
|
||||||
return v === '' || v === null || v === undefined;
|
return v === '' || v === null || v === undefined;
|
||||||
@ -99,23 +102,17 @@ export function ActivityForm({ meta, instanceId, record, onSuccess, successMessa
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
|
|
||||||
try {
|
// Drop empty optional values so we don't overwrite with blanks.
|
||||||
// Drop empty optional values so we don't overwrite with blanks.
|
const data: Record<string, unknown> = {};
|
||||||
const data: Record<string, unknown> = {};
|
for (const f of fields) {
|
||||||
for (const f of fields) {
|
const v = values[f.id];
|
||||||
const v = values[f.id];
|
if (v === '' || v === null || v === undefined) continue;
|
||||||
if (v === '' || v === null || v === undefined) continue;
|
if (Array.isArray(v) && v.length === 0) continue;
|
||||||
if (Array.isArray(v) && v.length === 0) continue;
|
data[f.id] =
|
||||||
if (f.type === 'file') {
|
f.type === 'number' ? Number(v) || 0 : f.type === 'datetime' ? toRfc3339(String(v)) : v;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
try {
|
||||||
const res =
|
const res =
|
||||||
instanceId != null
|
instanceId != null
|
||||||
? await client.performActivity(instanceId, meta.uid, data)
|
? await client.performActivity(instanceId, meta.uid, data)
|
||||||
@ -133,16 +130,34 @@ export function ActivityForm({ meta, instanceId, record, onSuccess, successMessa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (schemaQ.loading) {
|
||||||
|
return <div className="py-6 text-sm text-faint">Loading form…</div>;
|
||||||
|
}
|
||||||
|
if (schemaQ.error || !fields.length) {
|
||||||
|
if (isPermissionError(schemaQ.error)) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2.5 rounded-md border border-amber-200 bg-amber-50 px-4 py-3.5 text-sm text-amber-800">
|
||||||
|
<Lock size={16} className="mt-0.5 shrink-0 text-amber-600" />
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold">You 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 (
|
||||||
|
<div className="py-4 text-sm text-amber-700">
|
||||||
|
{schemaQ.error ? `Couldn't load the form: ${schemaQ.error}` : 'This activity has no input fields.'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SchemaGuard
|
|
||||||
loading={schemaQ.loading}
|
|
||||||
error={schemaQ.error}
|
|
||||||
empty={!fields.length}
|
|
||||||
action={meta.name.toLowerCase()}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{fields.filter((f) => !f.hidden).map((f) => (
|
{fields.map((f) => (
|
||||||
<Field
|
<Field
|
||||||
key={f.id}
|
key={f.id}
|
||||||
field={f}
|
field={f}
|
||||||
@ -150,7 +165,6 @@ export function ActivityForm({ meta, instanceId, record, onSuccess, successMessa
|
|||||||
onChange={(v) => set(f.id, v)}
|
onChange={(v) => set(f.id, v)}
|
||||||
activityId={meta.uid}
|
activityId={meta.uid}
|
||||||
instanceId={instanceId}
|
instanceId={instanceId}
|
||||||
formData={values}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -174,69 +188,6 @@ export function ActivityForm({ meta, instanceId, record, onSuccess, successMessa
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SchemaGuard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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<HTMLInputElement>(null);
|
|
||||||
const file = value instanceof File ? value : null;
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
<span className="text-sm font-medium text-muted">
|
|
||||||
{label}
|
|
||||||
{required && <span className="text-ruby-600"> *</span>}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
ref={ref}
|
|
||||||
type="file"
|
|
||||||
accept=".pdf,.png,.jpg,.jpeg"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
onChange(e.target.files?.[0] ?? null);
|
|
||||||
e.target.value = '';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => ref.current?.click()}
|
|
||||||
className="mt-1.5 w-full flex items-center gap-2.5 border border-dashed border-border-default rounded-md bg-slate-50 px-3.5 py-3 cursor-pointer text-left"
|
|
||||||
>
|
|
||||||
{file ? (
|
|
||||||
<FileText size={18} className="text-sunrise-600 shrink-0" />
|
|
||||||
) : (
|
|
||||||
<UploadCloud size={18} className="text-sunrise-500 shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="text-sm text-body truncate flex-1">
|
|
||||||
{file ? file.name : 'Upload file (PDF / image)'}
|
|
||||||
</span>
|
|
||||||
{file && (
|
|
||||||
<span
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onChange(null);
|
|
||||||
}}
|
|
||||||
className="text-2xs font-semibold text-link"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -246,34 +197,17 @@ function Field({
|
|||||||
onChange,
|
onChange,
|
||||||
activityId,
|
activityId,
|
||||||
instanceId,
|
instanceId,
|
||||||
formData,
|
|
||||||
}: {
|
}: {
|
||||||
field: FieldDef;
|
field: FieldDef;
|
||||||
value: unknown;
|
value: unknown;
|
||||||
onChange: (v: unknown) => void;
|
onChange: (v: unknown) => void;
|
||||||
activityId: string;
|
activityId: string;
|
||||||
instanceId?: number | string;
|
instanceId?: number | string;
|
||||||
formData?: Record<string, unknown>;
|
|
||||||
}) {
|
}) {
|
||||||
const full =
|
const full = field.type === 'longtext' || field.type === 'multiselect' || field.type === 'lookup';
|
||||||
field.type === 'longtext' ||
|
|
||||||
field.type === 'multiselect' ||
|
|
||||||
field.type === 'lookup' ||
|
|
||||||
field.type === 'file';
|
|
||||||
const wrap = full ? 'col-span-full' : '';
|
const wrap = full ? 'col-span-full' : '';
|
||||||
|
|
||||||
switch (field.type) {
|
switch (field.type) {
|
||||||
case 'file':
|
|
||||||
return (
|
|
||||||
<FileInput
|
|
||||||
className={wrap}
|
|
||||||
label={field.label}
|
|
||||||
required={field.required}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'longtext':
|
case 'longtext':
|
||||||
return (
|
return (
|
||||||
<label className={`${wrap} flex flex-col gap-1.5`}>
|
<label className={`${wrap} flex flex-col gap-1.5`}>
|
||||||
@ -342,7 +276,6 @@ function Field({
|
|||||||
instanceId={instanceId}
|
instanceId={instanceId}
|
||||||
value={(value as LookupValue) ?? null}
|
value={(value as LookupValue) ?? null}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
formData={formData}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -19,12 +19,6 @@ export interface LookupFieldProps {
|
|||||||
instanceId?: number | string;
|
instanceId?: number | string;
|
||||||
value: LookupValue | null;
|
value: LookupValue | null;
|
||||||
onChange: (v: LookupValue | null) => void;
|
onChange: (v: LookupValue | null) => void;
|
||||||
/** 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<string, unknown>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Row = Record<string, unknown>;
|
type Row = Record<string, unknown>;
|
||||||
@ -43,11 +37,8 @@ export function LookupField({
|
|||||||
instanceId,
|
instanceId,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
formData,
|
|
||||||
}: LookupFieldProps) {
|
}: LookupFieldProps) {
|
||||||
const { client } = useZino();
|
const { client } = useZino();
|
||||||
// Serialized so the effect refetches when a dependency value changes.
|
|
||||||
const formKey = JSON.stringify(formData ?? {});
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [rows, setRows] = useState<Row[]>([]);
|
const [rows, setRows] = useState<Row[]>([]);
|
||||||
@ -82,11 +73,8 @@ export function LookupField({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
client
|
client
|
||||||
.lookupRecords(templateUid, { activityId, fieldId, instanceId, search, limit: 50, formData })
|
.lookupRecords(templateUid, { activityId, fieldId, instanceId, search, limit: 50 })
|
||||||
.then((r) => {
|
.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 ?? []));
|
if (live) setRows(dedupe(r.records ?? []));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@ -101,7 +89,7 @@ export function LookupField({
|
|||||||
clearTimeout(t);
|
clearTimeout(t);
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open, search, templateUid, activityId, fieldId, instanceId, client, formKey]);
|
}, [open, search, templateUid, activityId, fieldId, instanceId, client]);
|
||||||
|
|
||||||
function pick(row: Row) {
|
function pick(row: Row) {
|
||||||
const stored: LookupValue = {};
|
const stored: LookupValue = {};
|
||||||
|
|||||||
@ -1,51 +0,0 @@
|
|||||||
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 <uuid>". */
|
|
||||||
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 <ActivityForm> 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 <div className="py-6 text-sm text-faint">Loading form…</div>;
|
|
||||||
}
|
|
||||||
if (error || empty) {
|
|
||||||
if (isPermissionError(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 {action}. Ask an admin to grant your role access to this activity, or
|
|
||||||
sign in as a role that can.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="py-4 text-sm text-amber-700">
|
|
||||||
{error ? `Couldn’t load the form: ${error}` : 'This activity has no input fields.'}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
@ -1,3 +1,2 @@
|
|||||||
export * from './ActivityForm';
|
export * from './ActivityForm';
|
||||||
export * from './LookupField';
|
export * from './LookupField';
|
||||||
export * from './SchemaGuard';
|
|
||||||
|
|||||||
@ -60,80 +60,81 @@ export function AIDecisionCard({
|
|||||||
style={autonomous ? undefined : { background: 'var(--status-escalated)' }}
|
style={autonomous ? undefined : { background: 'var(--status-escalated)' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* header band — identity + confidence, tinted by autonomy state */}
|
<div className="flex gap-4 pl-[22px] pr-5 py-[18px]">
|
||||||
<header
|
{/* left: identity + content */}
|
||||||
className="flex items-center gap-3 pl-[22px] pr-5 py-3.5 border-b border-border-subtle"
|
<div className="flex-1 min-w-0">
|
||||||
style={{ background: accentSoft }}
|
<header className="flex items-center gap-2.5 mb-3">
|
||||||
>
|
<Avatar name={employee} ai size={36} />
|
||||||
<Avatar name={employee} ai size={38} />
|
<div className="min-w-0">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<span className="text-base font-bold text-strong">{employee}</span>
|
||||||
<span className="text-base font-bold text-strong leading-tight">{employee}</span>
|
<span className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint bg-sunk px-[7px] py-[3px] rounded-pill">
|
||||||
<span className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint bg-card border border-border-subtle px-[7px] py-[3px] rounded-pill">
|
{role}
|
||||||
{role}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted mt-0.5 truncate">
|
|
||||||
{activity}
|
|
||||||
{time && <span className="text-faint"> · {time}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0">
|
|
||||||
<ConfidenceRing value={confidence} size={64} threshold={threshold} />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="pl-[22px] pr-5 py-[18px]">
|
|
||||||
{/* status pill */}
|
|
||||||
<div
|
|
||||||
className="inline-flex items-center gap-1.5 text-2xs font-bold tracking-[0.03em] px-[11px] py-[5px] rounded-pill mb-2.5"
|
|
||||||
style={{ background: accentSoft, color: accent }}
|
|
||||||
>
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full" style={{ background: accent }} />
|
|
||||||
{autonomous ? 'AUTONOMOUS DECISION' : 'ESCALATED TO HUMAN'}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* one-line reasoning summary */}
|
|
||||||
<p className="m-0 text-base leading-normal text-body">{summary}</p>
|
|
||||||
|
|
||||||
{/* expandable full reasoning */}
|
|
||||||
{reasoning && (
|
|
||||||
<div className="mt-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen((o) => !o)}
|
|
||||||
className="inline-flex items-center gap-1.5 bg-none border-none p-0 cursor-pointer font-sans text-xs font-semibold text-link"
|
|
||||||
>
|
|
||||||
<ChevronRight size={14} className={cn('transition-transform duration-200', open && 'rotate-90')} />
|
|
||||||
{open ? 'Hide full reasoning' : 'Show full reasoning'}
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<div className="mt-2.5 px-4 py-3.5 bg-sunk rounded-lg">
|
|
||||||
<ReasoningBody text={reasoning} accent={accent} accentSoft={accentSoft} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* citation chips */}
|
|
||||||
{citations.length > 0 && (
|
|
||||||
<div className="mt-3.5">
|
|
||||||
<div className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint mb-[7px]">
|
|
||||||
Cited knowledge
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-[7px]">
|
|
||||||
{citations.map((c, i) => (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted bg-card border border-border-default rounded-sm px-2.5 py-[5px]"
|
|
||||||
>
|
|
||||||
<FileText size={12} className="text-sunrise-500" />
|
|
||||||
{typeof c === 'string' ? c : c.title}
|
|
||||||
</span>
|
</span>
|
||||||
))}
|
</div>
|
||||||
|
<div className="text-xs text-muted mt-0.5">
|
||||||
|
{activity}
|
||||||
|
{time && <span className="text-faint"> · {time}</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* status pill */}
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center gap-1.5 text-2xs font-bold tracking-[0.03em] px-[11px] py-[5px] rounded-pill mb-2.5"
|
||||||
|
style={{ background: accentSoft, color: accent }}
|
||||||
|
>
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full" style={{ background: accent }} />
|
||||||
|
{autonomous ? 'AUTONOMOUS DECISION' : 'ESCALATED TO HUMAN'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{/* one-line reasoning summary */}
|
||||||
|
<p className="m-0 text-base leading-normal text-body">{summary}</p>
|
||||||
|
|
||||||
|
{/* expandable full reasoning */}
|
||||||
|
{reasoning && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="inline-flex items-center gap-1.5 bg-none border-none p-0 cursor-pointer font-sans text-xs font-semibold text-link"
|
||||||
|
>
|
||||||
|
<ChevronRight size={14} className={cn('transition-transform duration-200', open && 'rotate-90')} />
|
||||||
|
{open ? 'Hide full reasoning' : 'Show full reasoning'}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="mt-2.5 px-4 py-3.5 bg-sunk rounded-lg max-h-[320px] overflow-y-auto">
|
||||||
|
<ReasoningBody text={reasoning} accent={accent} accentSoft={accentSoft} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* citation chips */}
|
||||||
|
{citations.length > 0 && (
|
||||||
|
<div className="mt-3.5">
|
||||||
|
<div className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint mb-[7px]">
|
||||||
|
Cited knowledge
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-[7px]">
|
||||||
|
{citations.map((c, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted bg-card border border-border-default rounded-sm px-2.5 py-[5px]"
|
||||||
|
>
|
||||||
|
<FileText size={12} className="text-sunrise-500" />
|
||||||
|
{typeof c === 'string' ? c : c.title}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* right: confidence ring */}
|
||||||
|
<div className="shrink-0 flex flex-col items-center pt-0.5">
|
||||||
|
<ConfidenceRing value={confidence} size={76} threshold={threshold} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -28,8 +28,8 @@ export function AIDecisionStream({
|
|||||||
threshold = 0.7,
|
threshold = 0.7,
|
||||||
className,
|
className,
|
||||||
}: AIDecisionStreamProps) {
|
}: AIDecisionStreamProps) {
|
||||||
// Single-open accordion; all collapsed by default (-1 = none open).
|
// Single-open accordion; newest (index 0) open by default.
|
||||||
const [openIdx, setOpenIdx] = useState(-1);
|
const [openIdx, setOpenIdx] = useState(0);
|
||||||
const escalations = decisions.filter((d) => d.confidence < threshold);
|
const escalations = decisions.filter((d) => d.confidence < threshold);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -163,7 +163,7 @@ function DecisionRow({
|
|||||||
{open && (d.reasoning || d.citations.length > 0) && (
|
{open && (d.reasoning || d.citations.length > 0) && (
|
||||||
<div className="px-3.5 pb-4">
|
<div className="px-3.5 pb-4">
|
||||||
{d.reasoning && (
|
{d.reasoning && (
|
||||||
<div className="px-4 py-3.5 bg-sunk rounded-lg">
|
<div className="px-4 py-3.5 bg-sunk rounded-lg max-h-[300px] overflow-y-auto">
|
||||||
<ReasoningBody text={d.reasoning} accent={accent} accentSoft={accentSoft} />
|
<ReasoningBody text={d.reasoning} accent={accent} accentSoft={accentSoft} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,92 +0,0 @@
|
|||||||
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 (
|
|
||||||
<div className={cn('flex flex-col gap-2.5', className)}>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-1.5 text-2xs font-bold uppercase tracking-[0.06em] text-faint">
|
|
||||||
<Sparkles size={12} className="text-sunrise-500" />
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
{collapsible && open && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
className="inline-flex items-center gap-1 bg-none border-none p-0 cursor-pointer font-sans text-2xs font-semibold text-link"
|
|
||||||
>
|
|
||||||
<ChevronUp size={12} />
|
|
||||||
Collapse
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{open ? (
|
|
||||||
<AIDecisionCard {...card} />
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
className="group relative w-full text-left bg-card rounded-lg border border-border-subtle shadow-sm hover:shadow-md transition-shadow overflow-hidden font-sans flex items-center gap-3 pl-[14px] pr-3 py-2.5"
|
|
||||||
>
|
|
||||||
{/* accent rail */}
|
|
||||||
<span className="absolute top-0 left-0 bottom-0 w-1" style={{ background: accent }} />
|
|
||||||
<Avatar name={employee} ai size={30} />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-bold text-strong leading-tight truncate">{employee}</span>
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1 text-2xs font-bold tracking-[0.03em] px-1.5 py-0.5 rounded-pill shrink-0"
|
|
||||||
style={{ background: accentSoft, color: accent }}
|
|
||||||
>
|
|
||||||
{autonomous ? <Check size={10} strokeWidth={3} /> : <AlertTriangle size={10} strokeWidth={3} />}
|
|
||||||
{pct}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{summary && <div className="text-xs text-muted mt-0.5 truncate">{summary}</div>}
|
|
||||||
</div>
|
|
||||||
<ChevronDown
|
|
||||||
size={16}
|
|
||||||
className="shrink-0 text-faint transition-transform group-hover:translate-y-0.5"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -20,10 +20,6 @@ export interface DocumentUploadCardProps {
|
|||||||
fileName?: string | null;
|
fileName?: string | null;
|
||||||
onUpload?: () => void;
|
onUpload?: () => void;
|
||||||
onConfirm?: () => 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;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,14 +53,12 @@ export function DocumentUploadCard({
|
|||||||
fileName = null,
|
fileName = null,
|
||||||
onUpload,
|
onUpload,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onReplace,
|
|
||||||
onRemove,
|
|
||||||
className,
|
className,
|
||||||
}: DocumentUploadCardProps) {
|
}: DocumentUploadCardProps) {
|
||||||
const isEmpty = status === 'empty';
|
const isEmpty = status === 'empty';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('h-full flex flex-col bg-card border border-border-subtle rounded-lg shadow-sm overflow-hidden font-sans', className)}>
|
<div className={cn('bg-card border border-border-subtle rounded-lg shadow-sm overflow-hidden font-sans', className)}>
|
||||||
<header className="flex items-center justify-between px-[18px] py-3.5 border-b border-border-subtle">
|
<header className="flex items-center justify-between px-[18px] py-3.5 border-b border-border-subtle">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<span className="w-[34px] h-[34px] rounded-sm bg-sunrise-100 text-sunrise-600 flex items-center justify-center">
|
<span className="w-[34px] h-[34px] rounded-sm bg-sunrise-100 text-sunrise-600 flex items-center justify-center">
|
||||||
@ -78,19 +72,19 @@ export function DocumentUploadCard({
|
|||||||
{STATUS_BADGE[status]}
|
{STATUS_BADGE[status]}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col p-[18px]">
|
<div className="p-[18px]">
|
||||||
{isEmpty ? (
|
{isEmpty ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onUpload}
|
onClick={onUpload}
|
||||||
className="flex-1 w-full border-2 border-dashed border-border-default rounded-md bg-slate-50 px-4 py-7 cursor-pointer flex flex-col items-center justify-center gap-2 font-sans"
|
className="w-full border-2 border-dashed border-border-default rounded-md bg-slate-50 px-4 py-7 cursor-pointer flex flex-col items-center gap-2 font-sans"
|
||||||
>
|
>
|
||||||
<UploadCloud size={28} className="text-sunrise-500" />
|
<UploadCloud size={28} className="text-sunrise-500" />
|
||||||
<span className="text-sm font-semibold text-strong">Upload {docType} card</span>
|
<span className="text-sm font-semibold text-strong">Upload {docType} card</span>
|
||||||
<span className="text-xs text-faint">JPG, PNG or PDF · OCR auto-extracts fields</span>
|
<span className="text-xs text-faint">JPG, PNG or PDF · OCR auto-extracts fields</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<div className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint mb-2">
|
<div className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint mb-2">
|
||||||
OCR-extracted fields
|
OCR-extracted fields
|
||||||
</div>
|
</div>
|
||||||
@ -125,34 +119,15 @@ export function DocumentUploadCard({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{/* mt-auto keeps the action row pinned to the card bottom, so both
|
{status === 'extracted' && (
|
||||||
cards' Confirm/Replace align even with differing field counts. */}
|
<button
|
||||||
<div className="mt-auto flex flex-col gap-2.5 pt-3.5">
|
type="button"
|
||||||
{status === 'extracted' && (
|
onClick={onConfirm}
|
||||||
<button
|
className="mt-3.5 w-full h-10 border-none rounded-md bg-sunrise text-white font-sans text-base font-semibold cursor-pointer shadow-sunrise"
|
||||||
type="button"
|
>
|
||||||
onClick={onConfirm}
|
Confirm & verify
|
||||||
className="w-full h-10 border-none rounded-md bg-sunrise text-white font-sans text-base font-semibold cursor-pointer shadow-sunrise"
|
</button>
|
||||||
>
|
)}
|
||||||
Confirm & verify
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{(onReplace || onRemove) && status !== 'processing' && (
|
|
||||||
<div className="flex items-center justify-center gap-3 text-xs">
|
|
||||||
{onReplace && (
|
|
||||||
<button type="button" onClick={onReplace} className="font-semibold text-link">
|
|
||||||
Replace file
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onReplace && onRemove && <span className="text-faint">·</span>}
|
|
||||||
{onRemove && (
|
|
||||||
<button type="button" onClick={onRemove} className="font-semibold text-faint hover:text-ruby-600">
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -46,21 +46,6 @@ export function parseReasoning(raw: string): ReasoningParts {
|
|||||||
return { intro: text, steps: [] };
|
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 {
|
export interface ReasoningBodyProps {
|
||||||
text: string;
|
text: string;
|
||||||
/** Accent color for the step badges (matches the decision's autonomy state). */
|
/** Accent color for the step badges (matches the decision's autonomy state). */
|
||||||
@ -78,27 +63,17 @@ export function ReasoningBody({
|
|||||||
}: ReasoningBodyProps) {
|
}: ReasoningBodyProps) {
|
||||||
const { intro, steps } = parseReasoning(text);
|
const { intro, steps } = parseReasoning(text);
|
||||||
|
|
||||||
// No enumeration — split the prose into sentence-sized facts and render them
|
// No enumeration — render as prose paragraphs.
|
||||||
// as a scannable list, so a dense wall of reasoning reads point-by-point.
|
|
||||||
if (steps.length === 0) {
|
if (steps.length === 0) {
|
||||||
const facts = splitSentences(text);
|
const paras = text.split(/\n+/).map((p) => p.trim()).filter(Boolean);
|
||||||
if (facts.length < 2) {
|
|
||||||
return (
|
|
||||||
<p className={cn('m-0 text-sm leading-relaxed text-body', className)}>{text.trim()}</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<ul className={cn('m-0 p-0 list-none flex flex-col gap-2 text-sm leading-snug text-body', className)}>
|
<div className={cn('text-sm leading-relaxed text-body', className)}>
|
||||||
{facts.map((f, i) => (
|
{paras.map((p, i) => (
|
||||||
<li key={i} className="flex gap-2.5">
|
<p key={i} className={cn('m-0', i > 0 && 'mt-2')}>
|
||||||
<span
|
{p}
|
||||||
className="mt-[7px] shrink-0 w-1.5 h-1.5 rounded-full"
|
</p>
|
||||||
style={{ background: accent }}
|
|
||||||
/>
|
|
||||||
<span className="flex-1">{f}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
))}
|
||||||
</ul>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -38,30 +38,19 @@ const TONES: Record<RupeeTone, string> = {
|
|||||||
onNavy: 'var(--text-on-navy)',
|
onNavy: 'var(--text-on-navy)',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Large sizes shrink for long figures so a crore-scale number never spills out
|
/** Oversized confident rupee numeral via Inter Tight. */
|
||||||
// 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) {
|
export function RupeeAmount({ value, size = 'md', tone = 'default', sub, className, style }: RupeeAmountProps) {
|
||||||
const text = formatINR(value);
|
const fs = SIZES[size];
|
||||||
const fs = SIZES[size] * fitScale(size, text.length);
|
|
||||||
return (
|
return (
|
||||||
<span className={cn('flex flex-col leading-none font-numeric max-w-full min-w-0', className)} style={style}>
|
<span className={cn('inline-flex flex-col leading-none font-numeric', className)} style={style}>
|
||||||
<span
|
<span
|
||||||
className="font-extrabold tracking-[-0.02em] nums inline-flex items-baseline gap-0.5 max-w-full whitespace-nowrap overflow-hidden"
|
className="font-extrabold tracking-[-0.02em] nums inline-flex items-baseline gap-0.5"
|
||||||
style={{ fontSize: fs, color: TONES[tone] }}
|
style={{ fontSize: fs, color: TONES[tone] }}
|
||||||
>
|
>
|
||||||
<span className="font-bold shrink-0" style={{ fontSize: fs * 0.62 }}>
|
<span className="font-bold" style={{ fontSize: fs * 0.62 }}>
|
||||||
₹
|
₹
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate">{text}</span>
|
{formatINR(value)}
|
||||||
</span>
|
</span>
|
||||||
{sub && <span className="font-sans text-xs font-medium text-faint mt-1">{sub}</span>}
|
{sub && <span className="font-sans text-xs font-medium text-faint mt-1">{sub}</span>}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { cn } from '../../lib/cn';
|
import { cn } from '../../lib/cn';
|
||||||
import { aiEmployeeColor } from '../core/Avatar';
|
|
||||||
import type { Tone } from '../../types';
|
import type { Tone } from '../../types';
|
||||||
|
|
||||||
export interface TimelineEntryProps {
|
export interface TimelineEntryProps {
|
||||||
@ -36,24 +35,21 @@ export function TimelineEntry({
|
|||||||
last = false,
|
last = false,
|
||||||
className,
|
className,
|
||||||
}: TimelineEntryProps) {
|
}: 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 (
|
return (
|
||||||
<div className={cn('flex gap-3 font-sans', className)}>
|
<div className={cn('flex gap-3 font-sans', className)}>
|
||||||
<div className="flex flex-col items-center shrink-0">
|
<div className="flex flex-col items-center shrink-0">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-3 h-3 rounded-full border-2 border-card mt-[3px]',
|
'w-3 h-3 rounded-full border-2 border-card mt-[3px]',
|
||||||
!ai && DOT[tone],
|
ai ? 'bg-sunrise' : DOT[tone],
|
||||||
)}
|
)}
|
||||||
style={{ boxShadow: '0 0 0 1px var(--border-subtle)', background: aiColor }}
|
style={{ boxShadow: '0 0 0 1px var(--border-subtle)' }}
|
||||||
/>
|
/>
|
||||||
{!last && <span className="flex-1 w-0.5 bg-border-subtle mt-0.5 min-h-3.5" />}
|
{!last && <span className="flex-1 w-0.5 bg-border-subtle mt-0.5 min-h-3.5" />}
|
||||||
</div>
|
</div>
|
||||||
<div className={cn('flex-1', last ? 'pb-0' : 'pb-[18px]')}>
|
<div className={cn('flex-1', last ? 'pb-0' : 'pb-[18px]')}>
|
||||||
<div className="flex items-baseline gap-2 flex-wrap">
|
<div className="flex items-baseline gap-2 flex-wrap">
|
||||||
<span className="text-sm font-bold" style={{ color: aiColor || 'var(--text-strong)' }}>{actor}</span>
|
<span className={cn('text-sm font-bold', ai ? 'text-sunrise-600' : 'text-strong')}>{actor}</span>
|
||||||
<span className="text-sm text-body">{action}</span>
|
<span className="text-sm text-body">{action}</span>
|
||||||
<span className="ml-auto text-2xs text-faint whitespace-nowrap">{time}</span>
|
<span className="ml-auto text-2xs text-faint whitespace-nowrap">{time}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -14,8 +14,6 @@ export { TimelineEntry } from './TimelineEntry';
|
|||||||
export type { TimelineEntryProps } from './TimelineEntry';
|
export type { TimelineEntryProps } from './TimelineEntry';
|
||||||
export { AIDecisionCard } from './AIDecisionCard';
|
export { AIDecisionCard } from './AIDecisionCard';
|
||||||
export type { AIDecisionCardProps, Citation } from './AIDecisionCard';
|
export type { AIDecisionCardProps, Citation } from './AIDecisionCard';
|
||||||
export { AiSuggestionPanel } from './AiSuggestionPanel';
|
|
||||||
export type { AiSuggestionPanelProps } from './AiSuggestionPanel';
|
|
||||||
export { AIDecisionStream } from './AIDecisionStream';
|
export { AIDecisionStream } from './AIDecisionStream';
|
||||||
export type { AIDecisionStreamProps } from './AIDecisionStream';
|
export type { AIDecisionStreamProps } from './AIDecisionStream';
|
||||||
export { ReasoningBody, parseReasoning } from './ReasoningBody';
|
export { ReasoningBody, parseReasoning } from './ReasoningBody';
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
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 { ArrowLeft, Bell, CalendarClock, FileText, 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 { canSeeScreen } from '../api/config';
|
import { canSeeScreen } from '../api/config';
|
||||||
@ -90,14 +90,6 @@ function Topbar({ title, subtitle, onMenu }: { title: string; subtitle: string;
|
|||||||
>
|
>
|
||||||
<Menu size={20} />
|
<Menu size={20} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => (window.history.length > 1 ? navigate(-1) : navigate('/pipeline'))}
|
|
||||||
aria-label="Go back"
|
|
||||||
title="Go back"
|
|
||||||
className="w-[38px] h-[38px] rounded-md border border-border-subtle bg-card cursor-pointer text-muted flex items-center justify-center shrink-0 hover:text-strong hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
<ArrowLeft size={18} />
|
|
||||||
</button>
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h1 className="m-0 text-lg font-bold text-strong truncate">{title}</h1>
|
<h1 className="m-0 text-lg font-bold text-strong truncate">{title}</h1>
|
||||||
{subtitle && <div className="text-xs text-faint mt-px truncate">{subtitle}</div>}
|
{subtitle && <div className="text-xs text-faint mt-px truncate">{subtitle}</div>}
|
||||||
|
|||||||
@ -1,85 +0,0 @@
|
|||||||
// Faithful port of the Recommend Product form's designer `custom_js`
|
|
||||||
// (activity_data_fields → recommended_product_input / sum_assured_input).
|
|
||||||
// The standard renderer runs that JS on field-change and writes
|
|
||||||
// `sa_valid_input` + the rate-card `premium_amount_input`. The bespoke
|
|
||||||
// RecommendBody must replicate it, or the Meeting Scheduled → Product
|
|
||||||
// Recommended stage gate (rule d44a9000: `sa_valid is_true`) never passes and
|
|
||||||
// the lead is silently stuck. Verified against workflow e29c3c33 / app 385.
|
|
||||||
|
|
||||||
import type { ProductLookup } from '../api/types';
|
|
||||||
|
|
||||||
export interface RecommendCalc {
|
|
||||||
/** Rate-card (or income-pct) premium — what the designer writes to premium_amount_input. */
|
|
||||||
premium: number;
|
|
||||||
/** Drives the stage gate. True only when age, sum-assured bounds and income multiple all pass. */
|
|
||||||
saValid: boolean;
|
|
||||||
age: number | null;
|
|
||||||
okAge: boolean;
|
|
||||||
okBounds: boolean;
|
|
||||||
okMult: boolean;
|
|
||||||
/** True once a plan + sum assured are present (so callers can show validity). */
|
|
||||||
evaluated: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const num = (v: unknown): number => Number(v) || 0;
|
|
||||||
|
|
||||||
function ageFromDob(dob?: string | null): number | null {
|
|
||||||
if (!dob) return null;
|
|
||||||
const b = new Date(dob);
|
|
||||||
if (Number.isNaN(b.getTime())) return null;
|
|
||||||
const n = new Date();
|
|
||||||
let age = n.getFullYear() - b.getFullYear();
|
|
||||||
if (n < new Date(n.getFullYear(), b.getMonth(), b.getDate())) age--;
|
|
||||||
return age;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function recommendCalc(
|
|
||||||
plan: ProductLookup | null | undefined,
|
|
||||||
dob: string | null | undefined,
|
|
||||||
sumAssured: number,
|
|
||||||
annualIncome: number | null | undefined,
|
|
||||||
): RecommendCalc {
|
|
||||||
const age = ageFromDob(dob);
|
|
||||||
const sa = num(sumAssured);
|
|
||||||
const inc = num(annualIncome);
|
|
||||||
const blank: RecommendCalc = { premium: 0, saValid: false, age, okAge: false, okBounds: false, okMult: false, evaluated: false };
|
|
||||||
if (!plan || typeof plan !== 'object' || age == null || !sa) return blank;
|
|
||||||
|
|
||||||
const p = plan as Record<string, unknown>;
|
|
||||||
|
|
||||||
let premium = 0;
|
|
||||||
if (p.premium_basis === 'rate_card') {
|
|
||||||
let bands: Record<string, number> = {};
|
|
||||||
try {
|
|
||||||
bands =
|
|
||||||
typeof p.term_rate_bands === 'string'
|
|
||||||
? (JSON.parse(p.term_rate_bands) as Record<string, number>)
|
|
||||||
: ((p.term_rate_bands as Record<string, number>) ?? {});
|
|
||||||
} catch {
|
|
||||||
bands = {};
|
|
||||||
}
|
|
||||||
const band = age <= 30 ? '18-30' : age <= 40 ? '31-40' : age <= 50 ? '41-50' : '51-65';
|
|
||||||
premium = Math.round((sa / 1000) * num(bands[band]));
|
|
||||||
} else {
|
|
||||||
premium = Math.round(inc * num(p.premium_income_pct));
|
|
||||||
}
|
|
||||||
|
|
||||||
const okAge =
|
|
||||||
(p.entry_age_min == null || age >= num(p.entry_age_min)) &&
|
|
||||||
(p.entry_age_max == null || age <= num(p.entry_age_max));
|
|
||||||
const okBounds = sa >= num(p.min_sum_assured) && (!p.max_sum_assured || sa <= num(p.max_sum_assured));
|
|
||||||
const okMult =
|
|
||||||
!inc || (sa >= inc * num(p.sa_income_multiple_min) && sa <= inc * num(p.sa_income_multiple_max));
|
|
||||||
|
|
||||||
return { premium, saValid: okAge && okBounds && okMult, age, okAge, okBounds, okMult, evaluated: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Human reasons a recommendation fails validation (for an inline hint). */
|
|
||||||
export function saValidIssues(c: RecommendCalc): string[] {
|
|
||||||
if (!c.evaluated || c.saValid) return [];
|
|
||||||
const out: string[] = [];
|
|
||||||
if (!c.okAge) out.push('applicant age outside the plan’s entry-age band');
|
|
||||||
if (!c.okBounds) out.push('sum assured outside the plan’s min/max');
|
|
||||||
if (!c.okMult) out.push('sum assured outside the allowed income multiple');
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
@ -1,24 +1,77 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { Ban, CheckCircle2, CircleDashed, Download } from 'lucide-react';
|
import { Ban, CheckCircle2, CircleDashed, Download } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
AIDecisionStream,
|
AIDecisionStream,
|
||||||
Avatar,
|
Avatar,
|
||||||
Badge,
|
Badge,
|
||||||
|
Button,
|
||||||
Card,
|
Card,
|
||||||
ChannelBadge,
|
ChannelBadge,
|
||||||
formatINR,
|
formatINR,
|
||||||
|
RupeeAmount,
|
||||||
SegmentBadge,
|
SegmentBadge,
|
||||||
TimelineEntry,
|
TimelineEntry,
|
||||||
WorkflowStepper,
|
WorkflowStepper,
|
||||||
} from '../components';
|
} from '../components';
|
||||||
import { LeadActions, STEP_BY_STATE } from '../components/activities';
|
import { ActivityForm } from '../components/form';
|
||||||
import { useQuery, useZino } from '../api/provider';
|
import { useQuery, useZino } from '../api/provider';
|
||||||
import { useLeads } from '../api/leads';
|
import { useLeads } from '../api/leads';
|
||||||
import { recordToLead, decisionToCard, auditToTimeline } from '../api/adapters';
|
import { recordToLead, decisionToCard, auditToTimeline } from '../api/adapters';
|
||||||
import { STAGES, ONBOARD_ACTIVITY_UID } from '../api/config';
|
import { STAGES, ONBOARD_ACTIVITY_UID } from '../api/config';
|
||||||
import { DISQUALIFY_STATES } from '../api/forms';
|
import { ACTIVITY_META, DISQUALIFY_STATES, STATE_NEXT } from '../api/forms';
|
||||||
|
import type { LeadRecord } from '../api/types';
|
||||||
|
|
||||||
|
/** Contextual next-step action for a lead, driven by its current state.
|
||||||
|
* Routed steps (Assign / Recommend / Documents) deep-link to their rich
|
||||||
|
* screens; everything else renders its activity form inline. Disqualify is a
|
||||||
|
* secondary action wherever it's valid. */
|
||||||
|
function NextActionPanel({ record, onAdvanced }: { record: LeadRecord; onAdvanced: () => void }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [showDisqualify, setShowDisqualify] = useState(false);
|
||||||
|
const state = record.current_state_name ?? '';
|
||||||
|
const next = STATE_NEXT[state];
|
||||||
|
const inlineMeta = next && !next.route && next.activity ? ACTIVITY_META[next.activity] : undefined;
|
||||||
|
const canDisqualify = DISQUALIFY_STATES.has(state);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-2xs font-bold uppercase tracking-[0.06em] text-faint">Next action</span>
|
||||||
|
{canDisqualify && !next?.route && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDisqualify((s) => !s)}
|
||||||
|
className="text-2xs font-semibold text-ruby-600 inline-flex items-center gap-1 bg-transparent border-none cursor-pointer"
|
||||||
|
>
|
||||||
|
<Ban size={12} /> {showDisqualify ? 'Cancel' : 'Disqualify'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showDisqualify ? (
|
||||||
|
<ActivityForm
|
||||||
|
meta={ACTIVITY_META.disqualify}
|
||||||
|
instanceId={record.instance_id}
|
||||||
|
onSuccess={onAdvanced}
|
||||||
|
/>
|
||||||
|
) : next?.route ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="primary" size="sm" onClick={() => navigate(`${next.route}?instance=${record.instance_id}`)}>
|
||||||
|
{next.label ?? 'Continue'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : inlineMeta ? (
|
||||||
|
<ActivityForm meta={inlineMeta} instanceId={record.instance_id} record={record} onSuccess={onAdvanced} />
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-faint">
|
||||||
|
{state === 'Disqualified' ? 'This lead was disqualified.' : 'Lifecycle complete — no further action.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Compact Indian currency for the small stat tiles, so large cover figures
|
// 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
|
// (₹2,00,00,000) never overflow + get clipped by the card. Crore/lakh short
|
||||||
// form above ₹1L; full grouping below.
|
// form above ₹1L; full grouping below.
|
||||||
@ -29,15 +82,13 @@ function compactINR(n: number): string {
|
|||||||
return `₹${formatINR(n)}`;
|
return `₹${formatINR(n)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A compact label/value tile for the Profile grid — fills the wide card width
|
function ProfileRow({ label, value, mono }: { label: string; value: ReactNode; mono?: boolean }) {
|
||||||
* far better than full-width key→value rows. */
|
|
||||||
function ProfileTile({ label, value, mono }: { label: string; value: ReactNode; mono?: boolean }) {
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0 rounded-md bg-slate-50 px-3.5 py-3">
|
<div className="flex items-center justify-between py-2.5 border-b border-border-subtle last:border-b-0">
|
||||||
<div className="text-2xs text-faint mb-1.5">{label}</div>
|
<span className="text-xs text-faint">{label}</span>
|
||||||
<div className={mono ? 'truncate text-sm font-semibold text-strong font-mono' : 'truncate text-sm font-semibold text-strong'}>
|
<span className={mono ? 'text-sm font-semibold text-strong font-mono' : 'text-sm font-semibold text-strong'}>
|
||||||
{value}
|
{value}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -108,8 +159,6 @@ export function Lead360Screen() {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const issued = lead.stage >= STAGES.indexOf('Policy Issued');
|
const issued = lead.stage >= STAGES.indexOf('Policy Issued');
|
||||||
const state = record.current_state_name ?? '';
|
|
||||||
const hasAction = !!STEP_BY_STATE[state] || DISQUALIFY_STATES.has(state);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-[1320px] flex-col gap-[18px]">
|
<div className="mx-auto flex w-full max-w-[1320px] flex-col gap-[18px]">
|
||||||
@ -130,12 +179,9 @@ export function Lead360Screen() {
|
|||||||
{meetingLabel ? ` · meeting ${meetingLabel}` : ''}
|
{meetingLabel ? ` · meeting ${meetingLabel}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<Badge tone={issued ? 'success' : 'warning'} dot>
|
||||||
<Badge tone={issued ? 'success' : 'warning'} dot>
|
{record.current_state_name ?? '—'}
|
||||||
{record.current_state_name ?? '—'}
|
</Badge>
|
||||||
</Badge>
|
|
||||||
{hasAction && <LeadActions record={record} onDone={onAdvanced} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* lifecycle stepper */}
|
{/* lifecycle stepper */}
|
||||||
@ -165,16 +211,16 @@ export function Lead360Screen() {
|
|||||||
<div className="grid items-start gap-[18px] grid-cols-1 lg:grid-cols-[1fr_360px]">
|
<div className="grid items-start gap-[18px] grid-cols-1 lg:grid-cols-[1fr_360px]">
|
||||||
{/* MAIN */}
|
{/* MAIN */}
|
||||||
<div className="flex flex-col gap-[18px] min-w-0">
|
<div className="flex flex-col gap-[18px] min-w-0">
|
||||||
<Card title="Profile">
|
<Card title="Next action">
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
<div className="flex items-center gap-3.5 mb-4">
|
||||||
<ProfileTile label="Owner" value={lead.owner} />
|
<Avatar name={lead.owner} ai={lead.ownerAi} size={40} />
|
||||||
<ProfileTile label="Phone" value={lead.phone} mono />
|
<div className="flex-1 min-w-0">
|
||||||
<ProfileTile label="Email" value={lead.email} />
|
<div className="text-md font-bold text-strong">{record.current_state_name ?? 'In progress'}</div>
|
||||||
<ProfileTile label="Date of birth" value={lead.age ? `${lead.dob} · ${lead.age} yrs` : lead.dob} />
|
<div className="text-xs text-muted mt-0.5">Owned by {lead.owner}</div>
|
||||||
<ProfileTile label="Occupation" value={lead.occupation} />
|
</div>
|
||||||
<ProfileTile label="Annual income" value={compactINR(lead.income)} />
|
</div>
|
||||||
<ProfileTile label="Preferred language" value={lead.language} />
|
<div className="pt-4 border-t border-border-subtle">
|
||||||
<ProfileTile label="Product interest" value={<Badge tone="accent">{lead.interest}</Badge>} />
|
<NextActionPanel record={record} onAdvanced={onAdvanced} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -233,23 +279,40 @@ 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 className="text-sm text-faint">No activity recorded yet.</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RAIL — live AI decision feed (manual refresh, no polling). */}
|
{/* RAIL — live AI decision feed (manual refresh, no polling). */}
|
||||||
|
|||||||
@ -2,7 +2,17 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Columns3, Table } from 'lucide-react';
|
import { Columns3, Table } from 'lucide-react';
|
||||||
import { cn } from '../lib/cn';
|
import { cn } from '../lib/cn';
|
||||||
import { Badge, Card, KpiCard, LeadRow, LEAD_GRID, Pagination } from '../components';
|
import {
|
||||||
|
Avatar,
|
||||||
|
Badge,
|
||||||
|
Card,
|
||||||
|
ChannelBadge,
|
||||||
|
KpiCard,
|
||||||
|
LeadRow,
|
||||||
|
LEAD_GRID,
|
||||||
|
Pagination,
|
||||||
|
SegmentBadge,
|
||||||
|
} 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';
|
||||||
@ -13,6 +23,12 @@ import { applyFilters, EMPTY_FILTER, type FilterState } from '../api/filters';
|
|||||||
|
|
||||||
const PAGE_SIZE = 10;
|
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.
|
// Funnel milestones (subset of STAGES) — count = leads that have *reached* it.
|
||||||
const FUNNEL_STEPS: Array<{ label: string; stage: string }> = [
|
const FUNNEL_STEPS: Array<{ label: string; stage: string }> = [
|
||||||
{ label: 'New Lead', stage: 'New Lead' },
|
{ label: 'New Lead', stage: 'New Lead' },
|
||||||
@ -117,30 +133,29 @@ 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 }) {
|
function KanbanCard({ lead, onClick }: { lead: Lead; onClick: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="bg-card border border-border-subtle rounded-md shadow-xs p-3 cursor-pointer flex flex-col gap-1.5 transition-all duration-150 hover:shadow-md hover:-translate-y-px"
|
className="bg-card border border-border-subtle rounded-md shadow-xs p-3 cursor-pointer flex flex-col gap-2.5 transition-all duration-150 hover:shadow-md hover:-translate-y-px"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="min-w-0">
|
<Avatar name={lead.name} size={30} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-semibold text-strong truncate">{lead.name}</div>
|
<div className="text-sm font-semibold text-strong truncate">{lead.name}</div>
|
||||||
<div className="text-2xs text-faint truncate">
|
<div className="text-2xs text-faint">{lead.city}</div>
|
||||||
{[lead.city, lead.channel].filter(Boolean).join(' · ')}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="font-numeric font-bold text-sm text-muted nums shrink-0">{lead.score}</span>
|
<span className="font-numeric font-extrabold text-lg leading-none nums" style={{ color: scoreColor(lead.score) }}>
|
||||||
|
{lead.score}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 text-2xs text-faint min-w-0">
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
<span
|
<SegmentBadge segment={lead.segment} size="sm" />
|
||||||
className="w-1.5 h-1.5 rounded-full shrink-0"
|
<ChannelBadge channel={lead.channel} size="sm" />
|
||||||
style={{ background: SEGMENT_COLORS[lead.segment] ?? 'var(--slate-400)' }}
|
</div>
|
||||||
title={lead.segment}
|
<div className="flex items-center gap-[7px] pt-2.5 border-t border-border-subtle">
|
||||||
/>
|
<Avatar name={lead.owner} ai={lead.ownerAi} size={20} />
|
||||||
<span className="truncate">{lead.lastAction}</span>
|
<span className="text-2xs text-muted truncate">{lead.lastAction}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -149,7 +164,7 @@ function KanbanCard({ lead, onClick }: { lead: Lead; onClick: () => void }) {
|
|||||||
function KanbanBoard({ leads, onOpenLead }: { leads: Lead[]; onOpenLead: (l: Lead) => void }) {
|
function KanbanBoard({ leads, onOpenLead }: { leads: Lead[]; onOpenLead: (l: Lead) => void }) {
|
||||||
const cols = STAGES.slice(0, 8);
|
const cols = STAGES.slice(0, 8);
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-3.5 overflow-x-auto pb-1.5 scrollbar-slim">
|
<div className="flex gap-3.5 overflow-x-auto pb-1.5">
|
||||||
{cols.map((stage, idx) => {
|
{cols.map((stage, idx) => {
|
||||||
const items = leads.filter((l) => l.stage === idx);
|
const items = leads.filter((l) => l.stage === idx);
|
||||||
return (
|
return (
|
||||||
@ -160,9 +175,7 @@ function KanbanBoard({ leads, onOpenLead }: { leads: Lead[]; onOpenLead: (l: Lea
|
|||||||
{items.length}
|
{items.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Card list caps at viewport height and scrolls per-column so a
|
<div className="flex flex-col gap-2.5 bg-slate-100 rounded-md p-2.5 min-h-[120px]">
|
||||||
busy stage doesn't blow up the whole board's height. */}
|
|
||||||
<div className="flex flex-col gap-2.5 bg-slate-100 rounded-md p-2.5 min-h-[120px] max-h-[70vh] overflow-y-auto scrollbar-slim">
|
|
||||||
{items.length ? (
|
{items.length ? (
|
||||||
items.map((l) => <KanbanCard key={l.id} lead={l} onClick={() => onOpenLead(l)} />)
|
items.map((l) => <KanbanCard key={l.id} lead={l} onClick={() => onOpenLead(l)} />)
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -137,7 +137,7 @@ function AgentRail({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-1.5 flex flex-col gap-0.5 scrollbar-slim">
|
<div className="flex-1 overflow-y-auto p-1.5 flex flex-col gap-0.5">
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="text-2xs text-faint text-center py-6">No agents match.</div>
|
<div className="text-2xs text-faint text-center py-6">No agents match.</div>
|
||||||
) : (
|
) : (
|
||||||
@ -260,10 +260,9 @@ export function ScheduleScreen() {
|
|||||||
tomorrowKey={tomorrowKey}
|
tomorrowKey={tomorrowKey}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Detail: selected agent's agenda — capped to the viewport so a long
|
{/* Detail: selected agent's agenda */}
|
||||||
agenda scrolls internally instead of growing the whole page. */}
|
<div className="flex flex-col gap-4 min-w-0">
|
||||||
<div className="flex flex-col gap-4 min-w-0 md:h-[calc(100vh-180px)]">
|
<div className="flex flex-wrap items-center gap-3 justify-between">
|
||||||
<div className="flex flex-wrap items-center gap-3 justify-between shrink-0">
|
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<Avatar name={selected ?? ''} size={40} />
|
<Avatar name={selected ?? ''} size={40} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@ -296,7 +295,7 @@ export function ScheduleScreen() {
|
|||||||
<div className="text-2xs text-faint">Try “All”, or pick another agent.</div>
|
<div className="text-2xs text-faint">Try “All”, or pick another agent.</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-5 md:flex-1 md:min-h-0 md:overflow-y-auto md:pr-1 scrollbar-slim">
|
<div className="flex flex-col gap-5">
|
||||||
{days.map(([k, ms]) => (
|
{days.map(([k, ms]) => (
|
||||||
<div key={k} className="flex flex-col gap-2.5">
|
<div key={k} className="flex flex-col gap-2.5">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@ -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)) : '—';
|
return vals.length ? String(Math.round(vals.reduce((a, b) => a + b, 0) / vals.length)) : '—';
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Qualify (New Lead) + Assign (Qualified). */
|
/** Qualify (New Lead) + Assign (Qualified). Entry point — hosts Add Lead. */
|
||||||
const QUALIFY_TILES: TileSpec[] = [
|
const QUALIFY_TILES: TileSpec[] = [
|
||||||
{ label: 'In queue', value: count },
|
{ label: 'In queue', value: count },
|
||||||
{ label: 'Avg lead score', value: avg('lead_score') },
|
{ label: 'Avg lead score', value: avg('lead_score') },
|
||||||
@ -26,6 +26,7 @@ export function QualificationScreen() {
|
|||||||
return (
|
return (
|
||||||
<Worklist
|
<Worklist
|
||||||
states={['New Lead', 'Qualified']}
|
states={['New Lead', 'Qualified']}
|
||||||
|
showAddLead
|
||||||
tiles={QUALIFY_TILES}
|
tiles={QUALIFY_TILES}
|
||||||
emptyHint="No new or qualified leads in the queue."
|
emptyHint="No new or qualified leads in the queue."
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -159,27 +159,4 @@
|
|||||||
border-color: var(--sunrise-500);
|
border-color: var(--sunrise-500);
|
||||||
box-shadow: var(--shadow-focus);
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user