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 = { "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 (
{Array.from({ length: 6 }).map((_, i) => (
))}
); } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- export default function DetailViewPanel({ viewId, instanceId }: Props) { const [data, setData] = useState | null>(null); const [fields, setFields] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(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 ; if (error) return

{error}

; if (!data || !fields.length) return null; const sys = new Set(["instance_id", "current_state_name", "created_at", "updated_at"]); const groups: Record = { 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 (
{/* Status bar */}
{/* ── Header ──────────────────────────────────────────────────── */}
{heroValue && (

{heroValue}

)} {stateName && ( {stateName} )}
{model && (

Model Interest

{model}

)}
{phone && ( {phone} )} {phone && (data.created_at || data.instance_id) && ( · )} {data.created_at && ( {fmtDate(String(data.created_at))} )} {data.instance_id && ( Lead #{String(data.instance_id)} )}
{/* ── Two-column metadata ──────────────────────────────────────── */} {(leftGroups.length > 0 || rightGroups.length > 0) && (
{leftGroups.map(({ label, fields: fs }) => (

{label}

{fs.map(f => (
{fmtLabel(f.output_label)}
{fmtVal(data[f.field_key], f.data_type, f.field_key)}
))}
))}
{rightGroups.length > 0 ? rightGroups.map(({ label, fields: fs }) => (

{label === "Booking" && } {label === "Feedback" && } {label}

{fs.map(f => (
{fmtLabel(f.output_label)}
{fmtVal(data[f.field_key], f.data_type, f.field_key)}
))}
)) : (
Booking and feedback details will appear here once the AI completes its call.
)}
)} {/* ── AI Analysis ─────────────────────────────────────────────── */} {groups.ai.length > 0 && (

AI · Call Outcome

{aiShort.length > 0 && (
{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 (
{fmtLabel(f.output_label)}
{score && num !== null ? (
{pct.toFixed(0)}%
) : (
{fmtVal(val, f.data_type, f.field_key)}
)}
); })}
)} {aiLong.length > 0 && (
{aiLong.map(f => { const val = data[f.field_key]; const isClarif = f.field_key.includes("clarification") || f.field_key.includes("response"); return (
{isClarif && } {fmtLabel(f.output_label)}
); })}
)}
)} {/* ── Activity timeline ──────────────────────────────────────── */} {data.instance_id && }
); } // --------------------------------------------------------------------------- // 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([ STATE_IDS.SCHEDULING_CALL_FAILED, STATE_IDS.FEEDBACK_CALL_FAILED, ]); function TimelineSection({ instanceId }: { instanceId: string }) { const [inst, setInst] = useState(null); const [error, setError] = useState(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 (
Loading activity timeline…
); } const activities = (inst.data as { _activities?: Record } | 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 (

Activity Timeline

    {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 (
  1. {fmtAct(e.activityId)} {failed && ( Failed )} {isLast && !failed && ( Latest )}
    {new Date(e.createdAt).toLocaleString("en-IN", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit", })} · {actor}
  2. ); })}
); } // --------------------------------------------------------------------------- // ClampedText // --------------------------------------------------------------------------- function ClampedText({ text }: { text: string }) { const [expanded, setExpanded] = useState(false); const [clamped, setClamped] = useState(false); const ref = useRef(null); useEffect(() => { const el = ref.current; if (el) setClamped(el.scrollHeight > el.clientHeight + 2); }, [text]); return (

{text}

{(clamped || expanded) && ( )}
); } // --------------------------------------------------------------------------- // 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); }