Hero phase dots, name hierarchy, lighter page title, cleaner sections
This commit is contained in:
parent
6148db7902
commit
db5d3cb34d
@ -5,11 +5,11 @@
|
||||
// table below as support. DV: full-page replace with hero card + collapsible
|
||||
// section groups.
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
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, Clock, Car, ClipboardList, PhoneOutgoing,
|
||||
CheckCircle2, LogOut, Sparkles, Car, ClipboardList, PhoneOutgoing,
|
||||
CalendarCheck2, MessageSquareHeart, Trophy, ArrowLeft, ChevronDown, ChevronUp,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
@ -66,6 +66,44 @@ const STATE_TONE: Record<string, { bg: string; fg: string; dot: string }> = {
|
||||
};
|
||||
function tone(name: string) { return STATE_TONE[name] || { bg: "#F5F5F4", fg: "#57534E", dot: "#A8A29E" }; }
|
||||
|
||||
const PHASES = ["Captured", "Scheduling", "Test drive", "Feedback"] as const;
|
||||
function phaseIndexFor(state: string | null | undefined): number {
|
||||
if (!state) return 0;
|
||||
if (state === STATE_IDS.AWAITING_FEEDBACK_CALL || state === STATE_IDS.FEEDBACK_CALL_FAILED) return 3;
|
||||
if (state === STATE_IDS.FEEDBACK_COLLECTED || state === STATE_IDS.END_STATE) return 3;
|
||||
if (state === STATE_IDS.SCHEDULED) return 2;
|
||||
if (state === STATE_IDS.AWAITING_SCHEDULER_CALL || state === STATE_IDS.SCHEDULING_CALL_FAILED) return 1;
|
||||
return 0;
|
||||
}
|
||||
function PhaseDots({ state }: { state: string | null | undefined }) {
|
||||
const current = phaseIndexFor(state);
|
||||
return (
|
||||
<div className="mt-4 flex items-center gap-2.5 flex-wrap">
|
||||
{PHASES.map((label, i) => {
|
||||
const reached = i <= current;
|
||||
return (
|
||||
<React.Fragment key={label}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={
|
||||
reached
|
||||
? { background: "#e31837" }
|
||||
: { background: "transparent", border: "2px solid rgba(255,255,255,0.4)" }
|
||||
}
|
||||
/>
|
||||
<span className={`text-[11px] font-semibold uppercase tracking-[0.08em] ${reached ? "text-white" : "text-white/50"}`}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{i < PHASES.length - 1 && <span className="w-5 h-px" style={{ background: "rgba(255,255,255,0.2)" }} />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function iconFor(label: string) {
|
||||
const k = label.toLowerCase();
|
||||
if (k.includes("lead")) return <ClipboardList size={15} />;
|
||||
@ -123,7 +161,7 @@ export default function VariantB() {
|
||||
<div className={EYEBROW_CLS} style={EYEBROW_STYLE}>
|
||||
Test-drive lifecycle
|
||||
</div>
|
||||
<h1 className="text-[40px] font-bold tracking-tight leading-[1.05] mt-2" style={{ color: INK }}>
|
||||
<h1 className="text-[24px] font-semibold tracking-tight leading-tight mt-1.5" style={{ color: INK }}>
|
||||
{activeNav?.label ?? "Dashboard"}
|
||||
</h1>
|
||||
</div>
|
||||
@ -615,44 +653,42 @@ function DetailPage({
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Breadcrumb — TM style: red 12px font-weight 700 */}
|
||||
<div className="flex items-center gap-2 text-[12px] font-bold leading-6 uppercase tracking-[-0.02em]">
|
||||
<button onClick={onBack} className="inline-flex items-center gap-1.5 transition-opacity hover:opacity-70" style={{ color: ACCENT }}>
|
||||
<ArrowLeft size={13} />{breadcrumbLabel}
|
||||
</button>
|
||||
<span className="text-stone-300">/</span>
|
||||
<span style={{ color: INK }}>Lead detail</span>
|
||||
</div>
|
||||
|
||||
{/* Hero — editorial TM band. Big red number bug (instance id), name + meta on
|
||||
the left, state badge as a solid white tablet on the right (fills the
|
||||
previously-empty space and carries the strongest signal: status). */}
|
||||
{/* 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 px-7 py-6 text-white relative overflow-hidden"
|
||||
style={{
|
||||
background: `linear-gradient(120deg, ${ACCENT} 0%, ${ACCENT_DARK} 100%)`,
|
||||
}}
|
||||
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: TM_NAVY }} />
|
||||
{/* Diagonal navy slice on the far right — TM editorial accent */}
|
||||
<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-40 pointer-events-none opacity-40"
|
||||
className="absolute top-0 right-0 bottom-0 w-56 pointer-events-none opacity-50"
|
||||
style={{
|
||||
background: `linear-gradient(115deg, transparent 0%, transparent 55%, ${TM_NAVY} 55%, ${TM_NAVY} 58%, transparent 58%, transparent 72%, ${TM_NAVY} 72%, ${TM_NAVY} 74%, transparent 74%)`,
|
||||
background: `linear-gradient(115deg, transparent 0%, transparent 55%, ${ACCENT} 55%, ${ACCENT} 58%, transparent 58%, transparent 72%, ${ACCENT} 72%, ${ACCENT} 74%, transparent 74%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative flex items-stretch justify-between gap-8 flex-wrap">
|
||||
<div className="flex items-stretch gap-5 min-w-0 flex-1">
|
||||
{/* Big number bug — instance ID as editorial signature */}
|
||||
<div className="flex flex-col justify-center pr-5 border-r border-white/15">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-white/60 mb-1">Lead ID</div>
|
||||
<div className="text-[28px] font-bold leading-none tabular-nums">#{instanceId}</div>
|
||||
{/* 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="min-w-0 flex flex-col justify-center">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.15em] mb-2" style={{ color: "#ffd9de" }}>Lead</div>
|
||||
<h2 className="text-[30px] font-bold leading-none tracking-tight truncate">{heroValue || `#${instanceId}`}</h2>
|
||||
<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"] && (
|
||||
@ -662,7 +698,7 @@ function DetailPage({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<PhaseDots state={instanceState} />
|
||||
</div>
|
||||
|
||||
{/* Right column — state badge stacked over action buttons. Fills the dead space. */}
|
||||
@ -708,26 +744,48 @@ function DetailPage({
|
||||
|
||||
{/* 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]) => (
|
||||
<SectionCard key={key} sectionKey={key} fields={fs} data={dv.data!} />
|
||||
{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-2 mb-4">
|
||||
<Clock size={14} className="text-[#e31837]" />
|
||||
<div className={EYEBROW_CLS} style={EYEBROW_STYLE}>Audit</div>
|
||||
<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>
|
||||
<div className="text-[18px] font-bold leading-tight -mt-3 mb-4" style={{ color: INK }}>Activity timeline</div>
|
||||
<ol className="space-y-3">
|
||||
{audit.map((e, i) => (
|
||||
<li key={e.id} className="flex items-start gap-3 text-[13px]">
|
||||
<span className={`mt-1 w-2 h-2 rounded-full shrink-0 ${i === audit.length - 1 ? "" : ""}`} style={{ background: i === audit.length - 1 ? ACCENT : "#D6D3D1" }} />
|
||||
<span className="text-stone-700 flex-1">{e.activity_id.slice(0, 8)}…</span>
|
||||
<span className="text-stone-400 tabular-nums shrink-0">{new Date(e.created_at).toLocaleString("en-IN", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" })}</span>
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
@ -736,6 +794,19 @@ function DetailPage({
|
||||
}
|
||||
|
||||
// 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",
|
||||
@ -755,8 +826,9 @@ const SECTION_EMPTY: Record<string, string> = {
|
||||
};
|
||||
|
||||
function SectionCard({
|
||||
sectionKey, fields, data,
|
||||
index, sectionKey, fields, data,
|
||||
}: {
|
||||
index: number;
|
||||
sectionKey: string;
|
||||
fields: any[];
|
||||
data: Record<string, unknown>;
|
||||
@ -769,18 +841,44 @@ function SectionCard({
|
||||
const isEmpty = populated === 0;
|
||||
const [open, setOpen] = useState(!isEmpty);
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className="bg-white rounded-none px-6 py-5" style={{ border: CARD_BORDER }}>
|
||||
<div className="flex items-baseline gap-3">
|
||||
<div className="text-[18px] font-bold tracking-tight leading-none" style={{ color: INK }}>{title}</div>
|
||||
<div className="text-[11.5px] font-medium tabular-nums">
|
||||
<span className="text-stone-400" style={{ fontWeight: 600 }}>0</span>
|
||||
// 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>
|
||||
<div className="mt-2 text-[13px] text-stone-400 leading-relaxed">
|
||||
{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>
|
||||
@ -788,30 +886,12 @@ function SectionCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-none" style={{ border: CARD_BORDER }}>
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="w-full flex items-center justify-between text-left gap-4 group px-6 py-4"
|
||||
style={{ borderBottom: open ? `1px solid ${TM_BORDER}` : "none" }}
|
||||
>
|
||||
<div className="min-w-0 flex items-baseline gap-3">
|
||||
<div className="text-[18px] font-bold tracking-tight leading-none" style={{ color: INK }}>
|
||||
{title}
|
||||
</div>
|
||||
<div className="text-[11.5px] font-medium tabular-nums">
|
||||
<span style={{ color: populated > 0 ? ACCENT : "#a8a29e", fontWeight: 600 }}>{populated}</span>
|
||||
<span className="text-stone-300"> / </span>
|
||||
<span className="text-stone-400">{fields.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-stone-400 group-hover:text-stone-600 transition-colors">
|
||||
{open ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
</span>
|
||||
</button>
|
||||
<div className="bg-white rounded-none overflow-hidden" style={{ border: CARD_BORDER }}>
|
||||
{headerRow(true)}
|
||||
|
||||
{open && (
|
||||
<dl className="px-6 py-2">
|
||||
{fields.map((f, i) => {
|
||||
{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);
|
||||
@ -819,8 +899,7 @@ function SectionCard({
|
||||
return (
|
||||
<div
|
||||
key={f.field_key}
|
||||
className="flex items-baseline gap-6 py-2.5"
|
||||
style={{ borderTop: i === 0 ? "none" : `1px solid #f1f1f4` }}
|
||||
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)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user