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,
className,
}: AIDecisionStreamProps) {
// Single-open accordion; newest (index 0) open by default.
const [openIdx, setOpenIdx] = useState(0);
// Single-open accordion; all collapsed by default (-1 = none open).
const [openIdx, setOpenIdx] = useState(-1);
const escalations = decisions.filter((d) => d.confidence < threshold);
return (

View File

@ -1,77 +1,24 @@
import { useMemo, useState } from 'react';
import { useMemo } 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 {
AIDecisionStream,
Avatar,
Badge,
Button,
Card,
ChannelBadge,
formatINR,
RupeeAmount,
SegmentBadge,
TimelineEntry,
WorkflowStepper,
} from '../components';
import { ActivityForm } from '../components/form';
import { LeadActions, STEP_BY_STATE } from '../components/activities';
import { useQuery, useZino } from '../api/provider';
import { useLeads } from '../api/leads';
import { recordToLead, decisionToCard, auditToTimeline } from '../api/adapters';
import { STAGES, ONBOARD_ACTIVITY_UID } from '../api/config';
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>
);
}
import { DISQUALIFY_STATES } from '../api/forms';
// Compact Indian currency for the small stat tiles, so large cover figures
// (₹2,00,00,000) never overflow + get clipped by the card. Crore/lakh short
// form above ₹1L; full grouping below.
@ -82,13 +29,15 @@ function compactINR(n: number): string {
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 (
<div className="flex items-center justify-between py-2.5 border-b border-border-subtle last:border-b-0">
<span className="text-xs text-faint">{label}</span>
<span className={mono ? 'text-sm font-semibold text-strong font-mono' : 'text-sm font-semibold text-strong'}>
<div className="min-w-0 rounded-md bg-slate-50 px-3.5 py-3">
<div className="text-2xs text-faint mb-1.5">{label}</div>
<div className={mono ? 'truncate text-sm font-semibold text-strong font-mono' : 'truncate text-sm font-semibold text-strong'}>
{value}
</span>
</div>
</div>
);
}
@ -159,6 +108,8 @@ export function Lead360Screen() {
: null;
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 (
<div className="mx-auto flex w-full max-w-[1320px] flex-col gap-[18px]">
@ -179,9 +130,12 @@ export function Lead360Screen() {
{meetingLabel ? ` · meeting ${meetingLabel}` : ''}
</div>
</div>
<Badge tone={issued ? 'success' : 'warning'} dot>
{record.current_state_name ?? '—'}
</Badge>
<div className="flex flex-wrap items-center gap-3">
<Badge tone={issued ? 'success' : 'warning'} dot>
{record.current_state_name ?? '—'}
</Badge>
{hasAction && <LeadActions record={record} onDone={onAdvanced} />}
</div>
</div>
{/* lifecycle stepper */}
@ -211,16 +165,16 @@ export function Lead360Screen() {
<div className="grid items-start gap-[18px] grid-cols-1 lg:grid-cols-[1fr_360px]">
{/* MAIN */}
<div className="flex flex-col gap-[18px] min-w-0">
<Card title="Next action">
<div className="flex items-center gap-3.5 mb-4">
<Avatar name={lead.owner} ai={lead.ownerAi} size={40} />
<div className="flex-1 min-w-0">
<div className="text-md font-bold text-strong">{record.current_state_name ?? 'In progress'}</div>
<div className="text-xs text-muted mt-0.5">Owned by {lead.owner}</div>
</div>
</div>
<div className="pt-4 border-t border-border-subtle">
<NextActionPanel record={record} onAdvanced={onAdvanced} />
<Card title="Profile">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
<ProfileTile label="Owner" value={lead.owner} />
<ProfileTile label="Phone" value={lead.phone} mono />
<ProfileTile label="Email" value={lead.email} />
<ProfileTile label="Date of birth" value={lead.age ? `${lead.dob} · ${lead.age} yrs` : lead.dob} />
<ProfileTile label="Occupation" value={lead.occupation} />
<ProfileTile label="Annual income" value={compactINR(lead.income)} />
<ProfileTile label="Preferred language" value={lead.language} />
<ProfileTile label="Product interest" value={<Badge tone="accent">{lead.interest}</Badge>} />
</div>
</Card>
@ -279,40 +233,23 @@ export function Lead360Screen() {
</Card>
)}
{/* Activity log + profile reference sit side by side profile is
context, not a footer, so it reads next to the audit trail. */}
<div className="grid items-start gap-[18px] grid-cols-1 md:grid-cols-[1fr_300px]">
<Card title="Activity log">
{timeline.length ? (
timeline.map((t, i) => (
<TimelineEntry
key={i}
actor={t.actor}
ai={t.ai}
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>
<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>
<Card title="Activity log">
{timeline.length ? (
timeline.map((t, i) => (
<TimelineEntry
key={i}
actor={t.actor}
ai={t.ai}
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>
{/* RAIL — live AI decision feed (manual refresh, no polling). */}