Compare commits

..

2 Commits

Author SHA1 Message Date
Bhanu Prakash Sai Potteri
db5d3cb34d Hero phase dots, name hierarchy, lighter page title, cleaner sections 2026-05-23 14:02:27 +05:30
Bhanu Prakash Sai Potteri
6148db7902 Rebrand to Tech Mahindra/Zino + DV cards, FormModal & Login restyle 2026-05-23 13:43:06 +05:30
10 changed files with 1479 additions and 139 deletions

View File

@ -6,7 +6,7 @@
<link rel="icon" type="image/svg+xml" href="/zino.svg" /> <link rel="icon" type="image/svg+xml" href="/zino.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Poppins:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
<title>Test Drive Lead Manager</title> <title>Test Drive Lead Manager</title>
</head> </head>
<body> <body>

9
public/zino.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -1,11 +1,12 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { Loader2, CheckCircle2, RefreshCw, Clock, Sparkles } from "lucide-react"; import { Loader2, CheckCircle2, RefreshCw, Clock, Sparkles } from "lucide-react";
import DynamicHeader from "./DynamicHeader"; import DynamicHeader from "./DynamicHeader";
import ScreenRenderer from "./ScreenRenderer"; import ScreenRenderer from "./ScreenRenderer";
import { TableSkeleton } from "./Skeleton"; import { TableSkeleton } from "./Skeleton";
import DetailViewPanel from "./DetailViewPanel"; import DetailViewPanel from "./DetailViewPanel";
import FormModal from "./FormModal"; import FormModal from "./FormModal";
import VariantB from "./variants/VariantB";
import { import {
getHeaderConfig, getScreen, getAuditLog, getInstance, getHeaderConfig, getScreen, getAuditLog, getInstance,
type LayoutElement, type NavItem, type AuditEntry, type LayoutElement, type NavItem, type AuditEntry,
@ -50,6 +51,13 @@ function stateTone(stateId: string | null) {
} }
export default function AppShell() { export default function AppShell() {
const [searchParams] = useSearchParams();
// ?variant=current escape hatch to the old red UI; default is Variant B (Insights Studio).
if (searchParams.get("variant") === "current") return <CurrentAppShell />;
return <VariantB />;
}
function CurrentAppShell() {
const navigate = useNavigate(); const navigate = useNavigate();
const params = useParams(); const params = useParams();
const screenIdParam = params.screenId || null; const screenIdParam = params.screenId || null;

View File

@ -3,6 +3,14 @@ import { X, ArrowRight, AlertCircle, CheckCircle2, ChevronDown } from "lucide-re
import { getFormScreen, startWorkflow, performActivity } from "../api/viewService"; import { getFormScreen, startWorkflow, performActivity } from "../api/viewService";
import { WORKFLOW_ID } from "../config"; import { WORKFLOW_ID } from "../config";
// TM brand tokens — mirror VariantB.tsx. Inline to avoid a one-off tokens
// module; if a third consumer appears (Login likely), extract then.
const ACCENT = "#e31837";
const ACCENT_DARK = "#5f0229";
const ACCENT_SOFT = "#fde2e8";
const TM_BORDER = "#e4e4ed";
const INK = "#17181a";
interface FormField { interface FormField {
id: string; id: string;
uid: string; uid: string;
@ -108,46 +116,49 @@ export default function FormModal({ activityId, instanceId, title, onClose, onSu
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={() => animateClose(onClose)}> <div className="fixed inset-0 z-50 flex items-center justify-center" onClick={() => animateClose(onClose)}>
<div className={`absolute inset-0 bg-stone-900/50 dark:bg-black/60 backdrop-blur-[2px] transition-opacity duration-200 ${visible ? "opacity-100" : "opacity-0"}`} /> <div className={`absolute inset-0 bg-stone-900/40 transition-opacity duration-200 ${visible ? "opacity-100" : "opacity-0"}`} />
<div <div
ref={modalRef} ref={modalRef}
className={`relative bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl shadow-stone-900/10 dark:shadow-black/50 border border-stone-200/0 dark:border-zinc-700/60 w-full max-w-xl mx-4 max-h-[90vh] flex flex-col transition-all duration-200 ${visible ? "opacity-100 scale-100 translate-y-0" : "opacity-0 scale-95 translate-y-2"}`} className={`relative bg-white rounded-none w-full max-w-xl mx-4 max-h-[90vh] flex flex-col transition-all duration-200 ${visible ? "opacity-100 scale-100 translate-y-0" : "opacity-0 scale-95 translate-y-2"}`}
style={{ border: `1px solid ${TM_BORDER}`, boxShadow: "0 24px 60px -20px rgba(6, 31, 92, 0.25)" }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{success && ( {success && (
<div className="absolute inset-0 z-10 bg-white/95 dark:bg-zinc-900/95 rounded-2xl flex flex-col items-center justify-center gap-3"> <div className="absolute inset-0 z-10 bg-white/97 flex flex-col items-center justify-center gap-3">
<div className="w-12 h-12 rounded-full bg-emerald-50 dark:bg-emerald-950/40 flex items-center justify-center"> <div className="w-12 h-12 rounded-none flex items-center justify-center" style={{ background: ACCENT_SOFT }}>
<CheckCircle2 size={24} className="text-emerald-500" /> <CheckCircle2 size={24} style={{ color: ACCENT }} />
</div> </div>
<div className="text-[14px] font-semibold text-stone-800 dark:text-zinc-100">Submitted successfully</div> <div className="text-[14px] font-bold tracking-tight" style={{ color: INK }}>Submitted</div>
</div> </div>
)} )}
{/* ── Header ── */} {/* ── Header ── */}
<div className="flex items-center justify-between px-6 py-4 border-b border-stone-100 dark:border-zinc-800"> <div className="flex items-start justify-between px-7 py-5" style={{ borderBottom: `1px solid ${TM_BORDER}` }}>
<div> <div className="min-w-0">
<h2 className="text-[15px] font-semibold text-stone-900 dark:text-zinc-100">{formTitle}</h2> <div className="text-[11px] font-semibold uppercase tracking-[0.15em] mb-1.5" style={{ color: ACCENT }}>
<p className="text-[12px] text-stone-400 dark:text-zinc-500 mt-0.5"> {instanceId ? "Update" : "Create"}
{instanceId ? "Update the details below" : "Fill in the details to submit"} </div>
</p> <h2 className="text-[20px] font-bold tracking-tight leading-tight truncate" style={{ color: INK }}>
{instanceId ? formTitle : "New Lead"}
</h2>
</div> </div>
<button <button
onClick={() => animateClose(onClose)} onClick={() => animateClose(onClose)}
className="w-8 h-8 flex items-center justify-center text-stone-400 dark:text-zinc-500 hover:text-stone-600 dark:hover:text-zinc-200 hover:bg-stone-100 dark:hover:bg-zinc-800 rounded-lg transition-colors" className="w-8 h-8 flex items-center justify-center text-stone-400 hover:text-stone-700 transition-colors shrink-0 -mr-2"
> >
<X size={16} /> <X size={18} />
</button> </button>
</div> </div>
{/* ── Body ── */} {/* ── Body ── */}
<div className="flex-1 overflow-y-auto px-6 py-5"> <div className="flex-1 overflow-y-auto px-7 py-6">
{loading ? ( {loading ? (
<div className="space-y-5"> <div className="space-y-5">
{[0, 1, 2, 3].map((r) => ( {[0, 1, 2, 3].map((r) => (
<div key={r} className="grid grid-cols-2 gap-4"> <div key={r} className="grid grid-cols-2 gap-4">
<div><div className="h-3 w-20 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse mb-2" /><div className="h-10 w-full bg-stone-100 dark:bg-zinc-800 rounded-lg animate-pulse" /></div> <div><div className="h-3 w-20 bg-stone-100 animate-pulse mb-2" /><div className="h-10 w-full bg-stone-100 animate-pulse" /></div>
<div><div className="h-3 w-24 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse mb-2" /><div className="h-10 w-full bg-stone-100 dark:bg-zinc-800 rounded-lg animate-pulse" /></div> <div><div className="h-3 w-24 bg-stone-100 animate-pulse mb-2" /><div className="h-10 w-full bg-stone-100 animate-pulse" /></div>
</div> </div>
))} ))}
</div> </div>
@ -157,9 +168,9 @@ export default function FormModal({ activityId, instanceId, title, onClose, onSu
<div key={ri} className={`grid gap-4 ${row.length > 1 ? "grid-cols-2" : "grid-cols-1"}`}> <div key={ri} className={`grid gap-4 ${row.length > 1 ? "grid-cols-2" : "grid-cols-1"}`}>
{row.map((f) => ( {row.map((f) => (
<div key={f.id}> <div key={f.id}>
<label className="block text-[12px] font-medium text-stone-500 dark:text-zinc-400 mb-1.5"> <label className="block text-[11px] font-semibold uppercase tracking-[0.03em] text-stone-500 mb-2">
{f.name} {f.name}
{f.mandatory && <span className="text-red-400 ml-0.5">*</span>} {f.mandatory && <span className="ml-1" style={{ color: ACCENT }}>*</span>}
</label> </label>
{renderInput(f, formData[f.id] ?? "", (v) => { {renderInput(f, formData[f.id] ?? "", (v) => {
setFormData((p) => ({ ...p, [f.id]: v })); setFormData((p) => ({ ...p, [f.id]: v }));
@ -167,7 +178,7 @@ export default function FormModal({ activityId, instanceId, title, onClose, onSu
if (error) setError(null); if (error) setError(null);
}, isMissing(f))} }, isMissing(f))}
{isMissing(f) && ( {isMissing(f) && (
<p className="text-[11px] text-red-500 mt-1 flex items-center gap-1"> <p className="text-[11px] mt-1.5 flex items-center gap-1" style={{ color: ACCENT }}>
<AlertCircle size={11} /> Required <AlertCircle size={11} /> Required
</p> </p>
)} )}
@ -179,24 +190,28 @@ export default function FormModal({ activityId, instanceId, title, onClose, onSu
)} )}
{error && ( {error && (
<div className="mt-4 flex items-start gap-2.5 text-[13px] text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 rounded-xl px-4 py-3 border border-red-100 dark:border-red-900"> <div
<AlertCircle size={15} className="shrink-0 mt-0.5" /> className="mt-5 flex items-start gap-2.5 text-[13px] px-4 py-3"
<span>{error}</span> style={{ background: ACCENT_SOFT, border: `1px solid #fbb4be`, color: ACCENT_DARK }}
>
<AlertCircle size={15} className="shrink-0 mt-0.5" style={{ color: ACCENT }} />
<span className="font-medium">{error}</span>
</div> </div>
)} )}
</div> </div>
{/* ── Footer ── */} {/* ── Footer ── */}
{!loading && ( {!loading && (
<div className="px-6 py-4 border-t border-stone-100 dark:border-zinc-800 flex items-center justify-between"> <div className="px-7 py-4 flex items-center justify-between" style={{ borderTop: `1px solid ${TM_BORDER}` }}>
<div className="text-[11px] text-stone-400 dark:text-zinc-500"> <div className="text-[11px] uppercase tracking-[0.05em] text-stone-400 font-semibold">
<span className="text-red-400">*</span> Required <span style={{ color: ACCENT }}>*</span> Required
</div> </div>
<div className="flex gap-2.5"> <div className="flex gap-2">
<button <button
type="button" type="button"
onClick={() => animateClose(onClose)} onClick={() => animateClose(onClose)}
className="h-9 px-4 text-[13px] font-medium text-stone-500 dark:text-zinc-400 hover:text-stone-700 dark:hover:text-zinc-200 hover:bg-stone-50 dark:hover:bg-zinc-800 rounded-xl transition-colors" className="h-9 px-4 text-[13px] font-semibold transition-colors"
style={{ color: INK, border: `1px solid ${TM_BORDER}`, borderRadius: 4, background: "white" }}
> >
Cancel Cancel
</button> </button>
@ -204,8 +219,8 @@ export default function FormModal({ activityId, instanceId, title, onClose, onSu
type="submit" type="submit"
form="act-form" form="act-form"
disabled={submitting} disabled={submitting}
className="h-9 px-5 text-[13px] font-semibold text-white disabled:opacity-50 rounded-xl transition-all flex items-center gap-1.5 mh-glow hover:scale-[1.02] active:scale-[0.98]" className="h-9 px-5 text-[13px] font-semibold text-white disabled:opacity-60 inline-flex items-center gap-2 transition-colors hover:brightness-110"
style={{ background: "linear-gradient(135deg, #C8102E, #E84258)" }} style={{ background: ACCENT, borderRadius: 4 }}
> >
{submitting ? ( {submitting ? (
<><div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Submitting</> <><div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Submitting</>
@ -226,11 +241,19 @@ export default function FormModal({ activityId, instanceId, title, onClose, onSu
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function renderInput(f: FormField, value: string, onChange: (v: string) => void, hasError: boolean) { function renderInput(f: FormField, value: string, onChange: (v: string) => void, hasError: boolean) {
const base = `w-full h-10 border rounded-xl px-3.5 text-[13px] bg-stone-50/50 dark:bg-zinc-800/70 focus:bg-white dark:focus:bg-zinc-800 text-stone-800 dark:text-zinc-100 focus:outline-none focus:ring-2 transition placeholder:text-stone-400 dark:placeholder:text-zinc-600 ${ const base = `w-full h-10 px-3 text-[13.5px] bg-white text-stone-900 focus:outline-none transition placeholder:text-stone-400`;
hasError const style: React.CSSProperties = {
? "border-red-300 dark:border-red-700 focus:ring-red-500/20 focus:border-red-400" border: `1px solid ${hasError ? ACCENT : TM_BORDER}`,
: "border-stone-200 dark:border-zinc-700 focus:ring-[#C8102E]/20 focus:border-[#C8102E] dark:focus:border-rose-500" borderRadius: 4,
}`; };
const onFocus = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
e.currentTarget.style.borderColor = hasError ? ACCENT : ACCENT;
e.currentTarget.style.boxShadow = `0 0 0 3px ${ACCENT_SOFT}`;
};
const onBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
e.currentTarget.style.borderColor = hasError ? ACCENT : TM_BORDER;
e.currentTarget.style.boxShadow = "none";
};
const options = f.properties?.options; const options = f.properties?.options;
if (options && options.length > 0) { if (options && options.length > 0) {
@ -239,7 +262,10 @@ function renderInput(f: FormField, value: string, onChange: (v: string) => void,
<select <select
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
onFocus={onFocus}
onBlur={onBlur}
className={`${base} appearance-none pr-9 cursor-pointer`} className={`${base} appearance-none pr-9 cursor-pointer`}
style={style}
> >
<option value="">Select {f.name.toLowerCase()}</option> <option value="">Select {f.name.toLowerCase()}</option>
{options.map((opt) => ( {options.map((opt) => (
@ -257,38 +283,44 @@ function renderInput(f: FormField, value: string, onChange: (v: string) => void,
<textarea <textarea
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
onFocus={onFocus}
onBlur={onBlur}
rows={3} rows={3}
className={`${base} h-auto py-2.5 rounded-xl`} className={`${base} h-auto py-2.5`}
style={style}
placeholder={`Enter ${f.name.toLowerCase()}`} placeholder={`Enter ${f.name.toLowerCase()}`}
/> />
); );
case "number": case "number":
return <input type="number" step="any" value={value} onChange={(e) => onChange(e.target.value)} className={base} placeholder="0" />; return <input type="number" step="any" value={value} onChange={(e) => onChange(e.target.value)} onFocus={onFocus} onBlur={onBlur} className={base} style={style} placeholder="0" />;
case "date": case "date":
return <input type="date" value={value} onChange={(e) => onChange(e.target.value)} className={base} />; return <input type="date" value={value} onChange={(e) => onChange(e.target.value)} onFocus={onFocus} onBlur={onBlur} className={base} style={style} />;
case "email": case "email":
return <input type="email" value={value} onChange={(e) => onChange(e.target.value)} className={base} placeholder={`Enter ${f.name.toLowerCase()}`} />; return <input type="email" value={value} onChange={(e) => onChange(e.target.value)} onFocus={onFocus} onBlur={onBlur} className={base} style={style} placeholder={`Enter ${f.name.toLowerCase()}`} />;
case "phone": { case "phone": {
const cc = f.properties?.country_code; const cc = f.properties?.country_code;
if (!cc) { if (!cc) {
return <input type="tel" value={value} onChange={(e) => onChange(e.target.value)} className={base} placeholder={`Enter ${f.name.toLowerCase()}`} />; return <input type="tel" value={value} onChange={(e) => onChange(e.target.value)} onFocus={onFocus} onBlur={onBlur} className={base} style={style} placeholder={`Enter ${f.name.toLowerCase()}`} />;
} }
return ( return (
<div className="relative"> <div className="relative">
<span className="absolute left-3.5 top-1/2 -translate-y-1/2 text-[13px] text-stone-500 dark:text-zinc-400 pointer-events-none select-none"> <span className="absolute left-3 top-1/2 -translate-y-1/2 text-[13px] font-semibold text-stone-500 pointer-events-none select-none">
{cc} {cc}
</span> </span>
<input <input
type="tel" type="tel"
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
onFocus={onFocus}
onBlur={onBlur}
className={`${base} pl-12`} className={`${base} pl-12`}
style={style}
placeholder="Phone number" placeholder="Phone number"
/> />
</div> </div>
); );
} }
default: default:
return <input type="text" value={value} onChange={(e) => onChange(e.target.value)} className={base} placeholder={`Enter ${f.name.toLowerCase()}`} />; return <input type="text" value={value} onChange={(e) => onChange(e.target.value)} onFocus={onFocus} onBlur={onBlur} className={base} style={style} placeholder={`Enter ${f.name.toLowerCase()}`} />;
} }
} }

View File

@ -10,6 +10,20 @@ import Tile from "./Tile";
import BarChart from "./BarChart"; import BarChart from "./BarChart";
import PieChart from "./PieChart"; import PieChart from "./PieChart";
// 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;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Action column (config-driven from rv-screen layout) // Action column (config-driven from rv-screen layout)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -391,7 +405,16 @@ export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: P
</div> </div>
)} )}
<table className="w-full"> <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 }} />
))}
<col style={{ width: 56 }} />
</colgroup>
<thead> <thead>
<tr className="bg-stone-50/80 dark:bg-zinc-800/40 border-b border-stone-200/80 dark:border-zinc-700/80"> <tr className="bg-stone-50/80 dark:bg-zinc-800/40 border-b border-stone-200/80 dark:border-zinc-700/80">
{fields.map((f, i) => { {fields.map((f, i) => {
@ -400,6 +423,7 @@ export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: P
<th <th
key={f.field_key} key={f.field_key}
onClick={() => handleSort(f.field_key)} onClick={() => handleSort(f.field_key)}
style={{ maxWidth: colMaxWidth(f.data_type, f.field_key) }}
className={`py-2.5 text-left text-[12px] font-semibold cursor-pointer select-none whitespace-nowrap transition-colors group/th className={`py-2.5 text-left text-[12px] font-semibold cursor-pointer select-none whitespace-nowrap transition-colors group/th
${i === 0 ? "pl-5 pr-4" : "px-4"} ${i === 0 ? "pl-5 pr-4" : "px-4"}
${isSorted ${isSorted
@ -464,7 +488,7 @@ export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: P
return ( return (
<td <td
key={f.field_key} key={f.field_key}
className={`py-3.5 whitespace-nowrap text-[13px] className={`py-3.5 text-[13px]
${isFirst ${isFirst
? "pl-5 pr-4 border-l-2 border-l-transparent group-hover/row:border-l-rose-500 transition-colors" ? "pl-5 pr-4 border-l-2 border-l-transparent group-hover/row:border-l-rose-500 transition-colors"
: "px-4" : "px-4"
@ -474,8 +498,14 @@ export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: P
: "text-stone-500 dark:text-zinc-400" : "text-stone-500 dark:text-zinc-400"
} }
`} `}
>
<div
className="truncate"
style={{ maxWidth: colMaxWidth(f.data_type, f.field_key) }}
title={typeof row[f.field_key] === "string" ? String(row[f.field_key]) : undefined}
> >
{renderCell(row[f.field_key], f.data_type, f.field_key)} {renderCell(row[f.field_key], f.data_type, f.field_key)}
</div>
</td> </td>
); );
})} })}

View File

@ -0,0 +1,920 @@
// 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,
} 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 } from "../../config";
// ---------------------------------------------------------------------------
// 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" }; }
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} />;
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 ? (
<>
<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 }}>
<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: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f5f5f4" vertical={false} />
<XAxis dataKey="name" tick={{ fontSize: 11, fill: "#78716c" }} interval={0} 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>
<PhaseDots state={instanceState} />
</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);
// 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;
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 ? 400 : 600 }}
>
{text}
</dd>
</div>
);
})}
</dl>
)}
</div>
);
}

View File

@ -0,0 +1,333 @@
// PROTOTYPE — shared data hooks for /variants/*. Sharing DATA, not UI.
// When a variant wins, the winner stays; this hook is deleted.
import { useEffect, useState, useCallback } from "react";
import {
getHeaderConfig, getScreen, getRVScreen, getRecordView,
getDVScreen, getDetailView, getInstance, getAuditLog,
type NavItem, type LayoutElement, type RecordViewField,
type SearchQuery, type TileValue, type ChartDataResponse,
type AuditEntry,
} from "../api/viewService";
import { WORKFLOW_ID } from "../config";
// ---------------------------------------------------------------------------
// rv-screen layout walkers (same logic the production RecordViewTable uses;
// duplicated here so the prototype is self-contained and the existing red
// table is untouched until a variant wins).
// ---------------------------------------------------------------------------
export interface ActionCondition {
field_key: string; operator: string; value: unknown; data_type?: string;
}
export interface ActionConditions { logic?: "AND" | "OR"; conditions: ActionCondition[]; }
export interface ActionButton {
name: string; icon?: string; classes?: string[];
job_template?: string;
params?: { activity_uid?: string; workflow_uuid?: string; [k: string]: unknown };
show_conditions?: ActionConditions | null;
}
export interface ActionColumn { id: string; display: string; buttons: ActionButton[]; }
export type AnalyticsSpec =
| { kind: "tile"; uid: string; title?: string }
| { kind: "chart"; uid: string; title?: string; chartType?: string };
function walkLayout(layout: any, visit: (node: any) => void): void {
const walk = (nodes: any[]): void => {
for (const n of nodes ?? []) {
visit(n);
if (Array.isArray(n?.children)) walk(n.children);
}
};
walk(Array.isArray(layout) ? layout : []);
}
export function extractColumnLabels(layout: any): Record<string, string> {
const out: Record<string, string> = {};
walkLayout(layout, (n) => {
const cfg = n?.config;
if (cfg?.type === "rv_table" && Array.isArray(cfg.columns)) {
for (const c of cfg.columns) {
if (c?.key_name && c?.display) out[c.key_name] = c.display;
}
}
});
return out;
}
export function extractActionColumns(layout: any): ActionColumn[] {
const out: ActionColumn[] = [];
walkLayout(layout, (n) => {
const cfg = n?.config;
if (cfg?.type === "rv_table" && Array.isArray(cfg.columns)) {
for (const col of cfg.columns) {
if (col?.type === "custom" && Array.isArray(col.config)) {
const buttons = col.config.filter((b: any) => b?.type === "button");
if (buttons.length > 0) out.push({ id: col.id, display: col.display || "Actions", buttons });
}
}
}
});
return out;
}
export function extractAnalytics(layout: any): AnalyticsSpec[] {
const out: AnalyticsSpec[] = [];
walkLayout(layout, (n) => {
const cfg = n?.config;
if (cfg?.type === "rv_tile" && cfg.tile_uid) out.push({ kind: "tile", uid: cfg.tile_uid, title: cfg.title });
if (cfg?.type === "rv_chart" && cfg.chart_uid) out.push({ kind: "chart", uid: cfg.chart_uid, title: cfg.title, chartType: cfg.chart_type });
});
return out;
}
export function evalShowConditions(c: ActionConditions | null | undefined, row: Record<string, unknown>): boolean {
if (!c || !Array.isArray(c.conditions) || c.conditions.length === 0) return true;
const results = c.conditions.map((cond) => {
const v = row[cond.field_key];
switch (cond.operator) {
case "equals": return v === cond.value;
case "not_equals": return v !== cond.value;
case "is_empty": return v == null || v === "";
case "is_not_empty": return v != null && v !== "";
case "in": return Array.isArray(cond.value) && cond.value.includes(v as never);
case "not_in": return Array.isArray(cond.value) && !cond.value.includes(v as never);
default: return false;
}
});
return (c.logic === "OR") ? results.some(Boolean) : results.every(Boolean);
}
// ---------------------------------------------------------------------------
// Pull the recordview viewId out of a screen layout (the parent screen
// embeds an rv-screen via a `recordsview` element).
// ---------------------------------------------------------------------------
export function findRecordsViewId(layout: LayoutElement[]): number | string | null {
let found: number | string | null = null;
walkLayout(layout as any, (n) => {
if (found) return;
const cfg = n?.config;
if (cfg?.type === "recordsview") found = cfg.view_uuid || cfg.view_id || null;
});
return found;
}
// ---------------------------------------------------------------------------
// Header config — nav items
// ---------------------------------------------------------------------------
export function useHeaderConfig() {
const [navItems, setNavItems] = useState<NavItem[]>([]);
const [logoText, setLogoText] = useState("Mahindra · Sales Ops");
const [ready, setReady] = useState(false);
useEffect(() => {
getHeaderConfig("desktop").then((cfg) => {
setNavItems(cfg.components?.nav?.items ?? []);
const lt = (cfg.components as any)?.logo?.text;
if (lt) setLogoText(lt);
}).catch(console.error).finally(() => setReady(true));
}, []);
return { navItems, logoText, ready };
}
// ---------------------------------------------------------------------------
// Active screen → recordview viewId
// ---------------------------------------------------------------------------
export interface ScreenButton { activityId: string; label: string; jobTemplate: string }
// Pull screen-level CTA buttons (e.g. "Add Lead") from a layout. Mirrors the
// parsing in ScreenRenderer.tsx — buttons declared at the screen level kick off
// an activity via activity_uid_init / activity_id_init.
function findScreenButtons(layout: LayoutElement[]): ScreenButton[] {
const out: ScreenButton[] = [];
walkLayout(layout as any, (n) => {
const cfg = n?.config;
if (!cfg) return;
if (cfg.type !== "button" && !cfg.job_template) return;
const aid =
cfg.params?.activity_uid_init ||
cfg.params?.activity_id_init ||
cfg.params?.activity_uid ||
cfg.params?.activity_id;
if (!aid) return;
out.push({
activityId: aid,
label: (cfg.name || "").replace(/^\+\s*/, "") || "New",
jobTemplate: cfg.job_template || "start_state_machine",
});
});
return out;
}
export function useActiveScreenRecordView(activeScreenId: string | null) {
const [viewId, setViewId] = useState<number | string | null>(null);
const [screenButtons, setScreenButtons] = useState<ScreenButton[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!activeScreenId) { setViewId(null); setScreenButtons([]); return; }
setLoading(true);
getScreen(activeScreenId)
.then((s) => {
const layout = s.layout ?? [];
setViewId(findRecordsViewId(layout));
setScreenButtons(findScreenButtons(layout));
})
.catch(() => { setViewId(null); setScreenButtons([]); })
.finally(() => setLoading(false));
}, [activeScreenId]);
return { viewId, screenButtons, loading };
}
// ---------------------------------------------------------------------------
// Full record view (rv-screen + recordview data)
// ---------------------------------------------------------------------------
export interface UseRecordViewState {
loading: boolean;
dataLoading: boolean;
error: string | null;
fields: RecordViewField[];
rows: Record<string, unknown>[];
columnLabels: Record<string, string>;
actionColumns: ActionColumn[];
analytics: AnalyticsSpec[];
tileValues: TileValue[];
chartData: ChartDataResponse[];
pagination: { page: number; limit: number; total_count: number; total_pages: number };
query: SearchQuery;
setQuery: (q: SearchQuery | ((q: SearchQuery) => SearchQuery)) => void;
refetch: () => void;
}
export function useRecordView(viewId: number | string | null): UseRecordViewState {
const [rvTemplateUid, setRvTemplateUid] = useState<string | null>(null);
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
const [actionColumns, setActionColumns] = useState<ActionColumn[]>([]);
const [analytics, setAnalytics] = useState<AnalyticsSpec[]>([]);
const [tileValues, setTileValues] = useState<TileValue[]>([]);
const [chartData, setChartData] = useState<ChartDataResponse[]>([]);
const [fields, setFields] = useState<RecordViewField[]>([]);
const [rows, setRows] = useState<Record<string, unknown>[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 25, total_count: 0, total_pages: 0 });
const [query, setQuery] = useState<SearchQuery>({ page: 1, limit: 25, sort_dir: "desc" });
const [loading, setLoading] = useState(true);
const [dataLoading, setDataLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [refreshTick, setRefreshTick] = useState(0);
useEffect(() => {
if (!viewId) return;
setLoading(true); setError(null);
setRvTemplateUid(null); setColumnLabels({}); setActionColumns([]);
setAnalytics([]); setTileValues([]); setChartData([]);
getRVScreen(viewId)
.then((rv) => {
setColumnLabels(extractColumnLabels(rv.layout));
setActionColumns(extractActionColumns(rv.layout));
setAnalytics(extractAnalytics(rv.layout));
setRvTemplateUid(rv.rv_template_uid);
})
.catch((err) => { setError(err.message); setLoading(false); });
}, [viewId]);
const fetchData = useCallback(async () => {
if (!rvTemplateUid || !viewId) return;
if (!loading) setDataLoading(true);
setError(null);
try {
const res = await getRecordView(rvTemplateUid, viewId, query);
setTileValues(res.tile_values ?? []);
setChartData(res.chart_data ?? []);
setFields(res.config.fields.filter((f) => f.field_key !== ""));
const dataRows = Array.isArray(res.data) ? res.data : [];
setRows(dataRows);
if (res.pagination) setPagination(res.pagination);
else setPagination((p) => ({ ...p, total_count: dataRows.length, total_pages: Math.ceil(dataRows.length / p.limit) || 1 }));
} catch (err: any) { setError(err.message); }
finally { setLoading(false); setDataLoading(false); }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rvTemplateUid, viewId, query, refreshTick]);
useEffect(() => { fetchData(); }, [fetchData]);
return {
loading, dataLoading, error,
fields, rows, columnLabels, actionColumns, analytics, tileValues, chartData,
pagination, query, setQuery,
refetch: () => setRefreshTick((t) => t + 1),
};
}
// ---------------------------------------------------------------------------
// Detail view
// ---------------------------------------------------------------------------
export interface DVField { field_key: string; output_label: string; data_type: string }
export function useDetailView(viewId: number | string, instanceId: string) {
const [data, setData] = useState<Record<string, unknown> | null>(null);
const [fields, setFields] = useState<DVField[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!instanceId) return;
setLoading(true); setError(null);
getDVScreen(viewId)
.then((dv) => getDetailView(dv.dv_template_uid, instanceId))
.catch(() => getDetailView(viewId, instanceId))
.then((res) => {
setFields(res.config?.fields?.filter((f: any) => f.field_key !== "") ?? []);
setData(res.data ?? {});
})
.catch((e: any) => setError(e.message))
.finally(() => setLoading(false));
}, [viewId, instanceId]);
return { data, fields, loading, error };
}
// ---------------------------------------------------------------------------
// Instance + audit (for state badge / action menu / timeline)
// ---------------------------------------------------------------------------
export function useInstanceMeta(instanceId: string | null, refreshKey: number = 0) {
const [instanceState, setInstanceState] = useState<string | null>(null);
const [audit, setAudit] = useState<AuditEntry[]>([]);
useEffect(() => {
if (!instanceId) { setInstanceState(null); setAudit([]); return; }
getInstance(WORKFLOW_ID, instanceId).then((i) => setInstanceState(i.current_state_id)).catch(() => {});
getAuditLog(instanceId).then(setAudit).catch(() => setAudit([]));
}, [instanceId, refreshKey]);
return { instanceState, audit };
}
// ---------------------------------------------------------------------------
// Cell formatters — shared utilities (not UI)
// ---------------------------------------------------------------------------
export function fmtCellText(value: unknown, dataType: string, fieldKey: string): string {
if (value == null || value === "") return "—";
if (dataType === "phone" && typeof value === "object") {
const p = value as { phone_with_dial_code?: string; phone?: string; dial_code?: string };
return p.phone_with_dial_code || (p.dial_code && p.phone ? `${p.dial_code}${p.phone}` : p.phone || "—");
}
if (dataType === "number") {
const n = Number(value);
if (["amount", "tax", "price", "cost"].some((x) => fieldKey.toLowerCase().includes(x)))
return n.toLocaleString("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 0 });
return n.toLocaleString("en-IN");
}
if (dataType === "datetime" || dataType === "date") {
try { return new Date(String(value)).toLocaleDateString("en-IN", { day: "2-digit", month: "short", year: "numeric" }); }
catch { return String(value); }
}
return String(value);
}
export function fmtLabel(label: string): string {
return label.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}

View File

@ -3,10 +3,9 @@
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
body { html, body {
@apply antialiased text-stone-800 dark:text-zinc-100 bg-white dark:bg-zinc-950; @apply font-sans antialiased text-stone-800 bg-white;
font-feature-settings: "cv02", "cv03", "cv04", "cv11"; font-feature-settings: "cv02", "cv03", "cv04", "cv11";
transition: background-color 0.2s ease, color 0.2s ease;
} }
* { * {
transition-property: background-color, border-color, color; transition-property: background-color, border-color, color;

View File

@ -2,7 +2,14 @@ import { useState } from "react";
import { useNavigate, useLocation } from "react-router-dom"; import { useNavigate, useLocation } from "react-router-dom";
import { useAuthContext } from "../hooks/AuthContext"; import { useAuthContext } from "../hooks/AuthContext";
import { ORG_ID } from "../config"; import { ORG_ID } from "../config";
import { PhoneCall, CalendarCheck2, Sparkles, ArrowRight, Car } from "lucide-react"; import { PhoneCall, CalendarCheck2, Sparkles, ArrowRight } from "lucide-react";
// TM brand tokens — mirror VariantB.tsx / FormModal.tsx.
const ACCENT = "#e31837";
const ACCENT_SOFT = "#fde2e8";
const TM_NAVY = "#061f5c";
const TM_BORDER = "#e4e4ed";
const INK = "#17181a";
export default function Login() { export default function Login() {
const { login, loading, error, clearError } = useAuthContext(); const { login, loading, error, clearError } = useAuthContext();
@ -24,150 +31,151 @@ export default function Login() {
} catch {} } catch {}
}; };
const inputCls = const inputStyle: React.CSSProperties = { border: `1px solid ${TM_BORDER}`, borderRadius: 4 };
"w-full h-11 border border-stone-200 dark:border-zinc-700 rounded-lg px-3.5 text-[13px] " + const onFocus = (e: React.FocusEvent<HTMLInputElement>) => {
"bg-stone-50 dark:bg-zinc-800/60 text-stone-900 dark:text-zinc-100 " + e.currentTarget.style.borderColor = ACCENT;
"focus:outline-none focus:ring-2 focus:ring-[#C8102E]/20 focus:border-[#C8102E] " + e.currentTarget.style.boxShadow = `0 0 0 3px ${ACCENT_SOFT}`;
"focus:bg-white dark:focus:bg-zinc-800 transition placeholder:text-stone-400 dark:placeholder:text-zinc-600"; };
const onBlur = (e: React.FocusEvent<HTMLInputElement>) => {
e.currentTarget.style.borderColor = TM_BORDER;
e.currentTarget.style.boxShadow = "none";
};
return ( return (
<div className="min-h-screen flex dark:bg-zinc-950"> <div className="min-h-screen flex bg-white">
{/* ── Left — branding ───────────────────────────────────────── */} {/* ── Left — TM brand panel ───────────────────────────────────── */}
<div <div
className="hidden lg:flex lg:w-[45%] items-center justify-center p-16 relative overflow-hidden" className="hidden lg:flex lg:w-[45%] flex-col justify-between p-14 relative overflow-hidden text-white"
style={{ background: "linear-gradient(155deg, #1a0708 0%, #3a0e16 45%, #6d0f22 80%, #2a0810 100%)" }} style={{ background: TM_NAVY }}
> >
{/* Atmospheric orbs */} {/* Red vertical rail — TM editorial signature (mirrors the hero on DV) */}
<div className="absolute inset-0 overflow-hidden pointer-events-none"> <span className="absolute top-0 left-0 bottom-0 w-1.5" style={{ background: ACCENT }} />
<div
className="absolute -top-40 -left-32 w-[520px] h-[520px] rounded-full opacity-40" {/* Diagonal red slice on the right — fills dead space, brand accent */}
style={{ background: "radial-gradient(circle, #C8102E 0%, transparent 70%)" }} <span
/> className="absolute top-0 right-0 bottom-0 w-56 pointer-events-none opacity-50"
<div
className="absolute -bottom-24 -right-20 w-96 h-96 rounded-full opacity-25"
style={{ background: "radial-gradient(circle, #E84258 0%, transparent 70%)" }}
/>
<div
className="absolute inset-0 opacity-[0.05]"
style={{ style={{
backgroundImage: background: `linear-gradient(115deg, transparent 0%, transparent 55%, ${ACCENT} 55%, ${ACCENT} 58%, transparent 58%, transparent 72%, ${ACCENT} 72%, ${ACCENT} 74%, transparent 74%)`,
"linear-gradient(rgba(255,255,255,0.6) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.6) 1px, transparent 1px)",
backgroundSize: "44px 44px",
}} }}
/> />
{/* Brand lockup — zino mark + Sales Ops subtitle */}
<div className="relative z-10 leading-tight">
<img src={`${import.meta.env.BASE_URL}zino.svg`} alt="Zino" className="h-7 w-auto" />
<div className="text-[10.5px] uppercase tracking-[0.18em] text-white/60 mt-2">Sales Ops</div>
</div> </div>
<div className="absolute top-10 left-16 z-10 flex items-center gap-2.5"> {/* Headline + feature bullets */}
<div className="w-7 h-7 rounded-lg flex items-center justify-center gradient-mh shadow-md"> <div className="relative z-10 max-w-md">
<Car size={15} className="text-white" /> <div className="text-[12px] font-semibold uppercase tracking-[0.15em] mb-5" style={{ color: ACCENT }}>
AI · Test-drive scheduler
</div> </div>
<span className="text-[12px] font-bold tracking-widest text-white/85 uppercase">Mahindra · Sales Ops</span> <h1 className="text-[2.6rem] font-bold leading-[1.05] tracking-tight mb-10 text-white">
</div> Drive every lead<br />to the showroom floor.
<div className="relative z-10 max-w-lg">
<div
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium mb-8 border"
style={{ background: "rgba(200,16,46,0.18)", borderColor: "rgba(232,66,88,0.35)", color: "#fcb6c0" }}
>
<Sparkles size={13} />
AI-powered test drive scheduler
</div>
<h1 className="text-[2.6rem] font-bold text-white leading-[1.05] tracking-tight mb-8">
Drive every lead
<br />
<span
className="text-transparent bg-clip-text"
style={{ backgroundImage: "linear-gradient(135deg, #fda4af, #f9a8d4, #fbbf24)" }}
>
to the showroom floor.
</span>
</h1> </h1>
<div className="space-y-4"> <div className="space-y-5">
{[ {[
{ icon: <PhoneCall size={15} />, title: "Auto-dial leads", desc: "Maya, your AI scheduler, calls customers within seconds of capture" }, { icon: <PhoneCall size={14} />, title: "Auto-dial leads", desc: "Maya, your AI scheduler, calls customers within seconds of capture." },
{ icon: <CalendarCheck2 size={15} />, title: "Live slot booking", desc: "Real-time availability against the dealer's calendar" }, { icon: <CalendarCheck2 size={14} />, title: "Live slot booking", desc: "Real-time availability against the dealer's calendar." },
{ icon: <Sparkles size={15} />, title: "Feedback loop", desc: "Post-drive AI follow-up captures NPS and concerns" }, { icon: <Sparkles size={14} />, title: "Feedback loop", desc: "Post-drive AI follow-up captures NPS and concerns." },
].map((item) => ( ].map((item) => (
<div key={item.title} className="flex items-start gap-3"> <div key={item.title} className="flex items-start gap-3.5">
<div <div
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0" className="w-8 h-8 rounded-none flex items-center justify-center shrink-0"
style={{ background: "rgba(200,16,46,0.28)", border: "1px solid rgba(232,66,88,0.30)", color: "#fda4af" }} style={{ background: ACCENT, color: "white" }}
> >
{item.icon} {item.icon}
</div> </div>
<div> <div className="min-w-0">
<div className="text-[13px] font-semibold text-white">{item.title}</div> <div className="text-[13.5px] font-semibold text-white leading-tight">{item.title}</div>
<div className="text-[12px] mt-0.5 text-rose-200/65">{item.desc}</div> <div className="text-[12px] mt-1 text-white/65 leading-relaxed">{item.desc}</div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
</div> </div>
{/* Footer rule */}
<div className="relative z-10 text-[11px] uppercase tracking-[0.18em] text-white/40">
© Zino
</div>
</div>
{/* ── Right — login form ──────────────────────────────────────── */}
<div className="flex-1 flex items-center justify-center px-8" style={{ background: "white" }}>
<div className="w-full max-w-[360px]">
{/* Compact lockup for mobile (left panel hidden) */}
<div className="flex items-center mb-10 lg:hidden">
<img src={`${import.meta.env.BASE_URL}zino.svg`} alt="Zino" className="h-6 w-auto" />
</div> </div>
{/* ── Right — login form ──────────────────────────────────── */}
<div className="flex-1 flex items-center justify-center bg-white dark:bg-zinc-950 px-8">
<div className="w-full max-w-[340px]">
<div className="mb-8"> <div className="mb-8">
<div className="flex items-center gap-2 mb-6 lg:hidden"> <div className="text-[11px] font-semibold uppercase tracking-[0.15em] mb-2" style={{ color: ACCENT }}>
<div className="w-7 h-7 rounded-lg flex items-center justify-center gradient-mh shadow-md"> Sign in
<Car size={15} className="text-white" />
</div> </div>
<span className="text-[12px] font-bold tracking-widest text-stone-700 dark:text-zinc-200 uppercase"> <h2 className="text-[26px] font-bold tracking-tight leading-none mb-2" style={{ color: INK }}>
Mahindra · Sales Ops
</span>
</div>
<h2 className="text-[1.6rem] font-bold text-stone-900 dark:text-zinc-50 tracking-tight leading-none mb-2">
Welcome back Welcome back
</h2> </h2>
<p className="text-[13px] text-stone-400 dark:text-zinc-500"> <p className="text-[13px] text-stone-500">
Sign in to the test-drive operations console Sign in to the test-drive operations console.
</p> </p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-5">
<div> <div>
<label className="block text-[12px] font-medium text-stone-600 dark:text-zinc-400 mb-1.5 uppercase tracking-wide"> <label className="block text-[11px] font-semibold uppercase tracking-[0.03em] text-stone-500 mb-2">
Email Email
</label> </label>
<input <input
type="email" required autoComplete="email" autoFocus type="email" required autoComplete="email" autoFocus
value={email} onChange={(e) => { setEmail(e.target.value); clearError(); }} value={email} onChange={(e) => { setEmail(e.target.value); clearError(); }}
className={inputCls} placeholder="you@mahindra.in" onFocus={onFocus} onBlur={onBlur}
className="w-full h-11 px-3.5 text-[13.5px] bg-white text-stone-900 focus:outline-none transition placeholder:text-stone-400"
style={inputStyle}
placeholder="you@techmahindra.com"
/> />
</div> </div>
<div> <div>
<label className="block text-[12px] font-medium text-stone-600 dark:text-zinc-400 mb-1.5 uppercase tracking-wide"> <label className="block text-[11px] font-semibold uppercase tracking-[0.03em] text-stone-500 mb-2">
Password Password
</label> </label>
<input <input
type="password" required autoComplete="current-password" type="password" required autoComplete="current-password"
value={password} onChange={(e) => { setPassword(e.target.value); clearError(); }} value={password} onChange={(e) => { setPassword(e.target.value); clearError(); }}
className={inputCls} placeholder="••••••••" onFocus={onFocus} onBlur={onBlur}
className="w-full h-11 px-3.5 text-[13.5px] bg-white text-stone-900 focus:outline-none transition placeholder:text-stone-400"
style={inputStyle}
placeholder="••••••••"
/> />
</div> </div>
{error && ( {error && (
<p className="text-[12px] text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 rounded-lg px-3 py-2.5 border border-red-100 dark:border-red-900/60"> <div
className="text-[12.5px] px-3.5 py-2.5 font-medium"
style={{ background: ACCENT_SOFT, border: `1px solid #fbb4be`, color: "#5f0229", borderRadius: 4 }}
>
{error} {error}
</p> </div>
)} )}
<button <button
type="submit" disabled={loading} type="submit" disabled={loading}
className="w-full h-11 mt-1 disabled:opacity-50 text-white text-[13px] font-semibold rounded-lg transition-all flex items-center justify-center gap-2 hover:opacity-95 active:scale-[0.99] mh-glow" className="w-full h-11 mt-2 disabled:opacity-60 text-white text-[13.5px] font-semibold inline-flex items-center justify-center gap-2 transition-colors hover:brightness-110"
style={{ background: "linear-gradient(135deg, #C8102E, #E84258)" }} style={{ background: ACCENT, borderRadius: 4 }}
> >
{loading ? "Signing in…" : <>Sign in <ArrowRight size={14} /></>} {loading ? (
<><div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Signing in</>
) : (
<>Sign in <ArrowRight size={14} /></>
)}
</button> </button>
</form> </form>
<div className="flex items-center justify-center gap-1.5 mt-10"> <div className="flex items-center justify-center gap-1.5 mt-12">
<span className="text-[11px] text-stone-400 dark:text-zinc-500">Powered by</span> <span className="text-[11px] uppercase tracking-[0.12em] text-stone-400">Powered by</span>
<span className="text-[11px] font-bold text-stone-600 dark:text-zinc-400">Zino</span> <span className="text-[11px] font-bold uppercase tracking-[0.12em]" style={{ color: INK }}>Zino</span>
</div> </div>
</div> </div>
</div> </div>

View File

@ -5,7 +5,8 @@ export default {
theme: { theme: {
extend: { extend: {
fontFamily: { fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'], sans: ['Poppins', 'Inter', 'system-ui', '-apple-system', 'sans-serif'],
brand: ['Poppins', 'arial', 'sans-serif'],
}, },
}, },
}, },