Compare commits
2 Commits
a35c7824c9
...
db5d3cb34d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db5d3cb34d | ||
|
|
6148db7902 |
@ -6,7 +6,7 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/zino.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
9
public/zino.svg
Normal file
9
public/zino.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 27 KiB |
@ -1,11 +1,12 @@
|
||||
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 DynamicHeader from "./DynamicHeader";
|
||||
import ScreenRenderer from "./ScreenRenderer";
|
||||
import { TableSkeleton } from "./Skeleton";
|
||||
import DetailViewPanel from "./DetailViewPanel";
|
||||
import FormModal from "./FormModal";
|
||||
import VariantB from "./variants/VariantB";
|
||||
import {
|
||||
getHeaderConfig, getScreen, getAuditLog, getInstance,
|
||||
type LayoutElement, type NavItem, type AuditEntry,
|
||||
@ -50,6 +51,13 @@ function stateTone(stateId: string | null) {
|
||||
}
|
||||
|
||||
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 params = useParams();
|
||||
const screenIdParam = params.screenId || null;
|
||||
|
||||
@ -3,6 +3,14 @@ import { X, ArrowRight, AlertCircle, CheckCircle2, ChevronDown } from "lucide-re
|
||||
import { getFormScreen, startWorkflow, performActivity } from "../api/viewService";
|
||||
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 {
|
||||
id: string;
|
||||
uid: string;
|
||||
@ -108,46 +116,49 @@ export default function FormModal({ activityId, instanceId, title, onClose, onSu
|
||||
|
||||
return (
|
||||
<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
|
||||
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()}
|
||||
>
|
||||
{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="w-12 h-12 rounded-full bg-emerald-50 dark:bg-emerald-950/40 flex items-center justify-center">
|
||||
<CheckCircle2 size={24} className="text-emerald-500" />
|
||||
<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-none flex items-center justify-center" style={{ background: ACCENT_SOFT }}>
|
||||
<CheckCircle2 size={24} style={{ color: ACCENT }} />
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stone-100 dark:border-zinc-800">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold text-stone-900 dark:text-zinc-100">{formTitle}</h2>
|
||||
<p className="text-[12px] text-stone-400 dark:text-zinc-500 mt-0.5">
|
||||
{instanceId ? "Update the details below" : "Fill in the details to submit"}
|
||||
</p>
|
||||
<div className="flex items-start justify-between px-7 py-5" style={{ borderBottom: `1px solid ${TM_BORDER}` }}>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.15em] mb-1.5" style={{ color: ACCENT }}>
|
||||
{instanceId ? "Update" : "Create"}
|
||||
</div>
|
||||
<h2 className="text-[20px] font-bold tracking-tight leading-tight truncate" style={{ color: INK }}>
|
||||
{instanceId ? formTitle : "New Lead"}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* ── Body ── */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||
<div className="flex-1 overflow-y-auto px-7 py-6">
|
||||
{loading ? (
|
||||
<div className="space-y-5">
|
||||
{[0, 1, 2, 3].map((r) => (
|
||||
<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-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-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 animate-pulse mb-2" /><div className="h-10 w-full bg-stone-100 animate-pulse" /></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"}`}>
|
||||
{row.map((f) => (
|
||||
<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.mandatory && <span className="text-red-400 ml-0.5">*</span>}
|
||||
{f.mandatory && <span className="ml-1" style={{ color: ACCENT }}>*</span>}
|
||||
</label>
|
||||
{renderInput(f, formData[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);
|
||||
}, 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
|
||||
</p>
|
||||
)}
|
||||
@ -179,24 +190,28 @@ export default function FormModal({ activityId, instanceId, title, onClose, onSu
|
||||
)}
|
||||
|
||||
{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">
|
||||
<AlertCircle size={15} className="shrink-0 mt-0.5" />
|
||||
<span>{error}</span>
|
||||
<div
|
||||
className="mt-5 flex items-start gap-2.5 text-[13px] px-4 py-3"
|
||||
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>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
{!loading && (
|
||||
<div className="px-6 py-4 border-t border-stone-100 dark:border-zinc-800 flex items-center justify-between">
|
||||
<div className="text-[11px] text-stone-400 dark:text-zinc-500">
|
||||
<span className="text-red-400">*</span> Required
|
||||
<div className="px-7 py-4 flex items-center justify-between" style={{ borderTop: `1px solid ${TM_BORDER}` }}>
|
||||
<div className="text-[11px] uppercase tracking-[0.05em] text-stone-400 font-semibold">
|
||||
<span style={{ color: ACCENT }}>*</span> Required
|
||||
</div>
|
||||
<div className="flex gap-2.5">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
@ -204,8 +219,8 @@ export default function FormModal({ activityId, instanceId, title, onClose, onSu
|
||||
type="submit"
|
||||
form="act-form"
|
||||
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]"
|
||||
style={{ background: "linear-gradient(135deg, #C8102E, #E84258)" }}
|
||||
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: ACCENT, borderRadius: 4 }}
|
||||
>
|
||||
{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) {
|
||||
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 ${
|
||||
hasError
|
||||
? "border-red-300 dark:border-red-700 focus:ring-red-500/20 focus:border-red-400"
|
||||
: "border-stone-200 dark:border-zinc-700 focus:ring-[#C8102E]/20 focus:border-[#C8102E] dark:focus:border-rose-500"
|
||||
}`;
|
||||
const base = `w-full h-10 px-3 text-[13.5px] bg-white text-stone-900 focus:outline-none transition placeholder:text-stone-400`;
|
||||
const style: React.CSSProperties = {
|
||||
border: `1px solid ${hasError ? ACCENT : TM_BORDER}`,
|
||||
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;
|
||||
if (options && options.length > 0) {
|
||||
@ -239,7 +262,10 @@ function renderInput(f: FormField, value: string, onChange: (v: string) => void,
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
className={`${base} appearance-none pr-9 cursor-pointer`}
|
||||
style={style}
|
||||
>
|
||||
<option value="">Select {f.name.toLowerCase()}</option>
|
||||
{options.map((opt) => (
|
||||
@ -257,38 +283,44 @@ function renderInput(f: FormField, value: string, onChange: (v: string) => void,
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
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()}`}
|
||||
/>
|
||||
);
|
||||
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":
|
||||
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":
|
||||
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": {
|
||||
const cc = f.properties?.country_code;
|
||||
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 (
|
||||
<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}
|
||||
</span>
|
||||
<input
|
||||
type="tel"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
className={`${base} pl-12`}
|
||||
style={style}
|
||||
placeholder="Phone number"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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()}`} />;
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,20 @@ import Tile from "./Tile";
|
||||
import BarChart from "./BarChart";
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -391,7 +405,16 @@ export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: P
|
||||
</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>
|
||||
<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) => {
|
||||
@ -400,6 +423,7 @@ export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: P
|
||||
<th
|
||||
key={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
|
||||
${i === 0 ? "pl-5 pr-4" : "px-4"}
|
||||
${isSorted
|
||||
@ -464,7 +488,7 @@ export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: P
|
||||
return (
|
||||
<td
|
||||
key={f.field_key}
|
||||
className={`py-3.5 whitespace-nowrap text-[13px]
|
||||
className={`py-3.5 text-[13px]
|
||||
${isFirst
|
||||
? "pl-5 pr-4 border-l-2 border-l-transparent group-hover/row:border-l-rose-500 transition-colors"
|
||||
: "px-4"
|
||||
@ -475,7 +499,13 @@ export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: P
|
||||
}
|
||||
`}
|
||||
>
|
||||
{renderCell(row[f.field_key], f.data_type, f.field_key)}
|
||||
<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)}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
|
||||
920
src/components/variants/VariantB.tsx
Normal file
920
src/components/variants/VariantB.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
333
src/hooks/usePrototypeData.ts
Normal file
333
src/hooks/usePrototypeData.ts
Normal 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());
|
||||
}
|
||||
@ -3,10 +3,9 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply antialiased text-stone-800 dark:text-zinc-100 bg-white dark:bg-zinc-950;
|
||||
html, body {
|
||||
@apply font-sans antialiased text-stone-800 bg-white;
|
||||
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
* {
|
||||
transition-property: background-color, border-color, color;
|
||||
|
||||
@ -2,7 +2,14 @@ import { useState } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useAuthContext } from "../hooks/AuthContext";
|
||||
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() {
|
||||
const { login, loading, error, clearError } = useAuthContext();
|
||||
@ -24,150 +31,151 @@ export default function Login() {
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const inputCls =
|
||||
"w-full h-11 border border-stone-200 dark:border-zinc-700 rounded-lg px-3.5 text-[13px] " +
|
||||
"bg-stone-50 dark:bg-zinc-800/60 text-stone-900 dark:text-zinc-100 " +
|
||||
"focus:outline-none focus:ring-2 focus:ring-[#C8102E]/20 focus:border-[#C8102E] " +
|
||||
"focus:bg-white dark:focus:bg-zinc-800 transition placeholder:text-stone-400 dark:placeholder:text-zinc-600";
|
||||
const inputStyle: React.CSSProperties = { border: `1px solid ${TM_BORDER}`, borderRadius: 4 };
|
||||
const onFocus = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
e.currentTarget.style.borderColor = ACCENT;
|
||||
e.currentTarget.style.boxShadow = `0 0 0 3px ${ACCENT_SOFT}`;
|
||||
};
|
||||
const onBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
e.currentTarget.style.borderColor = TM_BORDER;
|
||||
e.currentTarget.style.boxShadow = "none";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex dark:bg-zinc-950">
|
||||
{/* ── Left — branding ───────────────────────────────────────── */}
|
||||
<div className="min-h-screen flex bg-white">
|
||||
{/* ── Left — TM brand panel ───────────────────────────────────── */}
|
||||
<div
|
||||
className="hidden lg:flex lg:w-[45%] items-center justify-center p-16 relative overflow-hidden"
|
||||
style={{ background: "linear-gradient(155deg, #1a0708 0%, #3a0e16 45%, #6d0f22 80%, #2a0810 100%)" }}
|
||||
className="hidden lg:flex lg:w-[45%] flex-col justify-between p-14 relative overflow-hidden text-white"
|
||||
style={{ background: TM_NAVY }}
|
||||
>
|
||||
{/* Atmospheric orbs */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div
|
||||
className="absolute -top-40 -left-32 w-[520px] h-[520px] rounded-full opacity-40"
|
||||
style={{ background: "radial-gradient(circle, #C8102E 0%, transparent 70%)" }}
|
||||
/>
|
||||
<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={{
|
||||
backgroundImage:
|
||||
"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",
|
||||
}}
|
||||
/>
|
||||
{/* Red vertical rail — TM editorial signature (mirrors the hero on DV) */}
|
||||
<span className="absolute top-0 left-0 bottom-0 w-1.5" style={{ background: ACCENT }} />
|
||||
|
||||
{/* Diagonal red slice on the right — fills dead space, brand accent */}
|
||||
<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%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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 className="absolute top-10 left-16 z-10 flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-lg flex items-center justify-center gradient-mh shadow-md">
|
||||
<Car size={15} className="text-white" />
|
||||
{/* Headline + feature bullets */}
|
||||
<div className="relative z-10 max-w-md">
|
||||
<div className="text-[12px] font-semibold uppercase tracking-[0.15em] mb-5" style={{ color: ACCENT }}>
|
||||
AI · Test-drive scheduler
|
||||
</div>
|
||||
<span className="text-[12px] font-bold tracking-widest text-white/85 uppercase">Mahindra · Sales Ops</span>
|
||||
</div>
|
||||
|
||||
<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 className="text-[2.6rem] font-bold leading-[1.05] tracking-tight mb-10 text-white">
|
||||
Drive every lead<br />to the showroom floor.
|
||||
</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: <CalendarCheck2 size={15} />, 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: <PhoneCall size={14} />, title: "Auto-dial leads", desc: "Maya, your AI scheduler, calls customers within seconds of capture." },
|
||||
{ icon: <CalendarCheck2 size={14} />, title: "Live slot booking", desc: "Real-time availability against the dealer's calendar." },
|
||||
{ icon: <Sparkles size={14} />, title: "Feedback loop", desc: "Post-drive AI follow-up captures NPS and concerns." },
|
||||
].map((item) => (
|
||||
<div key={item.title} className="flex items-start gap-3">
|
||||
<div key={item.title} className="flex items-start gap-3.5">
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg 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" }}
|
||||
className="w-8 h-8 rounded-none flex items-center justify-center shrink-0"
|
||||
style={{ background: ACCENT, color: "white" }}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[13px] font-semibold text-white">{item.title}</div>
|
||||
<div className="text-[12px] mt-0.5 text-rose-200/65">{item.desc}</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13.5px] font-semibold text-white leading-tight">{item.title}</div>
|
||||
<div className="text-[12px] mt-1 text-white/65 leading-relaxed">{item.desc}</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 bg-white dark:bg-zinc-950 px-8">
|
||||
<div className="w-full max-w-[340px]">
|
||||
{/* ── 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 className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-6 lg:hidden">
|
||||
<div className="w-7 h-7 rounded-lg flex items-center justify-center gradient-mh shadow-md">
|
||||
<Car size={15} className="text-white" />
|
||||
</div>
|
||||
<span className="text-[12px] font-bold tracking-widest text-stone-700 dark:text-zinc-200 uppercase">
|
||||
Mahindra · Sales Ops
|
||||
</span>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.15em] mb-2" style={{ color: ACCENT }}>
|
||||
Sign in
|
||||
</div>
|
||||
<h2 className="text-[1.6rem] font-bold text-stone-900 dark:text-zinc-50 tracking-tight leading-none mb-2">
|
||||
<h2 className="text-[26px] font-bold tracking-tight leading-none mb-2" style={{ color: INK }}>
|
||||
Welcome back
|
||||
</h2>
|
||||
<p className="text-[13px] text-stone-400 dark:text-zinc-500">
|
||||
Sign in to the test-drive operations console
|
||||
<p className="text-[13px] text-stone-500">
|
||||
Sign in to the test-drive operations console.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<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
|
||||
</label>
|
||||
<input
|
||||
type="email" required autoComplete="email" autoFocus
|
||||
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>
|
||||
<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
|
||||
</label>
|
||||
<input
|
||||
type="password" required autoComplete="current-password"
|
||||
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>
|
||||
|
||||
{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}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
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"
|
||||
style={{ background: "linear-gradient(135deg, #C8102E, #E84258)" }}
|
||||
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: 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>
|
||||
</form>
|
||||
|
||||
<div className="flex items-center justify-center gap-1.5 mt-10">
|
||||
<span className="text-[11px] text-stone-400 dark:text-zinc-500">Powered by</span>
|
||||
<span className="text-[11px] font-bold text-stone-600 dark:text-zinc-400">Zino</span>
|
||||
<div className="flex items-center justify-center gap-1.5 mt-12">
|
||||
<span className="text-[11px] uppercase tracking-[0.12em] text-stone-400">Powered by</span>
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.12em]" style={{ color: INK }}>Zino</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -5,7 +5,8 @@ export default {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
sans: ['Poppins', 'Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
brand: ['Poppins', 'arial', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user