refactor: Lead360 profile tiles + header LeadActions, collapse decision stream

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bhanu Prakash Sai Potteri 2026-06-26 12:42:56 +05:30
parent 850a12b648
commit 6d7b335147
2 changed files with 48 additions and 111 deletions

View File

@ -28,8 +28,8 @@ export function AIDecisionStream({
threshold = 0.7, threshold = 0.7,
className, className,
}: AIDecisionStreamProps) { }: AIDecisionStreamProps) {
// Single-open accordion; newest (index 0) open by default. // Single-open accordion; all collapsed by default (-1 = none open).
const [openIdx, setOpenIdx] = useState(0); const [openIdx, setOpenIdx] = useState(-1);
const escalations = decisions.filter((d) => d.confidence < threshold); const escalations = decisions.filter((d) => d.confidence < threshold);
return ( return (

View File

@ -1,77 +1,24 @@
import { useMemo, useState } from 'react'; import { useMemo } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { 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 { ActivityForm } from '../components/form'; import { LeadActions, STEP_BY_STATE } from '../components/activities';
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 { ACTIVITY_META, DISQUALIFY_STATES, STATE_NEXT } from '../api/forms'; import { DISQUALIFY_STATES } 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.
@ -82,13 +29,15 @@ function compactINR(n: number): string {
return `${formatINR(n)}`; return `${formatINR(n)}`;
} }
function ProfileRow({ label, value, mono }: { label: string; value: ReactNode; mono?: boolean }) { /** A compact label/value tile for the Profile grid fills the wide card width
* far better than full-width keyvalue rows. */
function ProfileTile({ label, value, mono }: { label: string; value: ReactNode; mono?: boolean }) {
return ( return (
<div className="flex items-center justify-between py-2.5 border-b border-border-subtle last:border-b-0"> <div className="min-w-0 rounded-md bg-slate-50 px-3.5 py-3">
<span className="text-xs text-faint">{label}</span> <div className="text-2xs text-faint mb-1.5">{label}</div>
<span className={mono ? 'text-sm font-semibold text-strong font-mono' : 'text-sm font-semibold text-strong'}> <div className={mono ? 'truncate text-sm font-semibold text-strong font-mono' : 'truncate text-sm font-semibold text-strong'}>
{value} {value}
</span> </div>
</div> </div>
); );
} }
@ -159,6 +108,8 @@ 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]">
@ -179,9 +130,12 @@ export function Lead360Screen() {
{meetingLabel ? ` · meeting ${meetingLabel}` : ''} {meetingLabel ? ` · meeting ${meetingLabel}` : ''}
</div> </div>
</div> </div>
<Badge tone={issued ? 'success' : 'warning'} dot> <div className="flex flex-wrap items-center gap-3">
{record.current_state_name ?? '—'} <Badge tone={issued ? 'success' : 'warning'} dot>
</Badge> {record.current_state_name ?? '—'}
</Badge>
{hasAction && <LeadActions record={record} onDone={onAdvanced} />}
</div>
</div> </div>
{/* lifecycle stepper */} {/* lifecycle stepper */}
@ -211,16 +165,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="Next action"> <Card title="Profile">
<div className="flex items-center gap-3.5 mb-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
<Avatar name={lead.owner} ai={lead.ownerAi} size={40} /> <ProfileTile label="Owner" value={lead.owner} />
<div className="flex-1 min-w-0"> <ProfileTile label="Phone" value={lead.phone} mono />
<div className="text-md font-bold text-strong">{record.current_state_name ?? 'In progress'}</div> <ProfileTile label="Email" value={lead.email} />
<div className="text-xs text-muted mt-0.5">Owned by {lead.owner}</div> <ProfileTile label="Date of birth" value={lead.age ? `${lead.dob} · ${lead.age} yrs` : lead.dob} />
</div> <ProfileTile label="Occupation" value={lead.occupation} />
</div> <ProfileTile label="Annual income" value={compactINR(lead.income)} />
<div className="pt-4 border-t border-border-subtle"> <ProfileTile label="Preferred language" value={lead.language} />
<NextActionPanel record={record} onAdvanced={onAdvanced} /> <ProfileTile label="Product interest" value={<Badge tone="accent">{lead.interest}</Badge>} />
</div> </div>
</Card> </Card>
@ -279,40 +233,23 @@ export function Lead360Screen() {
</Card> </Card>
)} )}
{/* Activity log + profile reference sit side by side profile is <Card title="Activity log">
context, not a footer, so it reads next to the audit trail. */} {timeline.length ? (
<div className="grid items-start gap-[18px] grid-cols-1 md:grid-cols-[1fr_300px]"> timeline.map((t, i) => (
<Card title="Activity log"> <TimelineEntry
{timeline.length ? ( key={i}
timeline.map((t, i) => ( actor={t.actor}
<TimelineEntry ai={t.ai}
key={i} action={t.action}
actor={t.actor} time={t.time}
ai={t.ai} tone={t.tone}
action={t.action} last={i === timeline.length - 1}
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). */}