tech_mahindra/src/components/variants/VariantB.tsx
2026-05-23 16:31:35 +05:30

941 lines
45 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,
} 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" },
"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-10 py-10 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 ? (
<>
<div className="mb-8">
<div className={EYEBROW_CLS} style={EYEBROW_STYLE}>
Test-drive lifecycle
</div>
<h1 className="text-[24px] font-semibold tracking-tight leading-tight mt-1.5" style={{ color: INK }}>
{activeNav?.label ?? "Dashboard"}
</h1>
</div>
<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: SURFACE, 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">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-none text-[13px] font-medium transition-all"
style={
active
? { background: ACCENT, color: "white" }
: { color: "#57534E" }
}
onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = SURFACE_ALT; }}
onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = "transparent"; }}
>
<span className="shrink-0">{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-none 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: TM_NAVY }}>
{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-none p-6 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" : CARD_BORDER,
}}
>
{/* 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-[14px] font-semibold tracking-[-0.02em] leading-[110%] mb-3"
style={{ color: isPrimary ? "#ffd9de" : ACCENT }}
>
{t.title}
</div>
<div
className="text-[40px] font-bold tabular-nums leading-none"
style={{ color: isPrimary ? "white" : INK }}
>
{rv.loading || rv.dataLoading
? <span className="inline-block h-10 w-16 bg-white/20 rounded animate-pulse" />
: display}
</div>
<div className={`text-[12px] mt-2 ${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-none p-6" style={{ border: CARD_BORDER }}>
<div className={EYEBROW_CLS} style={EYEBROW_STYLE}>Insight</div>
<div className="text-[18px] font-bold leading-tight mt-2 mb-1" style={{ color: INK }}>{c.title}</div>
<div className="text-[12px] text-stone-500 mb-5">Distribution across the pipeline</div>
{rv.loading || rv.dataLoading ? (
<div className="h-[260px] bg-stone-50 rounded-none 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-none overflow-hidden" style={{ border: CARD_BORDER }}>
<div className="px-7 py-5 flex items-center justify-between gap-4 border-b border-stone-100">
<div>
<div className={EYEBROW_CLS} style={EYEBROW_STYLE}>Data</div>
<div className="text-[18px] font-bold mt-1.5" style={{ color: INK }}>All records</div>
<div className="text-[12px] text-stone-500 mt-0.5">
{total_count} leads · 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-none 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-none 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-slate-300 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.map((ac) => (
<col key={ac.id} style={{ width: 160 }} />
))}
</colgroup>
<thead>
<tr className="border-b border-stone-100">
{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-4 text-left text-[11.5px] font-semibold cursor-pointer select-none whitespace-nowrap ${
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} />)}
</span>
</th>
);
})}
{actionColumns.map((ac) => (
<th key={ac.id} className="py-4 px-4 text-left text-[11.5px] font-semibold text-stone-500">
{ac.display}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{rows.length === 0 ? (
<tr><td colSpan={fields.length + actionColumns.length} className="py-20 text-center text-[14px] text-stone-400">No records yet</td></tr>
) : rows.map((row, i) => (
<tr
key={String(row.instance_id ?? i)}
onClick={() => onRowClick(String(row.instance_id))}
className="cursor-pointer hover:bg-[#f6f2ea]/60 transition-colors"
>
{fields.map((f, fi) => (
<td
key={f.field_key}
className={`py-4 text-[13.5px] ${
fi === 0 ? "pl-7 pr-4 font-semibold text-stone-900" : "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.map((ac) => {
const visible = ac.buttons.filter((b) => evalShowConditions(b.show_conditions, row));
return (
<td key={ac.id} className="px-4 py-3 whitespace-nowrap" onClick={(e) => e.stopPropagation()}>
{visible.length === 0 ? <span className="text-stone-300"></span> : (
<div className="flex items-center gap-2">
{visible.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-8 px-3.5 text-[12px] font-semibold rounded text-white inline-flex items-center gap-1.5"
style={{ background: ACCENT }}
>
{b.name}
</button>
);
})}
</div>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
{total_count > 0 && total_pages > 1 && (
<div className="px-7 py-4 border-t border-stone-100 flex items-center justify-end gap-1">
<button
disabled={page <= 1}
onClick={() => rv.setQuery((q) => ({ ...q, page: page - 1 }))}
className="w-8 h-8 rounded flex items-center justify-center hover:bg-stone-100 disabled:opacity-30"
><ChevronLeft size={14} /></button>
<span className="px-3 text-[12px] text-stone-500 tabular-nums">Page {page} of {total_pages}</span>
<button
disabled={page >= total_pages}
onClick={() => rv.setQuery((q) => ({ ...q, page: page + 1 }))}
className="w-8 h-8 rounded flex items-center justify-center hover:bg-stone-100 disabled:opacity-30"
><ChevronRight size={14} /></button>
</div>
)}
</div>
);
}
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 (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} /> }];
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.
const groups: Record<string, typeof dv.fields> = { customer: [], vehicle: [], booking: [], feedback: [], ai: [], other: [] };
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;
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);
}
return (
<div className="space-y-6">
{/* Hero — full-bleed navy band. Back button at the top, then the lead block.
The -mx-10 pulls it out of main's px-10 so it spans the content area
edge-to-edge. */}
<div
className="rounded-none -mx-10 -mt-10 px-10 pt-7 pb-7 text-white relative overflow-hidden"
style={{ background: TM_NAVY }}
>
<span className="absolute top-0 left-0 bottom-0 w-1.5" style={{ background: ACCENT }} />
{/* Diagonal red slice on the far right — mirrors techmahindra.com footer */}
<span
className="absolute top-0 right-0 bottom-0 w-56 pointer-events-none opacity-50"
style={{
background: `linear-gradient(115deg, transparent 0%, transparent 55%, ${ACCENT} 55%, ${ACCENT} 58%, transparent 58%, transparent 72%, ${ACCENT} 72%, ${ACCENT} 74%, transparent 74%)`,
}}
/>
{/* Back link — sits at the hero top, white on navy */}
<div className="relative mb-5 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.08em] leading-none">
<button
onClick={onBack}
className="inline-flex items-center gap-1.5 text-white/75 hover:text-white transition-colors"
>
<ArrowLeft size={13} className="shrink-0" />
<span>{breadcrumbLabel}</span>
</button>
<span className="text-white/30">/</span>
<span className="text-white/55">Lead detail</span>
</div>
<div className="relative flex items-stretch justify-between gap-8 flex-wrap">
<div className="min-w-0 flex-1 flex flex-col justify-center">
<div className="text-[11px] font-semibold uppercase tracking-[0.15em] mb-2" style={{ color: ACCENT }}>Lead</div>
<div className="flex items-baseline gap-3 min-w-0 flex-wrap">
<h2 className="text-[36px] font-bold leading-none tracking-tight truncate">{heroValue || `#${instanceId}`}</h2>
<span className="text-[20px] font-semibold leading-none tabular-nums text-white/60">#{instanceId}</span>
</div>
<div className="mt-3 flex items-center gap-3 text-[12.5px] text-white/80 flex-wrap">
{model && <span><span className="text-white/55">Model</span> <span className="font-semibold text-white">{model}</span></span>}
{dv.data["created_at"] && (
<>
{model && <span className="text-white/30">·</span>}
<span><span className="text-white/55">Created</span> <span className="font-semibold text-white">{new Date(String(dv.data["created_at"])).toLocaleDateString("en-IN", { day: "2-digit", month: "short", year: "numeric" })}</span></span>
</>
)}
</div>
</div>
{/* Right column — state badge stacked over action buttons. Fills the dead space. */}
<div className="flex flex-col items-end justify-center gap-3 shrink-0">
{stateName && (
<div
className="inline-flex items-center gap-2 px-3.5 py-2 bg-white text-[12px] font-bold uppercase tracking-[-0.02em] rounded"
style={{ color: t.fg }}
>
<span className="w-2 h-2 rounded-full" style={{ background: t.dot }} />
{stateName}
</div>
)}
{actions.length > 0 && (
<div className="flex items-center gap-2 flex-wrap justify-end">
{actions.map((a) => (
<button
key={a.id}
onClick={() => onAction({ activityId: a.id, instanceId, title: a.label })}
className="h-9 px-4 text-[13px] font-semibold rounded bg-white hover:bg-[#fde2e8] transition-colors inline-flex items-center gap-2"
style={{ color: ACCENT, border: `1px solid ${ACCENT}` }}
>
{a.icon}{a.label}
</button>
))}
</div>
)}
</div>
</div>
</div>
{aiInFlight && (
<div className="rounded-none px-5 py-4 flex items-center gap-3" style={{ background: ACCENT_SOFT, border: `1px solid #fbb4be` }}>
<div className="w-9 h-9 rounded-none 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>
)}
{/* Stack of independent section cards — each its own border, no shared chrome. */}
<div className="space-y-4">
{Object.entries(groups).filter(([, fs]) => fs.length > 0).map(([key, fs], i) => (
<SectionCard key={key} index={i} sectionKey={key} fields={fs} data={dv.data!} />
))}
</div>
{audit.length > 0 && (
<div className="bg-white rounded-none 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" },
};
const SECTION_TITLE: Record<string, string> = {
customer: "Customer",
vehicle: "Vehicle",
booking: "Booking",
feedback: "Feedback",
ai: "AI · Call Outcome",
other: "Other",
};
const SECTION_EMPTY: Record<string, string> = {
customer: "No customer details captured yet.",
vehicle: "No vehicle interest recorded yet.",
booking: "No booking yet — fills in once a test drive is scheduled.",
feedback: "No feedback yet — captured automatically after the post-test-drive call.",
ai: "No AI call activity yet.",
other: "Nothing recorded here yet.",
};
function SectionCard({
index, sectionKey, fields, data,
}: {
index: number;
sectionKey: string;
fields: any[];
data: Record<string, unknown>;
}) {
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 [open, setOpen] = useState(!isEmpty);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const LONGTEXT_PREVIEW = 220;
// White header with a small red number tag — editorial, no navy weight.
const numberTag = (
<span
className="text-[11px] font-bold tabular-nums px-1.5 py-0.5"
style={{ background: ACCENT, color: "white", letterSpacing: "0.04em" }}
>
{String(index + 1).padStart(2, "0")}
</span>
);
const headerRow = (interactive: boolean) => (
<div
className={`flex items-center justify-between gap-4 px-6 py-4 ${interactive ? "cursor-pointer group" : ""}`}
style={{ borderBottom: interactive && open ? `1px solid ${TM_BORDER}` : "none" }}
onClick={interactive ? () => setOpen((o) => !o) : undefined}
>
<div className="min-w-0 flex items-center gap-3">
{numberTag}
<div className="text-[17px] font-bold tracking-tight leading-none" style={{ color: INK }}>{title}</div>
<div className="text-[11.5px] font-medium tabular-nums ml-1">
<span style={{ color: populated > 0 ? ACCENT : "#a8a29e", fontWeight: 700 }}>{populated}</span>
<span className="text-stone-300"> / </span>
<span className="text-stone-400">{fields.length}</span>
</div>
</div>
{interactive && (
<span className="shrink-0 text-stone-400 group-hover:text-stone-700 transition-colors">
{open ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</span>
)}
</div>
);
if (isEmpty) {
return (
<div className="bg-white rounded-none overflow-hidden" style={{ border: CARD_BORDER }}>
{headerRow(false)}
<div className="px-6 pb-5 text-[13px] text-stone-400 leading-relaxed" style={{ marginTop: -8 }}>
{SECTION_EMPTY[sectionKey] ?? "Nothing recorded here yet."}
</div>
</div>
);
}
return (
<div className="bg-white rounded-none overflow-hidden" style={{ border: CARD_BORDER }}>
{headerRow(true)}
{open && (
<dl className="px-6 py-2">
{fields.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 isProse = !empty && text.length > 60;
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;
const isOpen = !!expanded[f.field_key];
return (
<div
key={f.field_key}
className="flex items-baseline gap-6 py-2"
>
<dt className="w-48 shrink-0 text-[11px] font-semibold uppercase tracking-[0.03em] text-stone-500">
{fmtLabel(f.output_label)}
</dt>
<dd
className="text-[14px] break-words leading-relaxed flex-1 min-w-0"
style={{ color: empty ? "#c8c6c2" : INK, fontWeight: empty || isProse || isRecording || isUrl ? 400 : 600 }}
>
{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
)}
</dd>
</div>
);
})}
</dl>
)}
</div>
);
}