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,
|
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 (
|
||||||
|
|||||||
@ -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 key→value 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>
|
||||||
|
<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 */}
|
||||||
@ -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,9 +233,6 @@ export function Lead360Screen() {
|
|||||||
</Card>
|
</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">
|
<Card title="Activity log">
|
||||||
{timeline.length ? (
|
{timeline.length ? (
|
||||||
timeline.map((t, i) => (
|
timeline.map((t, i) => (
|
||||||
@ -299,20 +250,6 @@ export function Lead360Screen() {
|
|||||||
<div className="text-sm text-faint">No activity recorded yet.</div>
|
<div className="text-sm text-faint">No activity recorded yet.</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</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). */}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user