210 lines
8.3 KiB
TypeScript
210 lines
8.3 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
import { CheckCircle2, UserCheck } from 'lucide-react';
|
|
import { AIDecisionCard, Avatar, Badge, Button, Card, formatINR, Select } from '../components';
|
|
import { LookupField } from '../components/form';
|
|
import type { LookupValue } from '../components/form';
|
|
import { useQuery, useZino } from '../api/provider';
|
|
import { useLeads } from '../api/leads';
|
|
import { decisionToCard } from '../api/adapters';
|
|
import { fieldFromSchema } from '../api/schema';
|
|
import { ACTIVITY_META } from '../api/forms';
|
|
|
|
const DEF = ACTIVITY_META.assign;
|
|
|
|
export function AssignScreen() {
|
|
const { client } = useZino();
|
|
const navigate = useNavigate();
|
|
const [params] = useSearchParams();
|
|
const instanceParam = params.get('instance');
|
|
|
|
const { records, refetch } = useLeads();
|
|
|
|
// Lookup field config (templates / display+storage cols) comes from the live
|
|
// form schema, not hardcoded.
|
|
const schemaQ = useQuery(() => client.formSchema(DEF.uid), []);
|
|
const REGION = useMemo(() => fieldFromSchema(schemaQ.data, 'assigned_region_input'), [schemaQ.data]);
|
|
const OWNER = useMemo(() => fieldFromSchema(schemaQ.data, 'owner_agent_input'), [schemaQ.data]);
|
|
|
|
// Assign Lead is valid at the Qualified state.
|
|
const eligible = useMemo(() => records.filter((r) => r.current_state_name === 'Qualified'), [records]);
|
|
|
|
const [selectedId, setSelectedId] = useState('');
|
|
useEffect(() => {
|
|
if (instanceParam) setSelectedId(instanceParam);
|
|
else if (!selectedId && eligible[0]) setSelectedId(String(eligible[0].instance_id));
|
|
}, [instanceParam, eligible, selectedId]);
|
|
|
|
const record = useMemo(() => records.find((r) => String(r.instance_id) === selectedId), [records, selectedId]);
|
|
|
|
const [region, setRegion] = useState<LookupValue | null>(null);
|
|
const [agent, setAgent] = useState<LookupValue | null>(null);
|
|
useEffect(() => {
|
|
if (!record) return;
|
|
setRegion((record.assigned_region as LookupValue) ?? null);
|
|
setAgent((record.owner_agent as LookupValue) ?? null);
|
|
}, [record]);
|
|
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
|
|
|
async function submit() {
|
|
if (!record) return;
|
|
if (!region) {
|
|
setResult({ ok: false, msg: 'Assigned region is required.' });
|
|
return;
|
|
}
|
|
setSubmitting(true);
|
|
setResult(null);
|
|
try {
|
|
await client.performActivity(record.instance_id, DEF.uid, {
|
|
assigned_region_input: region,
|
|
...(agent ? { owner_agent_input: agent } : {}),
|
|
});
|
|
setResult({ ok: true, msg: 'Lead assigned — advanced to Assigned.' });
|
|
refetch();
|
|
} catch (e) {
|
|
setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') });
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="grid gap-[18px] max-w-[1140px] mx-auto items-start grid-cols-1 lg:grid-cols-[1fr_340px]">
|
|
<div className="flex flex-col gap-[18px] min-w-0">
|
|
{/* context strip + lead picker */}
|
|
<div className="flex items-center gap-3 bg-card border border-border-subtle rounded-lg px-[18px] py-3.5 shadow-sm">
|
|
{record ? (
|
|
<>
|
|
<Avatar name={record.lead_name ?? `Lead ${record.instance_id}`} size={40} />
|
|
<div className="flex-1">
|
|
<div className="text-base font-bold text-strong">
|
|
{record.lead_name ?? `Lead ${record.instance_id}`} · #{record.instance_id}
|
|
</div>
|
|
<div className="text-xs text-faint">
|
|
{record.city ?? record.state_region ?? '—'} · {record.product_interest ?? 'No stated interest'} · income ₹
|
|
{formatINR(record.annual_income ?? 0)}
|
|
</div>
|
|
</div>
|
|
{record.lead_segment && <Badge tone="accent">{record.lead_segment}</Badge>}
|
|
</>
|
|
) : (
|
|
<div className="flex-1 text-sm text-faint">Select a lead at the Qualified stage.</div>
|
|
)}
|
|
<Select
|
|
options={[
|
|
{ value: '', label: eligible.length ? 'Choose lead…' : 'No qualified leads' },
|
|
...records.map((r) => ({
|
|
value: String(r.instance_id),
|
|
label: `#${r.instance_id} ${r.lead_name ?? ''} · ${r.current_state_name ?? ''}`,
|
|
})),
|
|
]}
|
|
value={selectedId}
|
|
onChange={(e) => {
|
|
setSelectedId(e.target.value);
|
|
setResult(null);
|
|
}}
|
|
className="w-[260px]"
|
|
/>
|
|
</div>
|
|
|
|
{result && (
|
|
<div
|
|
className={
|
|
result.ok
|
|
? 'flex items-center gap-2 text-sm text-emerald-700 bg-emerald-100 border border-emerald-300 rounded-md px-4 py-3'
|
|
: 'text-sm text-amber-700 bg-escalated-soft border border-amber-300 rounded-md px-4 py-3'
|
|
}
|
|
>
|
|
{result.ok && <CheckCircle2 size={16} />}
|
|
{result.msg}
|
|
{result.ok && record && (
|
|
<button
|
|
className="ml-auto text-sm font-semibold text-link underline"
|
|
onClick={() => navigate(`/leads/${record.instance_id}`)}
|
|
>
|
|
View lead
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<Card title="Assign to agent">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{REGION && (
|
|
<LookupField
|
|
label={REGION.label}
|
|
required={REGION.required}
|
|
templateUid={REGION.lookupTemplate!}
|
|
displayCols={REGION.lookupDisplay ?? []}
|
|
storageCols={REGION.lookupStorage ?? []}
|
|
activityId={DEF.uid}
|
|
fieldId={REGION.id}
|
|
instanceId={record?.instance_id}
|
|
value={region}
|
|
onChange={setRegion}
|
|
/>
|
|
)}
|
|
{OWNER && (
|
|
<LookupField
|
|
label={OWNER.label}
|
|
templateUid={OWNER.lookupTemplate!}
|
|
displayCols={OWNER.lookupDisplay ?? []}
|
|
storageCols={OWNER.lookupStorage ?? []}
|
|
activityId={DEF.uid}
|
|
fieldId={OWNER.id}
|
|
instanceId={record?.instance_id}
|
|
value={agent}
|
|
onChange={setAgent}
|
|
/>
|
|
)}
|
|
{schemaQ.loading && <div className="col-span-full text-sm text-faint">Loading form…</div>}
|
|
</div>
|
|
<div className="flex gap-2.5 mt-5">
|
|
<Button variant="primary" disabled={!record || submitting} onClick={submit}>
|
|
{submitting ? 'Assigning…' : 'Assign lead'}
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* RIGHT — assignment summary + AI suggestion */}
|
|
<div className="flex flex-col gap-4">
|
|
<div className="bg-navy-grad rounded-lg px-5 py-[22px] shadow-md">
|
|
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy-muted mb-3.5">Assignment</div>
|
|
<div className="flex items-center gap-3">
|
|
<span className="w-10 h-10 rounded-md bg-[rgba(251,169,76,0.18)] text-sunrise-300 flex items-center justify-center">
|
|
<UserCheck size={20} />
|
|
</span>
|
|
<div className="min-w-0">
|
|
<div className="text-base font-bold text-on-navy truncate">
|
|
{agent ? String(agent.name ?? '—') : 'No agent yet'}
|
|
</div>
|
|
<div className="text-sm text-on-navy-muted truncate">
|
|
{agent?.region ? String(agent.region) : region?.region ? String(region.region) : 'Pick a region & agent'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<AssignAiSuggestion instanceId={record?.instance_id} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AssignAiSuggestion({ instanceId }: { instanceId?: number }) {
|
|
const { client } = useZino();
|
|
const q = useQuery(() => client.aiDecisions(instanceId!), [instanceId], instanceId != null);
|
|
const latest = (q.data ?? [])[0];
|
|
if (!latest) return null;
|
|
const card = decisionToCard(latest);
|
|
return (
|
|
<>
|
|
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-faint">AI suggestion</div>
|
|
<AIDecisionCard {...card} defaultExpanded />
|
|
</>
|
|
);
|
|
}
|