tech_mahindra/src/components/DetailViewPanel.tsx
Bhanu Prakash Sai Potteri d16cd8d86e inital commit
2026-05-29 19:57:38 +05:30

518 lines
23 KiB
TypeScript

import { useEffect, useState, useRef } from "react";
import { Hash, Calendar, Sparkles, TrendingUp, MessageSquare, ChevronDown, ChevronUp, Activity } from "lucide-react";
import { getDVScreen, getDetailView, getInstance, type InstanceResponse } from "../api/viewService";
import { WORKFLOW_ID, STATE_IDS, ACTIVITY_IDS } from "../config";
import { fmtAct } from "../lib/activity";
interface Field { field_key: string; output_label: string; data_type: string }
interface Props { viewId: number | string; instanceId: string }
type GroupKey = "hero" | "customer" | "vehicle" | "booking" | "feedback" | "ai" | "general";
// ---------------------------------------------------------------------------
// Classification — Mahindra test-drive workflow
// ---------------------------------------------------------------------------
function classify(key: string): GroupKey {
const k = key.toLowerCase();
if (k === "customer_name") return "hero";
if (k.startsWith("customer_") || k === "lead_source" || k === "phone" || k === "email") return "customer";
if (k === "model_interest" || k.startsWith("vehicle_") || k === "variant" || k === "color" || k === "fuel_type") return "vehicle";
if (k.startsWith("booking_") || k === "dealer_name" || k === "dealer_id" || k === "slot_start" || k === "slot_end") return "booking";
if (k.startsWith("feedback_") || k === "test_drive_outcome" || k === "would_recommend" || k === "rating") return "feedback";
if (k.startsWith("ai_") || k.startsWith("call_") || k.includes("summary") || k.includes("failure_reason") ||
k.includes("confidence") || k.includes("reasoning") || k.includes("escalation")) return "ai";
return "general";
}
// ---------------------------------------------------------------------------
// Status
// ---------------------------------------------------------------------------
const STATUS: Record<string, { pill: string; dot: string; bar: string; pulse?: boolean }> = {
"Awaiting Scheduler Call": {
pill: "text-rose-700 bg-rose-50 ring-rose-200 dark:text-rose-300 dark:bg-rose-950/30 dark:ring-rose-900",
dot: "bg-rose-500", bar: "bg-rose-500", pulse: true,
},
"Scheduled": {
pill: "text-amber-700 bg-amber-50 ring-amber-200 dark:text-amber-300 dark:bg-amber-950/30 dark:ring-amber-900",
dot: "bg-amber-500", bar: "bg-amber-500",
},
"Awaiting Feedback Call": {
pill: "text-rose-700 bg-rose-50 ring-rose-200 dark:text-rose-300 dark:bg-rose-950/30 dark:ring-rose-900",
dot: "bg-rose-500", bar: "bg-rose-500", pulse: true,
},
"Feedback Collected": {
pill: "text-emerald-700 bg-emerald-50 ring-emerald-200 dark:text-emerald-300 dark:bg-emerald-950/30 dark:ring-emerald-900",
dot: "bg-emerald-500", bar: "bg-emerald-500",
},
"Scheduling Call Failed": {
pill: "text-red-700 bg-red-50 ring-red-200 dark:text-red-300 dark:bg-red-950/30 dark:ring-red-900",
dot: "bg-red-500", bar: "bg-red-500",
},
"Feedback Call Failed": {
pill: "text-red-700 bg-red-50 ring-red-200 dark:text-red-300 dark:bg-red-950/30 dark:ring-red-900",
dot: "bg-red-500", bar: "bg-red-500",
},
"End State": {
pill: "text-stone-600 bg-stone-100 ring-stone-200 dark:text-zinc-400 dark:bg-zinc-800 dark:ring-zinc-700",
dot: "bg-stone-400", bar: "bg-stone-400",
},
};
const DS = { pill: "text-stone-600 bg-stone-100 ring-stone-200 dark:text-zinc-400 dark:bg-zinc-800 dark:ring-zinc-700", dot: "bg-stone-400", bar: "bg-stone-300" };
const SKIP_KEYS = new Set(["analysis_so_far"]);
// ---------------------------------------------------------------------------
// Skeleton
// ---------------------------------------------------------------------------
function DetailSkeleton() {
return (
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-stone-200/80 dark:border-zinc-700/60 overflow-hidden">
<div className="h-[3px] bg-stone-100 dark:bg-zinc-800" />
<div className="px-7 pt-5 pb-4 border-b border-stone-100 dark:border-zinc-800 space-y-3">
<div className="h-6 w-48 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse" />
<div className="h-4 w-32 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse" />
</div>
<div className="px-7 py-5 grid grid-cols-2 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-2">
<div className="h-3 w-20 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse" />
<div className="h-4 w-32 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse" />
</div>
))}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
export default function DetailViewPanel({ viewId, instanceId }: Props) {
const [data, setData] = useState<Record<string, unknown> | null>(null);
const [fields, setFields] = useState<Field[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!instanceId) return;
setLoading(true); setError(null);
getDVScreen(viewId)
.then(dv => getDetailView(dv.dv_template_uid, instanceId))
.catch(() => getDetailView(viewId, instanceId))
.then(res => {
setFields(res.config?.fields?.filter((f: any) => f.field_key !== "") ?? []);
setData(res.data ?? {});
})
.catch((e: any) => setError(e.message))
.finally(() => setLoading(false));
}, [viewId, instanceId]);
if (loading) return <DetailSkeleton />;
if (error) return <p className="text-sm text-red-500 dark:text-red-400">{error}</p>;
if (!data || !fields.length) return null;
const sys = new Set(["instance_id", "current_state_name", "created_at", "updated_at"]);
const groups: Record<GroupKey, Field[]> = { hero: [], customer: [], vehicle: [], booking: [], feedback: [], ai: [], general: [] };
for (const f of fields) {
if (sys.has(f.field_key) || SKIP_KEYS.has(f.field_key)) continue;
const g = classify(f.field_key);
groups[g].push(f);
}
const heroField = groups.hero[0];
const heroValue = heroField ? String(data[heroField.field_key] ?? "") : "";
const stateName = String(data.current_state_name ?? "");
const sc = STATUS[stateName] ?? DS;
const phone = fmtPhone(data.customer_phone);
const model = String(data.model_interest ?? "");
const leftGroups = [
{ label: "Customer", fields: groups.customer },
{ label: "Vehicle", fields: groups.vehicle },
{ label: "Other", fields: groups.general },
].filter(g => g.fields.length > 0);
const rightGroups = [
{ label: "Booking", fields: groups.booking },
{ label: "Feedback", fields: groups.feedback },
].filter(g => g.fields.length > 0);
const isScore = (f: Field) => f.field_key.toLowerCase().includes("confidence") || f.field_key.toLowerCase().includes("rating") || f.field_key.toLowerCase().includes("score");
const isLongText = (f: Field) => { const v = data[f.field_key]; return typeof v === "string" && v.length > 55; };
const aiShort = groups.ai.filter(f => isScore(f) || !isLongText(f));
const aiLong = groups.ai.filter(f => !isScore(f) && isLongText(f));
return (
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-stone-200/80 dark:border-zinc-700/60 overflow-hidden">
{/* Status bar */}
<div className={`h-[3px] ${sc.bar}`} />
{/* ── Header ──────────────────────────────────────────────────── */}
<div className="px-7 pt-5 pb-4 border-b border-stone-100 dark:border-zinc-800">
<div className="flex items-start justify-between gap-6 mb-3">
<div className="min-w-0 flex items-center gap-3 flex-wrap">
{heroValue && (
<h1 className="text-[20px] font-bold tracking-tight text-stone-900 dark:text-white leading-none">
{heroValue}
</h1>
)}
{stateName && (
<span className={`inline-flex items-center gap-1.5 text-[11px] font-semibold px-2.5 py-1 rounded-full ring-1 shrink-0 ${sc.pill}`}>
<span className={`w-1.5 h-1.5 rounded-full ${sc.dot} ${sc.pulse ? "animate-pulse" : ""}`} />
{stateName}
</span>
)}
</div>
{model && (
<div className="text-right shrink-0">
<p className="text-[10px] font-medium text-stone-400 dark:text-zinc-600 mb-0.5 uppercase tracking-wider">Model Interest</p>
<p className="text-[18px] font-bold text-stone-900 dark:text-white leading-none">{model}</p>
</div>
)}
</div>
<div className="flex items-center gap-3 flex-wrap text-[12px]">
{phone && (
<span className="font-medium text-stone-600 dark:text-zinc-300">{phone}</span>
)}
{phone && (data.created_at || data.instance_id) && (
<span className="text-stone-200 dark:text-zinc-700">·</span>
)}
{data.created_at && (
<span className="flex items-center gap-1 text-stone-400 dark:text-zinc-500">
<Calendar size={11} />{fmtDate(String(data.created_at))}
</span>
)}
{data.instance_id && (
<span className="flex items-center gap-1 text-stone-400 dark:text-zinc-500 font-mono text-[11px]">
<Hash size={10} />Lead #{String(data.instance_id)}
</span>
)}
</div>
</div>
{/* ── Two-column metadata ──────────────────────────────────────── */}
{(leftGroups.length > 0 || rightGroups.length > 0) && (
<div className="grid lg:grid-cols-2 divide-y lg:divide-y-0 lg:divide-x divide-stone-100 dark:divide-zinc-800 border-b border-stone-100 dark:border-zinc-800">
<div className="px-7 py-5 space-y-6">
{leftGroups.map(({ label, fields: fs }) => (
<section key={label}>
<p className="text-[10px] font-bold uppercase tracking-widest text-stone-400 dark:text-zinc-600 mb-3">{label}</p>
<dl className="grid grid-cols-2 gap-x-8 gap-y-4">
{fs.map(f => (
<div key={f.field_key}>
<dt className="text-[11px] text-stone-400 dark:text-zinc-500 mb-0.5">{fmtLabel(f.output_label)}</dt>
<dd className="text-[14px] font-medium text-stone-900 dark:text-zinc-100 break-words">
{fmtVal(data[f.field_key], f.data_type, f.field_key)}
</dd>
</div>
))}
</dl>
</section>
))}
</div>
<div className="px-7 py-5 space-y-6">
{rightGroups.length > 0 ? rightGroups.map(({ label, fields: fs }) => (
<section key={label}>
<p className="text-[10px] font-bold uppercase tracking-widest text-stone-400 dark:text-zinc-600 mb-3 flex items-center gap-1.5">
{label === "Booking" && <TrendingUp size={11} className="text-amber-500" />}
{label === "Feedback" && <MessageSquare size={11} className="text-emerald-500" />}
{label}
</p>
<dl className="grid grid-cols-2 gap-x-8 gap-y-4">
{fs.map(f => (
<div key={f.field_key}>
<dt className="text-[11px] text-stone-400 dark:text-zinc-500 mb-0.5">{fmtLabel(f.output_label)}</dt>
<dd className="text-[14px] font-medium text-stone-900 dark:text-zinc-100 break-words">
{fmtVal(data[f.field_key], f.data_type, f.field_key)}
</dd>
</div>
))}
</dl>
</section>
)) : (
<div className="text-[13px] text-stone-300 dark:text-zinc-700 italic">
Booking and feedback details will appear here once the AI completes its call.
</div>
)}
</div>
</div>
)}
{/* ── AI Analysis ─────────────────────────────────────────────── */}
{groups.ai.length > 0 && (
<div className="px-7 py-5">
<p className="text-[10px] font-bold uppercase tracking-widest text-stone-400 dark:text-zinc-600 mb-4 flex items-center gap-1.5">
<Sparkles size={10} className="text-[#C8102E]" />AI · Call Outcome
</p>
{aiShort.length > 0 && (
<dl className="grid grid-cols-2 sm:grid-cols-3 gap-x-8 gap-y-5 mb-5">
{aiShort.map(f => {
const val = data[f.field_key];
const score = isScore(f);
const num = score ? Number(val) : null;
const pct = num !== null ? Math.min(num > 1 ? num : num * 100, 100) : 0;
const barCls = pct >= 70 ? "bg-emerald-500" : pct >= 40 ? "bg-amber-500" : "bg-red-500";
const pctCls = pct >= 70 ? "text-emerald-600 dark:text-emerald-400" : pct >= 40 ? "text-amber-600 dark:text-amber-400" : "text-red-500";
return (
<div key={f.field_key}>
<dt className="text-[11px] text-stone-400 dark:text-zinc-500 mb-1">{fmtLabel(f.output_label)}</dt>
{score && num !== null ? (
<dd className="flex items-center gap-2.5">
<div className="flex-1 h-1.5 bg-stone-100 dark:bg-zinc-800 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${barCls}`} style={{ width: `${pct}%` }} />
</div>
<span className={`text-[12px] font-bold tabular-nums w-8 text-right ${pctCls}`}>{pct.toFixed(0)}%</span>
</dd>
) : (
<dd className="text-[14px] font-semibold text-stone-900 dark:text-zinc-100">
{fmtVal(val, f.data_type, f.field_key)}
</dd>
)}
</div>
);
})}
</dl>
)}
{aiLong.length > 0 && (
<div className="space-y-4 border-t border-stone-100 dark:border-zinc-800 pt-4">
{aiLong.map(f => {
const val = data[f.field_key];
const isClarif = f.field_key.includes("clarification") || f.field_key.includes("response");
return (
<div key={f.field_key} className="border-b border-stone-50 dark:border-zinc-800/50 pb-4 last:border-0 last:pb-0">
<div className="flex items-center gap-1.5 mb-1.5">
{isClarif && <MessageSquare size={11} className="text-amber-400 shrink-0" />}
<span className="text-[11px] font-medium text-stone-400 dark:text-zinc-500">{fmtLabel(f.output_label)}</span>
</div>
<ClampedText text={String(val ?? "—")} />
</div>
);
})}
</div>
)}
</div>
)}
{/* ── Activity timeline ──────────────────────────────────────── */}
{data.instance_id && <TimelineSection instanceId={String(data.instance_id)} />}
</div>
);
}
// ---------------------------------------------------------------------------
// TimelineSection — derived from instance.data._activities (no audit endpoint
// needed). INIT is synthesised from instance.created_at since the platform
// doesn't write INIT into _activities.
// ---------------------------------------------------------------------------
interface ActivityRecord {
_system?: { created_at?: string; user_id?: string; user_roles?: string[] | null };
}
interface TimelineEntry {
activityId: string;
createdAt: string;
userRoles: string[] | null;
synthetic: boolean;
}
const FAILURE_STATE_IDS = new Set<string>([
STATE_IDS.SCHEDULING_CALL_FAILED,
STATE_IDS.FEEDBACK_CALL_FAILED,
]);
function TimelineSection({ instanceId }: { instanceId: string }) {
const [inst, setInst] = useState<InstanceResponse | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!instanceId) return;
let cancelled = false;
setInst(null); setError(null);
getInstance(WORKFLOW_ID, instanceId)
.then((r) => !cancelled && setInst(r))
.catch((e: any) => !cancelled && setError(e?.message ?? "failed to load"));
return () => { cancelled = true; };
}, [instanceId]);
if (error) return null;
if (inst === null) {
return (
<div className="px-7 py-5 border-t border-stone-100 dark:border-zinc-800 text-[12px] text-stone-300 dark:text-zinc-700">
Loading activity timeline
</div>
);
}
const activities = (inst.data as { _activities?: Record<string, ActivityRecord> } | null)?._activities;
const entries: TimelineEntry[] = [];
// Synthesise INIT from instance.created_at — _activities never includes it.
entries.push({
activityId: ACTIVITY_IDS.INIT_ACTIVITY,
createdAt: inst.created_at,
userRoles: null,
synthetic: true,
});
if (activities) {
for (const [activityId, record] of Object.entries(activities)) {
entries.push({
activityId,
createdAt: record._system?.created_at ?? inst.created_at,
userRoles: record._system?.user_roles ?? null,
synthetic: false,
});
}
}
entries.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
const lastIdx = entries.length - 1;
const inFailureState = FAILURE_STATE_IDS.has(inst.current_state_id);
return (
<div className="px-7 py-5 border-t border-stone-100 dark:border-zinc-800">
<p className="text-[10px] font-bold uppercase tracking-widest text-stone-400 dark:text-zinc-600 mb-4 flex items-center gap-1.5">
<Activity size={10} className="text-[#C8102E]" /> Activity Timeline
</p>
<ol className="relative pl-6">
<span aria-hidden className="absolute left-[7px] top-1 bottom-1 w-px bg-stone-200 dark:bg-zinc-700" />
{entries.map((e, i) => {
const isLast = i === lastIdx;
const isFirst = i === 0;
const failed = isLast && inFailureState;
const dotBg = failed ? "#dc2626" : isLast ? "#C8102E" : isFirst ? "#10b981" : "#d1d5db";
const actor = (e.userRoles && e.userRoles.length > 0) ? e.userRoles.join(", ") : "system";
return (
<li key={`${e.activityId}-${e.createdAt}`} className="relative pb-4 last:pb-0">
<span
aria-hidden
className={`absolute -left-[22px] top-1 w-3.5 h-3.5 rounded-full border-2 border-white dark:border-zinc-900 shadow-sm ${isLast ? "ring-2 ring-rose-200 dark:ring-rose-900" : ""}`}
style={{ background: dotBg, boxShadow: isLast ? "0 0 8px rgba(200,16,46,0.5)" : undefined }}
/>
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-[13px] font-medium ${
failed
? "text-red-600 dark:text-red-400"
: isLast
? "text-[#9D0D24] dark:text-rose-300"
: "text-stone-700 dark:text-zinc-200"
}`}>
{fmtAct(e.activityId)}
</span>
{failed && (
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-300">
Failed
</span>
)}
{isLast && !failed && (
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full"
style={{ background: "rgba(200,16,46,0.10)", color: "#C8102E" }}>
Latest
</span>
)}
</div>
<div className="text-[11px] text-stone-400 dark:text-zinc-500 mt-0.5">
{new Date(e.createdAt).toLocaleString("en-IN", {
day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit",
})}
<span className="mx-1.5 text-stone-200 dark:text-zinc-700">·</span>
<span className="text-stone-500 dark:text-zinc-400">{actor}</span>
</div>
</li>
);
})}
</ol>
</div>
);
}
// ---------------------------------------------------------------------------
// ClampedText
// ---------------------------------------------------------------------------
function ClampedText({ text }: { text: string }) {
const [expanded, setExpanded] = useState(false);
const [clamped, setClamped] = useState(false);
const ref = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const el = ref.current;
if (el) setClamped(el.scrollHeight > el.clientHeight + 2);
}, [text]);
return (
<div>
<p
ref={ref}
className={`text-[13px] leading-relaxed text-stone-700 dark:text-zinc-200 transition-all ${expanded ? "" : "line-clamp-3"}`}
>
{text}
</p>
{(clamped || expanded) && (
<button
onClick={() => setExpanded(e => !e)}
className="mt-1.5 flex items-center gap-1 text-[11px] font-semibold text-rose-600 dark:text-rose-400 hover:text-rose-700 dark:hover:text-rose-300 transition-colors"
>
{expanded ? <><ChevronUp size={12} /> Show less</> : <><ChevronDown size={12} /> Show more</>}
</button>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function fmtLabel(label: string): string {
return label.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
}
function fmtVal(val: unknown, type: string, key: string): string {
if (val == null || val === "") return "—";
if (type === "phone" && typeof val === "object") {
const p = val as { phone_with_dial_code?: string; phone?: string; dial_code?: string };
return p.phone_with_dial_code || (p.dial_code && p.phone ? `${p.dial_code}${p.phone}` : p.phone || "—");
}
if (type === "number") {
const n = Number(val);
if (["amount","tax","price","cost"].some(x => key.toLowerCase().includes(x))) return fmtCurrency(n);
return n.toLocaleString("en-IN");
}
if (type === "datetime" || type === "date") return fmtDate(String(val));
return String(val);
}
function fmtCurrency(n: number): string {
return n.toLocaleString("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 0 });
}
function fmtDate(val: string): string {
try { return new Date(val).toLocaleDateString("en-IN", { day: "2-digit", month: "short", year: "numeric" }); }
catch { return val; }
}
function fmtPhone(val: unknown): string {
if (val == null || val === "") return "";
if (typeof val === "object") {
const p = val as { phone_with_dial_code?: string; phone?: string; dial_code?: string };
return p.phone_with_dial_code || (p.dial_code && p.phone ? `${p.dial_code}${p.phone}` : p.phone || "");
}
return String(val);
}