941 lines
45 KiB
TypeScript
941 lines
45 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,
|
||
} 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>
|
||
);
|
||
}
|