tech_mahindra/src/components/variants/VariantB.tsx
2026-05-29 16:15:42 +05:30

1305 lines
63 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// PROTOTYPE — Variant B: "Insights Studio"
// Visual language: Tech Mahindra brand — red #e31837 (their identity color)
// with navy #061f5c as structural supporting. Poppins typography on a cream
// #f6f2ea canvas. Left sidebar, hero KPI cards, big charts as centerpiece,
// table below as support. DV: full-page replace with hero card + collapsible
// section groups.
import React, { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
ChevronLeft, ChevronRight, Search, ArrowUp, ArrowDown, X, RefreshCw,
CheckCircle2, LogOut, Sparkles, Car, ClipboardList, PhoneOutgoing,
CalendarCheck2, MessageSquareHeart, Trophy, ArrowLeft, ChevronDown, ChevronUp,
Plus, PlayCircle, ExternalLink, Inbox, Star, ThumbsUp, ThumbsDown, User, Hash,
} from "lucide-react";
import {
BarChart as RBarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid,
ResponsiveContainer, Cell, PieChart as RPieChart, Pie, Legend,
} from "recharts";
import { useAuthContext } from "../../hooks/AuthContext";
import {
useHeaderConfig, useActiveScreenRecordView, useRecordView,
useDetailView, useInstanceMeta,
evalShowConditions, fmtCellText, fmtLabel,
type ActionButton, type AnalyticsSpec, type ScreenButton,
} from "../../hooks/usePrototypeData";
import FormModal from "../FormModal";
import { SCREEN_TO_DV, DETAIL_VIEW_IDS, ACTIVITY_IDS, STATE_IDS, DEMO_SLOTS_SCREEN_UUID } from "../../config";
import { CalendarView } from "./Calendar";
// ---------------------------------------------------------------------------
// Palette — Tech Mahindra brand. Red is the identity color (the "Tech" in
// their logo, their CTAs). Navy is structural/supporting. Wine deepens the
// red. Cream is the canvas.
// ---------------------------------------------------------------------------
const ACCENT = "#e31837"; // TM red — primary identity
const ACCENT_DARK = "#5f0229"; // TM wine — gradient end / depth
const ACCENT_SOFT = "#fde2e8"; // TM red soft tint
const TM_NAVY = "#061f5c"; // TM navy — structural supporting
const SURFACE = "#ffffff"; // sidebar: pure white, separated by TM border
const SURFACE_ALT = "#fafafa"; // hover/subtle wash on white surfaces
const TM_BORDER = "#e4e4ed"; // TM neutral border (from techmahindra.com CSS)
const INK = "#17181a"; // TM body text
// Chart palette — TM red leads, navy + slate support, wine + bright red round it out.
const PALETTE = ["#e31837", "#061f5c", "#5f0229", "#3b4f61", "#bc2130", "#5b7a95"];
// TM signature visual treatments — pulled from techmahindra.com CSS.
// They use sharp 0px corners, hard offset shadows (4px 4px 0 #color, no blur),
// and red eyebrow labels with -0.02em tracking.
// Flat card chrome — 1px TM border instead of the brutalist hard offset shadow.
// In a dense dashboard the offset shadows competed with the data; a hairline
// border gives definition without the visual noise.
const CARD_BORDER = `1px solid ${TM_BORDER}`;
const EYEBROW_CLS = "text-[14px] font-semibold tracking-[-0.02em] leading-[110%]";
const EYEBROW_STYLE = { color: ACCENT }; // TM red eyebrow
const STATE_TONE: Record<string, { bg: string; fg: string; dot: string }> = {
"Awaiting Scheduler Call": { bg: "#FEF3C7", fg: "#92400E", dot: "#e6ab2e" },
"Scheduled": { bg: "#FEF3C7", fg: "#92400E", dot: "#e6ab2e" },
"Awaiting Feedback Call": { bg: "#FEF3C7", fg: "#92400E", dot: "#e6ab2e" },
"Expert TD Scheduled": { bg: "#FEF3C7", fg: "#92400E", dot: "#e6ab2e" },
"Feedback Collected": { bg: "#DCFCE7", fg: "#166534", dot: "#27a846" },
"Scheduling Call Failed": { bg: ACCENT_SOFT, fg: ACCENT_DARK, dot: ACCENT },
"Feedback Call Failed": { bg: ACCENT_SOFT, fg: ACCENT_DARK, dot: ACCENT },
"End State": { bg: "#F5F5F4", fg: "#57534E", dot: "#81818c" },
};
function tone(name: string) { return STATE_TONE[name] || { bg: "#F5F5F4", fg: "#57534E", dot: "#A8A29E" }; }
function iconFor(label: string) {
const k = label.toLowerCase();
if (k.includes("lead")) return <ClipboardList size={15} />;
if (k.includes("active") || k.includes("scheduling")) return <PhoneOutgoing size={15} />;
if (k.includes("scheduled")) return <CalendarCheck2 size={15} />;
if (k.includes("feedback")) return <MessageSquareHeart size={15} />;
if (k.includes("completed") || k.includes("done")) return <Trophy size={15} />;
return <Car size={15} />;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
export default function VariantB() {
const navigate = useNavigate();
const params = useParams();
const activeScreenId = params.screenId || null;
const detailInstanceId = params.instanceId || null;
const { navItems } = useHeaderConfig();
const [refreshKey, setRefreshKey] = useState(0);
const [formModal, setFormModal] = useState<{ activityId: string; instanceId?: string; title: string } | null>(null);
useEffect(() => {
if (!activeScreenId && !detailInstanceId && navItems.length > 0) {
navigate(`/screen/${navItems[0].screen_uuid}?variant=B`, { replace: true });
}
}, [activeScreenId, detailInstanceId, navItems, navigate]);
const goTo = (uuid: string) => navigate(`/screen/${uuid}?variant=B`);
const openDetail = (id: string) => navigate(`/screen/${activeScreenId}/detail/${id}?variant=B`);
const back = () => navigate(`/screen/${activeScreenId}?variant=B`);
const activeNav = navItems.find((n) => n.screen_uuid === activeScreenId);
return (
<div className="min-h-screen flex bg-white">
{/* Sidebar — cream, gives the TM warmth a permanent home */}
<Sidebar navItems={navItems} activeScreenId={activeScreenId} onNavigate={goTo} />
{/* Main — clean white canvas for data */}
<div className="flex-1 min-w-0 ml-[240px] overflow-x-hidden">
<main className="px-7 py-6 max-w-[1440px]">
{detailInstanceId && activeScreenId ? (
<DetailPage
screenUuid={activeScreenId}
instanceId={detailInstanceId}
refreshKey={refreshKey}
onBack={back}
onAction={(a) => setFormModal(a)}
breadcrumbLabel={activeNav?.label ?? "Pipeline"}
/>
) : activeScreenId === DEMO_SLOTS_SCREEN_UUID ? (
<CalendarView />
) : activeScreenId ? (
<>
<h1 className="text-[22px] font-semibold tracking-tight leading-tight mb-5" style={{ color: INK }}>
{activeNav?.label ?? "Dashboard"}
</h1>
<DashboardContent
activeScreenId={activeScreenId}
onRowClick={openDetail}
onOpenForm={(a) => setFormModal(a)}
refreshKey={refreshKey}
/>
</>
) : null}
</main>
</div>
{formModal && (
<FormModal
activityId={formModal.activityId}
instanceId={formModal.instanceId}
title={formModal.title}
onClose={() => setFormModal(null)}
onSuccess={() => { setFormModal(null); setRefreshKey((k) => k + 1); }}
/>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function Sidebar({
navItems, activeScreenId, onNavigate,
}: { navItems: any[]; activeScreenId: string | null; onNavigate: (u: string) => void }) {
const { user, logout } = useAuthContext();
const navigate = useNavigate();
const initials = (user?.name || "U").split(" ").map((n) => n[0]).join("").slice(0, 2).toUpperCase();
const role = user?.roles?.[0] ?? "";
const handleLogout = () => { logout(); navigate("/login"); };
return (
<aside
className="fixed top-0 left-0 bottom-0 w-[240px] flex flex-col z-40"
style={{
background: "linear-gradient(180deg, #fdfbfb 0%, #fef2f4 100%)",
borderRight: `1px solid ${TM_BORDER}`,
}}
>
<div className="px-5 pt-6 pb-5 border-b border-stone-200/60">
<div className="leading-tight">
<img src={`${import.meta.env.BASE_URL}zino.svg`} alt="Zino" className="h-5 w-auto" />
<div className="text-[10.5px] uppercase tracking-[0.18em] text-stone-500 mt-2">AI Sales Ops</div>
</div>
</div>
<div className="flex-1 px-3 py-4 overflow-y-auto">
<div className="px-3 mb-3 text-[11px] font-semibold tracking-[-0.02em] leading-[110%]" style={{ color: ACCENT }}>Pipeline</div>
<nav className="space-y-1">
{navItems.map((item: any) => {
const active = activeScreenId === item.screen_uuid;
return (
<button
key={item.id}
onClick={() => onNavigate(item.screen_uuid)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-[13px] font-medium transition-all group ${
active
? ""
: "text-stone-600 hover:bg-white/70"
}`}
style={active ? { background: ACCENT_SOFT, color: ACCENT } : undefined}
>
<span
className={`flex items-center justify-center w-7 h-7 rounded-lg shrink-0 transition-colors ${
active
? ""
: "bg-white text-stone-500 shadow-sm group-hover:text-[#e31837]"
}`}
style={active ? { background: "#fbcfd7", color: ACCENT } : undefined}
>
{iconFor(item.label)}
</span>
<span className="truncate">{item.label}</span>
</button>
);
})}
</nav>
</div>
<div className="px-3 py-3 border-t border-stone-200/60">
<div
onClick={handleLogout}
className="flex items-center gap-3 px-2.5 py-2 rounded-lg cursor-pointer hover:bg-[#fafafa] transition-colors group"
>
<div className="w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-bold text-white shrink-0" style={{ background: `linear-gradient(135deg, ${ACCENT} 0%, ${ACCENT_DARK} 100%)` }}>
{initials}
</div>
<div className="flex-1 min-w-0">
<div className="text-[12.5px] font-semibold text-stone-800 truncate leading-tight">{user?.name}</div>
<div className="text-[10.5px] text-stone-500 truncate">{role}</div>
</div>
<LogOut size={13} className="text-stone-300 group-hover:text-stone-600 shrink-0" />
</div>
</div>
</aside>
);
}
// ---------------------------------------------------------------------------
// Dashboard content
// ---------------------------------------------------------------------------
function DashboardContent({
activeScreenId, onRowClick, onOpenForm, refreshKey,
}: {
activeScreenId: string;
onRowClick: (id: string) => void;
onOpenForm: (a: { activityId: string; instanceId?: string; title: string }) => void;
refreshKey: number;
}) {
const { viewId, screenButtons } = useActiveScreenRecordView(activeScreenId);
const rv = useRecordView(viewId);
useEffect(() => { if (refreshKey > 0) rv.refetch(); /* eslint-disable-next-line */ }, [refreshKey]);
if (!viewId) return <div className="text-[13px] text-stone-400 py-12 text-center">Loading</div>;
const tileSpecs = rv.analytics.filter((a): a is Extract<AnalyticsSpec, { kind: "tile" }> => a.kind === "tile");
const chartSpecs = rv.analytics.filter((a): a is Extract<AnalyticsSpec, { kind: "chart" }> => a.kind === "chart");
return (
<div className="space-y-7">
{/* Hero KPI cards */}
{tileSpecs.length > 0 && (
<div className="grid gap-5 grid-cols-2 lg:grid-cols-4">
{tileSpecs.map((t, i) => {
const v = rv.tileValues.find((tv) => tv.tile_uid === t.uid);
const display = v?.value == null ? "—" : typeof v.value === "number" ? v.value.toLocaleString("en-IN") : String(v.value);
const isPrimary = i === 0;
return (
<div
key={t.uid}
className="rounded-2xl p-5 transition-all hover:-translate-y-0.5 relative overflow-hidden"
style={{
background: isPrimary ? `linear-gradient(135deg, ${ACCENT} 0%, ${ACCENT_DARK} 100%)` : "white",
color: isPrimary ? "white" : "inherit",
border: isPrimary ? "none" : "1px solid #f1f1ee",
boxShadow: isPrimary
? "0 8px 24px -8px rgba(227,24,55,0.35)"
: "0 1px 2px rgba(6,31,92,0.04), 0 8px 24px -12px rgba(6,31,92,0.08)",
}}
>
{/* Navy structural accent in the corner of the primary red tile */}
{isPrimary && (
<span
className="absolute top-0 right-0 w-20 h-20 -mt-10 -mr-10 rounded-full"
style={{ background: `radial-gradient(circle at 30% 70%, ${TM_NAVY}66 0%, transparent 70%)` }}
/>
)}
<div
className="text-[12.5px] font-semibold tracking-[-0.02em] leading-[110%] mb-2"
style={{ color: isPrimary ? "#ffd9de" : ACCENT }}
>
{t.title}
</div>
<div
className="text-[30px] font-bold tabular-nums leading-none"
style={{ color: isPrimary ? "white" : INK }}
>
{rv.loading || rv.dataLoading
? <span className="inline-block h-8 w-14 bg-white/20 rounded animate-pulse" />
: display}
</div>
<div className={`text-[11px] mt-1.5 ${isPrimary ? "text-white/70" : "text-stone-500"}`}>
total this week
</div>
</div>
);
})}
</div>
)}
{/* Big charts as centerpiece */}
{chartSpecs.length > 0 && (
<div className="grid gap-5 lg:grid-cols-2">
{chartSpecs.map((c) => {
const cd = rv.chartData.find((d) => d.chart_uid === c.uid);
const data = (cd?.rows ?? [])
.map((r) => ({ name: String(r.dimension ?? "—"), value: Number(r.value ?? 0) }))
.filter((d) => d.name !== "—" && d.value > 0);
return (
<div key={c.uid} className="bg-white rounded-2xl p-6" style={{ border: "1px solid #f1f1ee", boxShadow: "0 1px 2px rgba(6,31,92,0.04), 0 8px 24px -12px rgba(6,31,92,0.08)" }}>
<div className={EYEBROW_CLS} style={EYEBROW_STYLE}>Insight</div>
<div className="text-[18px] font-bold leading-tight mt-2 mb-5" style={{ color: INK }}>{c.title}</div>
{rv.loading || rv.dataLoading ? (
<div className="h-[260px] bg-stone-50 rounded-xl animate-pulse" />
) : data.length === 0 ? (
<div className="h-[260px] flex items-center justify-center text-[13px] text-stone-400">No data yet</div>
) : (
<div
style={{ width: "100%", height: 260 }}
className="[&_.recharts-sector]:outline-none [&_.recharts-rectangle]:outline-none [&_.recharts-surface]:outline-none"
>
<ResponsiveContainer>
{c.chartType === "pie" ? (
<RPieChart>
<Pie data={data} dataKey="value" nameKey="name" innerRadius={55} outerRadius={95} paddingAngle={3}>
{data.map((_, i) => <Cell key={i} fill={PALETTE[i % PALETTE.length]} />)}
</Pie>
<Tooltip contentStyle={{ fontSize: 12, borderRadius: 12, border: "none", boxShadow: "0 4px 12px rgba(0,0,0,0.1)" }} />
<Legend verticalAlign="middle" align="right" layout="vertical" iconType="circle" wrapperStyle={{ fontSize: 12 }} />
</RPieChart>
) : (
<RBarChart data={data} margin={{ top: 5, right: 10, left: -10, bottom: 50 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f5f5f4" vertical={false} />
<XAxis
dataKey="name"
tick={{ fontSize: 11, fill: "#78716c" }}
interval={0}
angle={-25}
textAnchor="end"
height={60}
axisLine={false}
tickLine={false}
/>
<YAxis tick={{ fontSize: 11, fill: "#78716c" }} allowDecimals={false} axisLine={false} tickLine={false} />
<Tooltip cursor={{ fill: ACCENT_SOFT }} contentStyle={{ fontSize: 12, borderRadius: 12, border: "none", boxShadow: "0 4px 12px rgba(0,0,0,0.1)" }} />
<Bar dataKey="value" radius={[8, 8, 0, 0]}>
{data.map((_, i) => <Cell key={i} fill={PALETTE[i % PALETTE.length]} />)}
</Bar>
</RBarChart>
)}
</ResponsiveContainer>
</div>
)}
</div>
);
})}
</div>
)}
<RecordTable rv={rv} onRowClick={onRowClick} onOpenForm={onOpenForm} screenButtons={screenButtons} />
</div>
);
}
// Per-column max width (px). Long-text columns get the widest cap; the rest
// stay tight so a single overflowing summary can't push the whole table wide.
function colMaxWidth(dataType: string, fieldKey: string): number {
const k = fieldKey.toLowerCase();
if (dataType === "longtext") return 360;
if (k.includes("summary") || k.includes("reason") || k.includes("transcript") || k.includes("recording")) return 320;
if (k === "instance_id" || k.endsWith("_id")) return 110;
if (dataType === "number") return 130;
if (dataType === "date" || dataType === "datetime") return 140;
if (k.includes("email")) return 220;
if (k.includes("phone")) return 160;
return 200;
}
function RecordTable({
rv, onRowClick, onOpenForm, screenButtons,
}: {
rv: ReturnType<typeof useRecordView>;
onRowClick: (id: string) => void;
onOpenForm: (a: { activityId: string; instanceId?: string; title: string }) => void;
screenButtons: ScreenButton[];
}) {
const [searchInput, setSearchInput] = useState("");
const { fields, rows, columnLabels, actionColumns, pagination, query, dataLoading, error } = rv;
const { page, total_count, total_pages, limit } = pagination;
const from = total_count > 0 ? (page - 1) * limit + 1 : 0;
const to = Math.min(page * limit, total_count);
const handleSort = (k: string) =>
rv.setQuery((q) => ({ ...q, page: 1, sort_by: k, sort_dir: q.sort_by === k && q.sort_dir === "asc" ? "desc" : "asc" }));
const submitSearch = (e: React.FormEvent) => { e.preventDefault(); rv.setQuery((q) => ({ ...q, page: 1, search: searchInput })); };
return (
<div className="bg-white rounded-2xl overflow-hidden" style={{ border: "1px solid #f1f1ee", boxShadow: "0 1px 2px rgba(6,31,92,0.04), 0 4px 16px -8px rgba(6,31,92,0.08)" }}>
<div className="px-6 py-3.5 flex items-center justify-between gap-4 border-b border-stone-100">
<div className="flex items-baseline gap-3 min-w-0">
<div className="text-[15px] font-bold leading-none truncate" style={{ color: INK }}>All leads</div>
<div className="text-[12px] text-stone-500 tabular-nums whitespace-nowrap">
{total_count} records · showing {from}{to}
</div>
</div>
<div className="flex items-center gap-2">
{screenButtons.map((b) => (
<button
key={b.activityId}
onClick={() => onOpenForm({ activityId: b.activityId, title: b.label })}
className="h-9 px-4 text-[13px] font-semibold text-white inline-flex items-center gap-1.5 transition-opacity hover:opacity-90 rounded"
style={{ background: ACCENT }}
>
<Plus size={14} strokeWidth={2.5} />{b.label}
</button>
))}
<button
onClick={() => rv.refetch()}
className="w-9 h-9 rounded-lg flex items-center justify-center text-stone-400 hover:text-stone-700 hover:bg-stone-50 transition-colors"
title="Refresh"
>
<RefreshCw size={14} className={dataLoading ? "animate-spin" : ""} />
</button>
<form onSubmit={submitSearch}>
<div className="relative">
<Search size={13} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-stone-400" />
<input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search leads…"
className="w-56 h-9 pl-9 pr-3 text-[13px] border border-stone-200 rounded-lg bg-stone-50 text-stone-800 focus:outline-none focus:bg-white"
style={{ borderColor: searchInput ? ACCENT : undefined }}
/>
{searchInput && (
<button
type="button"
onClick={() => { setSearchInput(""); rv.setQuery((q) => ({ ...q, page: 1, search: "" })); }}
className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-700"
><X size={12} /></button>
)}
</div>
</form>
</div>
</div>
{error ? (
<div className="p-10 text-center text-[13px] text-red-600">{error}</div>
) : (
<div className="overflow-x-auto relative">
{dataLoading && (
<div className="absolute inset-0 bg-white/50 z-10 flex items-center justify-center backdrop-blur-[1px]">
<div className="w-5 h-5 border-2 border-rose-200 border-t-[#e31837] rounded-full animate-spin" />
</div>
)}
<table className="w-full" style={{ tableLayout: "fixed" }}>
<colgroup>
{fields.map((f) => (
<col key={f.field_key} style={{ width: colMaxWidth(f.data_type, f.field_key) }} />
))}
{actionColumns.length > 0 && <col style={{ width: 180 }} />}
</colgroup>
<thead>
<tr className="border-b-2 border-stone-200/70">
{fields.map((f, i) => {
const sorted = query.sort_by === f.field_key;
return (
<th
key={f.field_key}
onClick={() => handleSort(f.field_key)}
className={`py-3 text-left text-[11.5px] font-semibold cursor-pointer select-none whitespace-nowrap group/th transition-colors ${
i === 0 ? "pl-7 pr-4" : "px-4"
} ${sorted ? "text-[#e31837]" : "text-stone-500 hover:text-stone-800"}`}
>
<span className="inline-flex items-center gap-1">
{columnLabels[f.field_key] || f.output_label || f.field_key}
{sorted
? (query.sort_dir === "asc" ? <ArrowUp size={10} /> : <ArrowDown size={10} />)
: <ArrowDown size={10} className="text-stone-300 opacity-0 group-hover/th:opacity-100 transition-opacity" />}
</span>
</th>
);
})}
{actionColumns.length > 0 && (
<th className="py-3 px-4 text-left text-[11.5px] font-semibold text-stone-500 whitespace-nowrap">Action</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-stone-200/60">
{rows.length === 0 ? (
<tr>
<td colSpan={fields.length + (actionColumns.length > 0 ? 1 : 0)} className="py-20 text-center">
<div className="flex flex-col items-center gap-3">
<div
className="w-14 h-14 rounded-2xl flex items-center justify-center"
style={{ background: `linear-gradient(135deg, ${ACCENT_SOFT}, #fbcfd7)` }}
>
<Inbox size={22} className="text-[#e31837]" />
</div>
<div>
<div className="text-[14px] font-semibold text-stone-700">No leads here yet</div>
<div className="text-[12px] text-stone-400 mt-1">
{query.search ? "Try a different search term" : "New leads will show up here as they come in"}
</div>
</div>
{query.search && (
<button
onClick={() => { setSearchInput(""); rv.setQuery((q) => ({ ...q, page: 1, search: "" })); }}
className="text-[12px] font-semibold text-[#e31837] hover:text-[#5f0229] transition-colors"
>
Clear search
</button>
)}
</div>
</td>
</tr>
) : rows.map((row, i) => (
<tr
key={String(row.instance_id ?? i)}
onClick={() => onRowClick(String(row.instance_id))}
className="group/row cursor-pointer hover:bg-[#f6f2ea]/60 transition-colors"
>
{fields.map((f, fi) => (
<td
key={f.field_key}
className={`py-3.5 text-[13.5px] ${
fi === 0
? "pl-7 pr-4 font-semibold text-stone-900 border-l-2 border-l-transparent group-hover/row:border-l-[#e31837] transition-colors"
: "px-4 text-stone-600"
}`}
>
<div
className="truncate"
title={typeof row[f.field_key] === "string" ? String(row[f.field_key]) : undefined}
>
{renderCell(row[f.field_key], f.data_type, f.field_key)}
</div>
</td>
))}
{actionColumns.length > 0 && (
<td className="px-4 py-3 whitespace-nowrap" onClick={(e) => e.stopPropagation()}>
<div className="inline-flex items-center gap-2">
{actionColumns.flatMap((ac) =>
ac.buttons.filter((b) => evalShowConditions(b.show_conditions, row))
).map((b: ActionButton, bi) => {
const aid = b.params?.activity_uid;
if (!aid) return null;
return (
<button
key={bi}
onClick={() => onOpenForm({ activityId: aid, instanceId: String(row.instance_id), title: b.name })}
className="h-9 px-4 text-[13px] font-semibold rounded-lg inline-flex items-center gap-2 transition-colors hover:bg-[#fbcfd7]"
style={{ background: ACCENT_SOFT, color: ACCENT, border: `1px solid ${ACCENT}33` }}
>
{b.name}
</button>
);
})}
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{total_count > 0 && (
<div className="px-6 py-3 border-t border-stone-100 flex items-center justify-between gap-4">
<div className="flex items-center gap-3 text-[12px] text-stone-500">
<div className="flex items-center gap-1.5">
Show
<select
value={limit}
onChange={(e) => rv.setQuery((q) => ({ ...q, page: 1, limit: Number(e.target.value) }))}
className="h-7 px-2 text-[12px] border border-stone-200 rounded-lg bg-white text-stone-600 font-medium focus:outline-none cursor-pointer"
>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
</select>
per page
</div>
<span className="pl-3 border-l border-stone-200 tabular-nums">{from}{to} of {total_count}</span>
</div>
{total_pages > 1 && (
<div className="flex items-center gap-0.5">
<button
disabled={page <= 1}
onClick={() => rv.setQuery((q) => ({ ...q, page: page - 1 }))}
className="w-7 h-7 rounded-lg flex items-center justify-center text-stone-400 hover:text-[#e31837] hover:bg-rose-50 disabled:opacity-30 disabled:pointer-events-none transition-colors"
><ChevronLeft size={15} /></button>
{pageList(page, total_pages).map((p, idx) =>
p === "…" ? (
<span key={`e${idx}`} className="w-7 h-7 flex items-center justify-center text-[12px] text-stone-300 select-none"></span>
) : (
<button
key={p}
onClick={() => rv.setQuery((q) => ({ ...q, page: p as number }))}
className={`w-7 h-7 rounded-lg text-[12px] font-medium transition-colors ${
p === page
? "text-white"
: "text-stone-500 hover:text-[#e31837] hover:bg-rose-50"
}`}
style={p === page ? { background: ACCENT } : undefined}
>
{p}
</button>
)
)}
<button
disabled={page >= total_pages}
onClick={() => rv.setQuery((q) => ({ ...q, page: page + 1 }))}
className="w-7 h-7 rounded-lg flex items-center justify-center text-stone-400 hover:text-[#e31837] hover:bg-rose-50 disabled:opacity-30 disabled:pointer-events-none transition-colors"
><ChevronRight size={15} /></button>
</div>
)}
</div>
)}
</div>
);
}
const AVATAR_PALETTE: { bg: string; fg: string }[] = [
{ bg: "#fde2e8", fg: "#9d0d24" }, // soft rose
{ bg: "#e3e8f5", fg: "#1e3a8a" }, // soft navy
{ bg: "#e8f0e3", fg: "#3f6212" }, // soft olive
{ bg: "#f5ead3", fg: "#854d0e" }, // soft amber
{ bg: "#e5dff5", fg: "#5b21b6" }, // soft violet
{ bg: "#d6ecec", fg: "#155e75" }, // soft teal
{ bg: "#f5e0d3", fg: "#9a3412" }, // soft terracotta
{ bg: "#e6e8ec", fg: "#334155" }, // soft slate
];
function avatarColor(name: string): { bg: string; fg: string } {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return AVATAR_PALETTE[h % AVATAR_PALETTE.length];
}
const MODEL_DOT: Record<string, string> = {
"Thar Roxx": "#9d0d24",
"XUV700": "#1e3a8a",
"XUV3XO": "#155e75",
"Scorpio-N": "#5b21b6",
"Bolero Neo": "#854d0e",
};
function modelColor(model: string): string {
if (MODEL_DOT[model]) return MODEL_DOT[model];
let h = 0;
for (let i = 0; i < model.length; i++) h = (h * 31 + model.charCodeAt(i)) >>> 0;
const fallback = ["#9d0d24", "#1e3a8a", "#155e75", "#5b21b6", "#854d0e", "#3f6212"];
return fallback[h % fallback.length];
}
function pageList(current: number, total: number): (number | "…")[] {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
const pages: (number | "…")[] = [1];
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
if (start > 2) pages.push("…");
for (let i = start; i <= end; i++) pages.push(i);
if (end < total - 1) pages.push("…");
pages.push(total);
return pages;
}
function renderCell(value: unknown, dataType: string, fieldKey: string): React.ReactNode {
if (value == null || value === "") return <span className="text-stone-300"></span>;
if (fieldKey === "current_state_name") {
const t = tone(String(value));
return (
<span className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-[11.5px] font-semibold" style={{ background: t.bg, color: t.fg }}>
<span className="w-1.5 h-1.5 rounded-full" style={{ background: t.dot }} />
{String(value)}
</span>
);
}
if (fieldKey === "instance_id") {
return <span className="font-mono text-[11px] text-stone-400">#{String(value).slice(0, 8)}</span>;
}
if (fieldKey === "customer_name") {
const name = String(value);
const initials = name.split(" ").map((n) => n[0]).join("").slice(0, 2).toUpperCase();
const c = avatarColor(name);
return (
<span className="inline-flex items-center gap-2.5">
<span
className="w-7 h-7 rounded-full flex items-center justify-center text-[10.5px] font-semibold shrink-0"
style={{ background: c.bg, color: c.fg }}
>
{initials}
</span>
<span className="font-semibold text-stone-900">{name}</span>
</span>
);
}
if (fieldKey === "customer_phone" || fieldKey === "phone") {
const phone = typeof value === "object" && value !== null
? String((value as any).phone_with_dial_code ?? (value as any).phone ?? "")
: String(value);
if (!phone) return <span className="text-stone-300"></span>;
return <span className="text-stone-600 tabular-nums">{phone}</span>;
}
if (fieldKey === "model_interest" || fieldKey === "model") {
const dot = modelColor(String(value));
return (
<span
className="inline-flex items-center gap-2 px-2.5 py-1 rounded-full border bg-white"
style={{ borderColor: `${dot}33` }}
>
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: `${dot}cc` }} />
<span className="text-[12.5px] text-stone-700 tracking-[-0.01em]">{String(value)}</span>
</span>
);
}
if (fieldKey === "feedback_rating") {
const n = Number(value);
if (!Number.isFinite(n)) return <span className="text-stone-300"></span>;
// Render as 5 stars (rating is /10; halve to /5).
const filled = Math.round(n / 2);
return (
<span className="inline-flex items-center gap-1.5">
<span className="inline-flex items-center gap-0.5">
{Array.from({ length: 5 }, (_, i) => (
<Star
key={i}
size={13}
fill={i < filled ? "#f59e0b" : "transparent"}
stroke={i < filled ? "#f59e0b" : "#d6d3d1"}
strokeWidth={2}
/>
))}
</span>
<span className="text-[12px] tabular-nums text-stone-500 font-medium">{n.toFixed(n % 1 === 0 ? 0 : 1)}</span>
</span>
);
}
if (fieldKey === "feedback_would_recommend") {
const v = value === true || value === "true" || value === 1 || value === "1";
const no = value === false || value === "false" || value === 0 || value === "0";
if (!v && !no) return <span className="text-stone-300"></span>;
return v
? <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[12px] font-semibold" style={{ background: "#e7f3eb", color: "#15803d" }}><ThumbsUp size={12} /> Yes</span>
: <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[12px] font-semibold" style={{ background: "#fde2e8", color: "#9d0d24" }}><ThumbsDown size={12} /> No</span>;
}
if (dataType === "number") return <span className="tabular-nums font-medium text-stone-900">{fmtCellText(value, dataType, fieldKey)}</span>;
return fmtCellText(value, dataType, fieldKey);
}
// ---------------------------------------------------------------------------
// Detail page (full-width replace)
// ---------------------------------------------------------------------------
function DetailPage({
screenUuid, instanceId, refreshKey, onBack, onAction, breadcrumbLabel,
}: {
screenUuid: string;
instanceId: string;
refreshKey: number;
onBack: () => void;
onAction: (a: { activityId: string; instanceId: string; title: string }) => void;
breadcrumbLabel: string;
}) {
const dvViewId = SCREEN_TO_DV[screenUuid] || DETAIL_VIEW_IDS.INSTANCE_DETAIL;
const dv = useDetailView(dvViewId, instanceId);
const { instanceState, audit } = useInstanceMeta(instanceId, refreshKey);
const actions = useMemo(() => {
if (!instanceState) return [] as Array<{ id: string; label: string; icon: React.ReactNode }>;
if (instanceState === STATE_IDS.SCHEDULED) return [{ id: ACTIVITY_IDS.MARK_TEST_DRIVE_DONE, label: "Mark Test Drive Done", icon: <CheckCircle2 size={14} /> }];
if (instanceState === STATE_IDS.SCHEDULING_CALL_FAILED) return [{ id: ACTIVITY_IDS.RETRY_SCHEDULING_CALL, label: "Retry Scheduling Call", icon: <RefreshCw size={14} /> }];
if (instanceState === STATE_IDS.FEEDBACK_CALL_FAILED) return [{ id: ACTIVITY_IDS.RETRY_FEEDBACK_CALL, label: "Retry Feedback Call", icon: <RefreshCw size={14} /> }];
if (instanceState === STATE_IDS.EXPERT_TD_SCHEDULED) return [{ id: ACTIVITY_IDS.MARK_EXPERT_TD_DONE, label: "Mark Expert TD Done", icon: <CheckCircle2 size={14} /> }];
return [];
}, [instanceState]);
const aiInFlight = instanceState === STATE_IDS.AWAITING_SCHEDULER_CALL || instanceState === STATE_IDS.AWAITING_FEEDBACK_CALL;
if (dv.loading) return <div className="text-[14px] text-stone-400 py-20 text-center">Loading lead</div>;
if (!dv.data) return null;
const heroValue = String(dv.data["customer_name"] ?? "");
const stateName = String(dv.data["current_state_name"] ?? "");
const t = tone(stateName);
const model = String(dv.data["model_interest"] ?? "");
// Group fields by prefix. Recording-URL fields are pulled out separately so
// they can surface in the narrow lane as a "Play call" quick action instead of
// being buried inside the Feedback/AI section.
const groups: Record<string, typeof dv.fields> = { customer: [], vehicle: [], booking: [], feedback: [], ai: [], other: [] };
const recordings: typeof dv.fields = [];
const sys = new Set(["instance_id", "current_state_name", "created_at", "updated_at", "customer_name", "model_interest", "analysis_so_far"]);
for (const f of dv.fields) {
if (sys.has(f.field_key)) continue;
if (/recording_url$/i.test(f.field_key)) { recordings.push(f); continue; }
// Drop plumbing IDs (feedback_call_id, scheduling_call_id, …) from DV sections.
if (/_call_id$/i.test(f.field_key)) continue;
const k = f.field_key.toLowerCase();
if (k.startsWith("customer_") || k === "phone" || k === "email" || k === "lead_source") groups.customer.push(f);
else if (k.startsWith("vehicle_") || k === "variant" || k === "color" || k === "fuel_type") groups.vehicle.push(f);
else if (k.startsWith("booking_") || k === "dealer_name" || k === "dealer_id" || k.includes("slot")) groups.booking.push(f);
else if (k.startsWith("feedback_") || k === "rating" || k === "would_recommend" || k === "test_drive_outcome") groups.feedback.push(f);
else if (k.startsWith("ai_") || k.startsWith("call_") || k.includes("summary") || k.includes("confidence") || k.includes("reasoning") || k.includes("failure_reason") || k.includes("escalation")) groups.ai.push(f);
else groups.other.push(f);
}
// Lead age — relative-time string from created_at. Empty if no timestamp.
const leadAgeLabel = (() => {
const ts = dv.data["created_at"];
if (!ts) return "";
const ms = Date.now() - new Date(String(ts)).getTime();
if (!Number.isFinite(ms) || ms < 0) return "";
const mins = Math.floor(ms / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins} min${mins === 1 ? "" : "s"} ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs} hour${hrs === 1 ? "" : "s"} ago`;
const days = Math.floor(hrs / 24);
if (days < 30) return `${days} day${days === 1 ? "" : "s"} ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months} month${months === 1 ? "" : "s"} ago`;
const years = Math.floor(days / 365);
return `${years} year${years === 1 ? "" : "s"} ago`;
})();
const playableRecordings = recordings
.map((f) => ({ field: f, url: dv.data[f.field_key] }))
.filter((r) => r.url != null && r.url !== "" && r.url !== "—") as { field: typeof recordings[number]; url: string }[];
const hasQuickInfo = !!leadAgeLabel || playableRecordings.length > 0;
const phone = (() => {
const v = dv.data["customer_phone"];
if (typeof v === "object" && v !== null) return String((v as any).phone_with_dial_code ?? (v as any).phone ?? "");
return v ? String(v) : "";
})();
const createdDate = dv.data["created_at"]
? new Date(String(dv.data["created_at"])).toLocaleDateString("en-IN", { day: "2-digit", month: "short", year: "numeric" })
: "";
return (
<div className="space-y-6">
{/* Breadcrumb above the card, on the page surface */}
<div className="flex items-center gap-2 text-[12px] font-medium text-stone-400">
<button onClick={onBack} className="inline-flex items-center gap-1.5 hover:text-[#e31837] transition-colors">
<ChevronLeft size={14} />
{breadcrumbLabel}
</button>
<span className="text-stone-300">/</span>
<span className="text-stone-600 font-semibold">{heroValue || `#${instanceId}`}</span>
</div>
{aiInFlight && (
<div className="rounded-xl px-5 py-4 flex items-center gap-3" style={{ background: ACCENT_SOFT, border: `1px solid #fbb4be` }}>
<div className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" style={{ background: ACCENT }}>
<Sparkles size={15} className="text-white" />
</div>
<div>
<div className="text-[13px] font-semibold text-[#e31837]">Maya is on the call</div>
<div className="text-[12px] text-[#5f0229]">The AI scheduler is handling this lead. This page refreshes when complete.</div>
</div>
</div>
)}
{/* Unified card — hero on top, hairline divider, then sub-section grid. */}
<div className="bg-white rounded-2xl" style={{ border: CARD_BORDER }}>
{/* Hero region — avatar + name + inline state pill on the left, actions on the right.
Metadata row uses small inline icons instead of middots for clarity. */}
<div className="px-7 py-6 flex items-start justify-between gap-6 flex-wrap">
<div className="min-w-0 flex-1 flex items-start gap-4">
{/* Avatar circle — first two initials of the customer name */}
<div
className="w-14 h-14 rounded-full flex items-center justify-center shrink-0 text-[18px] font-bold tracking-tight"
style={{ background: ACCENT_SOFT, color: ACCENT_DARK }}
>
{(() => {
const src = heroValue || `#${instanceId}`;
const parts = src.replace(/[#]/g, "").trim().split(/\s+/).filter(Boolean);
const initials = parts.length >= 2
? (parts[0][0] + parts[1][0])
: (parts[0] || "?").slice(0, 2);
return initials.toUpperCase();
})()}
</div>
<div className="min-w-0 flex-1 pt-0.5">
<h2 className="text-[26px] font-bold leading-none tracking-tight" style={{ color: INK }}>
{heroValue || `#${instanceId}`}
</h2>
{/* Metadata row — small inline icons, less ornament than middots */}
<div className="mt-3 flex items-center gap-x-5 gap-y-1.5 text-[12.5px] text-stone-500 flex-wrap">
{phone && (
<span className="inline-flex items-center gap-1.5 tabular-nums">
<PhoneOutgoing size={13} strokeWidth={2.25} className="text-stone-400" />
{phone}
</span>
)}
{model && (
<span className="inline-flex items-center gap-1.5 font-medium text-stone-600">
<Car size={13} strokeWidth={2.25} className="text-stone-400" />
{model}
</span>
)}
{createdDate && (
<span className="inline-flex items-center gap-1.5">
<CalendarCheck2 size={13} strokeWidth={2.25} className="text-stone-400" />
{createdDate}
</span>
)}
<span className="inline-flex items-center gap-1 tabular-nums text-stone-400">
<Hash size={12} strokeWidth={2.25} />
{instanceId}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0 flex-wrap justify-end">
{stateName && (
<span
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11.5px] font-semibold"
style={{ background: t.bg, color: t.fg }}
>
<span className="w-1.5 h-1.5 rounded-full" style={{ background: t.dot }} />
{stateName}
</span>
)}
{actions.length > 0 && actions.map((a, i) => (
<button
key={a.id}
onClick={() => onAction({ activityId: a.id, instanceId, title: a.label })}
className="h-9 px-4 text-[13px] font-semibold rounded-lg inline-flex items-center gap-2 transition-colors"
style={
i === 0
? { background: ACCENT, color: "white" }
: { background: "white", color: INK, border: `1px solid ${TM_BORDER}` }
}
>
{a.icon}{a.label}
</button>
))}
</div>
</div>
{/* Sub-section grid — inside the unified card, separated from hero by a hairline.
Hide the AI analysis block on the Scheduled Test Drives view; the dealer is
the audience there and the AI fields would just be noise. */}
{(() => {
const visible = Object.entries(groups).filter(
([key, fs]) => fs.length > 0 && !(screenUuid === "98af93e0-0f66-444b-a007-129b322be86b" && key === "ai")
);
const wide = visible.filter(([key]) => SECTION_LANE[key] !== "narrow");
const narrow = visible.filter(([key]) => SECTION_LANE[key] === "narrow");
if (visible.length === 0 && !hasQuickInfo) return null;
const hasBoth = wide.length > 0 && (narrow.length > 0 || hasQuickInfo);
const quickInfo = hasQuickInfo ? (
<div>
<div className="text-[11px] font-bold uppercase tracking-[0.12em] mb-4" style={{ color: ACCENT }}>
At a Glance
</div>
<div className="space-y-3">
{leadAgeLabel && (
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" style={{ background: ACCENT_SOFT, color: ACCENT_DARK }}>
<CalendarCheck2 size={16} strokeWidth={2.25} />
</div>
<div className="min-w-0">
<div className="text-[11.5px] font-medium text-stone-400">Lead age</div>
<div className="text-[14px] font-semibold leading-snug" style={{ color: INK }}>{leadAgeLabel}</div>
</div>
</div>
)}
{playableRecordings.map((r) => (
<a
key={r.field.field_key}
href={r.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 rounded-lg px-2 py-1.5 -mx-2 hover:bg-stone-50 transition-colors group"
>
<div className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" style={{ background: ACCENT_SOFT, color: ACCENT_DARK }}>
<PlayCircle size={16} strokeWidth={2.25} />
</div>
<div className="min-w-0">
<div className="text-[11.5px] font-medium text-stone-400">{fmtLabel(r.field.output_label) || "Recording"}</div>
<div className="text-[14px] font-semibold leading-snug group-hover:underline underline-offset-4" style={{ color: ACCENT_DARK }}>
Play call
</div>
</div>
<ExternalLink size={12} strokeWidth={2.25} className="ml-auto shrink-0 text-stone-300" />
</a>
))}
</div>
</div>
) : null;
return (
<div className="px-7 py-6 border-t border-stone-200">
{hasBoth ? (
<div className="grid grid-cols-3 gap-x-10">
<div className="col-span-2 [&>*+*]:mt-8 [&>*+*]:pt-8 [&>*+*]:border-t [&>*+*]:border-stone-200">
{wide.map(([key, fs]) => (
<SubSection key={key} sectionKey={key} fields={fs} data={dv.data!} lane="wide" />
))}
</div>
<div className="col-span-1 pl-10 border-l border-stone-200 [&>*+*]:mt-8 [&>*+*]:pt-8 [&>*+*]:border-t [&>*+*]:border-stone-200">
{quickInfo}
{narrow.map(([key, fs]) => (
<SubSection key={key} sectionKey={key} fields={fs} data={dv.data!} lane="narrow" />
))}
</div>
</div>
) : (
<div className="[&>*+*]:mt-8 [&>*+*]:pt-8 [&>*+*]:border-t [&>*+*]:border-stone-200">
{quickInfo}
{visible.map(([key, fs]) => (
<SubSection key={key} sectionKey={key} fields={fs} data={dv.data!} lane={SECTION_LANE[key] === "narrow" ? "narrow" : "wide"} />
))}
</div>
)}
</div>
);
})()}
</div>
{audit.length > 0 && (
<div className="bg-white rounded-2xl p-6" style={{ border: CARD_BORDER }}>
<div className="flex items-center gap-3 mb-5">
<span className="text-[11px] font-bold tabular-nums px-1.5 py-0.5 text-white" style={{ background: ACCENT, letterSpacing: "0.04em" }}>LOG</span>
<div className="text-[17px] font-bold tracking-tight leading-none" style={{ color: INK }}>Activity timeline</div>
<div className="text-[11.5px] font-medium tabular-nums text-stone-400">{audit.length} {audit.length === 1 ? "event" : "events"}</div>
</div>
<ol className="relative">
{/* Vertical rail down the timeline */}
<span className="absolute left-[5px] top-2 bottom-2 w-px" style={{ background: TM_BORDER }} />
{audit.map((e, i) => {
const meta = ACTIVITY_LABELS[e.activity_id];
const label = meta?.label ?? "Workflow step";
const tone = meta?.tone ?? "system";
const isLast = i === audit.length - 1;
const dotColor = isLast ? ACCENT : tone === "ai" ? TM_NAVY : tone === "user" ? "#57534e" : "#a8a29e";
const actor = tone === "ai" ? "AI · Maya" : (e.user_roles?.[0] ?? "System");
return (
<li key={e.id} className="relative pl-7 pb-4 last:pb-0 text-[13px]">
<span
className="absolute left-0 top-1 w-[11px] h-[11px] rounded-full"
style={{ background: dotColor, boxShadow: `0 0 0 3px white` }}
/>
<div className="flex items-baseline justify-between gap-4">
<div className="min-w-0">
<div className="font-semibold leading-tight" style={{ color: INK }}>{label}</div>
<div className="text-[11.5px] uppercase tracking-[0.04em] text-stone-400 mt-1">
{actor}{e.execution_state && e.execution_state !== "completed" ? ` · ${e.execution_state}` : ""}
</div>
</div>
<span className="text-stone-400 tabular-nums shrink-0 text-[12px]">
{new Date(e.created_at).toLocaleString("en-IN", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" })}
</span>
</div>
</li>
);
})}
</ol>
</div>
)}
</div>
);
}
// Section titles — single strong word, no subtitle (cleaner editorial feel).
// Human labels for activity UUIDs that appear in the audit log. Anything not
// in this map falls back to "Workflow step".
const ACTIVITY_LABELS: Record<string, { label: string; tone: "user" | "ai" | "system" }> = {
[ACTIVITY_IDS.INIT_ACTIVITY]: { label: "Lead captured", tone: "user" },
[ACTIVITY_IDS.MARK_TEST_DRIVE_DONE]: { label: "Test drive marked done", tone: "user" },
[ACTIVITY_IDS.AI_CONFIRM_SCHEDULING_SUCCESS]: { label: "Scheduling call succeeded", tone: "ai" },
[ACTIVITY_IDS.AI_CONFIRM_SCHEDULING_FAILED]: { label: "Scheduling call failed", tone: "ai" },
[ACTIVITY_IDS.RETRY_SCHEDULING_CALL]: { label: "Retried scheduling call", tone: "user" },
[ACTIVITY_IDS.AI_CONFIRM_FEEDBACK_COLLECTED]: { label: "Feedback collected", tone: "ai" },
[ACTIVITY_IDS.AI_CONFIRM_FEEDBACK_FAILED]: { label: "Feedback call failed", tone: "ai" },
[ACTIVITY_IDS.RETRY_FEEDBACK_CALL]: { label: "Retried feedback call", tone: "user" },
[ACTIVITY_IDS.CONCERN_EXPERT_TD_BOOKED]: { label: "Concern → Expert TD booked", tone: "ai" },
[ACTIVITY_IDS.MARK_EXPERT_TD_DONE]: { label: "Expert TD marked done", tone: "user" },
};
const SECTION_TITLE: Record<string, string> = {
customer: "Customer",
vehicle: "Vehicle",
booking: "Booking",
feedback: "Feedback",
ai: "AI · Call Outcome",
other: "Other",
};
// Lane assignment for the DV sub-section grid. Sections with long-form text or
// many fields go in the wide (left, col-span-2) lane; short metadata sections
// stack in the narrow (right) lane.
const SECTION_LANE: Record<string, "wide" | "narrow"> = {
customer: "narrow",
vehicle: "narrow",
booking: "wide",
feedback: "wide",
ai: "wide",
other: "wide",
};
const SECTION_EMPTY: Record<string, { icon: React.ReactNode; title: string; body: string }> = {
customer: {
icon: <User size={18} strokeWidth={2} />,
title: "No customer details yet",
body: "Captured when the lead is created.",
},
vehicle: {
icon: <Car size={18} strokeWidth={2} />,
title: "No vehicle interest recorded",
body: "Filled in once the customer picks a model.",
},
booking: {
icon: <CalendarCheck2 size={18} strokeWidth={2} />,
title: "No booking yet",
body: "Fills in once a test drive is scheduled.",
},
feedback: {
icon: <MessageSquareHeart size={18} strokeWidth={2} />,
title: "No feedback yet",
body: "Captured automatically after the post-test-drive call.",
},
ai: {
icon: <Sparkles size={18} strokeWidth={2} />,
title: "No AI call activity",
body: "Maya's call summary appears here once she's connected.",
},
other: {
icon: <Inbox size={18} strokeWidth={2} />,
title: "Nothing recorded here yet",
body: "Fields will appear as the workflow progresses.",
},
};
function SubSection({
sectionKey, fields, data, lane,
}: {
sectionKey: string;
fields: any[];
data: Record<string, unknown>;
lane: "wide" | "narrow";
}) {
const title = SECTION_TITLE[sectionKey] ?? "Other";
const populated = fields.filter((f) => {
const r = data[f.field_key];
return r != null && r !== "" && r !== "—";
}).length;
const isEmpty = populated === 0;
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const LONGTEXT_PREVIEW = 220;
const gridClass = lane === "narrow"
? "grid grid-cols-1 gap-y-5"
: "grid grid-cols-2 gap-x-8 gap-y-5";
return (
<div>
{/* Section header — small pink/red uppercase letter-spaced label */}
<div className="text-[11px] font-bold uppercase tracking-[0.12em] mb-5" style={{ color: ACCENT }}>
{title}
</div>
{isEmpty ? (
(() => {
const e = SECTION_EMPTY[sectionKey] ?? SECTION_EMPTY.other;
return (
<div className="flex items-start gap-3 rounded-xl px-4 py-3.5" style={{ border: `1px dashed ${TM_BORDER}` }}>
<div className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 text-stone-400">
{e.icon}
</div>
<div className="min-w-0">
<div className="text-[13px] font-semibold leading-tight" style={{ color: INK }}>{e.title}</div>
<div className="text-[12px] text-stone-500 mt-1 leading-snug">{e.body}</div>
</div>
</div>
);
})()
) : (
<div className={gridClass}>
{fields
.filter((f) => {
const r = data[f.field_key];
return r != null && r !== "" && r !== "—";
})
.map((f) => {
const raw = data[f.field_key];
const empty = raw == null || raw === "" || raw === "—";
const text = empty ? "—" : fmtCellText(raw, f.data_type, f.field_key);
const isRecording = !empty && /recording_url$/i.test(f.field_key);
const isUrl = !empty && !isRecording && typeof raw === "string" && /^https?:\/\//i.test(raw);
const isLongText = !empty && f.data_type === "longtext" && text.length > LONGTEXT_PREVIEW;
// Any prose-style value (longtext data_type, or just a long string) reads
// as a paragraph — kill the bold weight and let it breathe.
const isProse = !empty && (f.data_type === "longtext" || text.length > 80);
const isRecommend = !empty && f.field_key === "feedback_would_recommend";
// UUID-ish identifiers (current_state_id, workflow_id, …) read as
// plumbing — render compact monospaced chips instead of bold values.
const isIdChip = !empty && (/_id$/i.test(f.field_key) || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(raw)));
// Short enum-ish status fields (scheduling_ended_reason, feedback_failure,
// test_drive_outcome, …) read as status chips, tone derived from the value.
const isStatusChip = !empty && !isIdChip && /_(reason|failure|outcome|status)$/i.test(f.field_key);
const statusTone = isStatusChip ? (() => {
const v = String(raw).toLowerCase();
if (/(fail|error|reject|cancel|abort|no_answer|busy|unreach)/.test(v)) return { bg: "#fde2e8", fg: "#9d0d24" };
if (/(success|complete|done|book|confirm|positive|good|happy)/.test(v)) return { bg: "#e7f3eb", fg: "#15803d" };
return { bg: "#f5f5f4", fg: "#57534e" };
})() : null;
const isOpen = !!expanded[f.field_key];
const spanFull = lane === "wide" && isProse;
return (
<div
key={f.field_key}
className={spanFull ? "col-span-2" : ""}
>
<div className="text-[11.5px] font-semibold uppercase tracking-[0.06em] text-stone-400 mb-2">
{fmtLabel(f.output_label)}
</div>
<div
className={`text-[14px] break-words ${isProse ? "leading-relaxed max-w-[68ch]" : "leading-snug"}`}
style={{ color: empty ? "#c8c6c2" : isProse ? "#44403c" : INK, fontWeight: empty || isRecording || isUrl || isProse || isRecommend || isIdChip || isStatusChip ? 400 : 600 }}
>
{isStatusChip && statusTone ? (
<span
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[12px] font-semibold"
style={{ background: statusTone.bg, color: statusTone.fg }}
>
<span className="w-1.5 h-1.5 rounded-full" style={{ background: statusTone.fg, opacity: 0.7 }} />
{text}
</span>
) : isIdChip ? (
<span
className="inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-mono tabular-nums tracking-tight"
style={{ background: "#f5f5f4", color: "#57534e", border: "1px solid #e7e5e4" }}
>
{text}
</span>
) : isRecommend ? (
(raw === true || String(raw).toLowerCase() === "true" || String(raw).toLowerCase() === "yes") ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[12px] font-semibold" style={{ background: "#e7f3eb", color: "#15803d" }}>
<ThumbsUp size={12} strokeWidth={2.25} /> Yes
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[12px] font-semibold" style={{ background: "#fde2e8", color: "#9d0d24" }}>
<ThumbsDown size={12} strokeWidth={2.25} /> No
</span>
)
) : isRecording ? (
<a
href={String(raw)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12.5px] font-semibold transition-opacity hover:opacity-90"
style={{ background: ACCENT_SOFT, color: ACCENT_DARK }}
>
<PlayCircle size={14} strokeWidth={2.25} />
Play recording
</a>
) : isUrl ? (
<a
href={String(raw)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 underline decoration-stone-300 underline-offset-4 hover:decoration-stone-700"
style={{ color: ACCENT_DARK }}
>
<span className="truncate max-w-[420px]">{text}</span>
<ExternalLink size={12} strokeWidth={2.25} className="shrink-0 opacity-70" />
</a>
) : isLongText ? (
<span>
<span className="whitespace-pre-wrap">
{isOpen ? text : text.slice(0, LONGTEXT_PREVIEW).trimEnd() + "…"}
</span>
<button
type="button"
onClick={() => setExpanded((m) => ({ ...m, [f.field_key]: !isOpen }))}
className="ml-2 text-[12px] font-semibold underline-offset-4 hover:underline"
style={{ color: ACCENT_DARK }}
>
{isOpen ? "Show less" : "Show more"}
</button>
</span>
) : (
text
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}