refactor: Lead360 profile tiles + header LeadActions, collapse decision stream
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
850a12b648
commit
6d7b335147
@ -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 (
|
||||
|
||||
@ -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 key→value 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). */}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user