1305 lines
63 KiB
TypeScript
1305 lines
63 KiB
TypeScript
// 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>
|
||
);
|
||
}
|