DV revamp: unified card, sub-section grid, chips, at-a-glance lane

- Merge hero + sections into one bordered card with hairline divider
- Avatar + icon-led metadata; status pill moves to right cluster
- Sub-sections in left/right lanes with vertical+horizontal dividers
- Status/ID/recommend chips; longtext labels and prose styling cleaned up
- Empty-state placeholders per section with icons
- RV: lift recording URLs into narrow lane "At a Glance" with lead age
- Calendar/header/form/login: misc session-2 polish (per handoff)
This commit is contained in:
Bhanu Prakash Sai Potteri 2026-05-23 21:54:34 +05:30
parent 8d7b965418
commit 7830127e96
6 changed files with 664 additions and 331 deletions

View File

@ -38,7 +38,7 @@ export default function DynamicHeader({ activeScreenId, onNavigate, onSidebarCha
}, []); }, []);
const navItems = config?.components?.nav?.items ?? []; const navItems = config?.components?.nav?.items ?? [];
const logoText = (config?.components as any)?.logo?.text || "Mahindra · Sales Ops"; const logoText = (config?.components as any)?.logo?.text || "Mahindra · AI Sales Ops";
const handleLogout = () => { logout(); navigate("/login"); }; const handleLogout = () => { logout(); navigate("/login"); };

View File

@ -123,13 +123,13 @@ export default function FormModal({ activityId, instanceId, title, onClose, onSu
<div <div
ref={modalRef} ref={modalRef}
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"}`} className={`relative bg-white rounded-2xl 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)" }} 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/97 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-none flex items-center justify-center" style={{ background: ACCENT_SOFT }}> <div className="w-12 h-12 rounded-xl flex items-center justify-center" style={{ background: ACCENT_SOFT }}>
<CheckCircle2 size={24} style={{ color: ACCENT }} /> <CheckCircle2 size={24} style={{ color: ACCENT }} />
</div> </div>
<div className="text-[14px] font-bold tracking-tight" style={{ color: INK }}>Submitted</div> <div className="text-[14px] font-bold tracking-tight" style={{ color: INK }}>Submitted</div>

View File

@ -299,7 +299,7 @@ export function CalendarView() {
const cell = grid.get(`${dk}|${tk}|${Number(c.id)}`); const cell = grid.get(`${dk}|${tk}|${Number(c.id)}`);
if (!cell) { if (!cell) {
return ( return (
<div key={c.id} className="h-7 px-2 flex items-center text-[10.5px] text-stone-300" style={{ border: `1px dashed ${TM_BORDER}` }}> <div key={c.id} className="h-7 px-2 flex items-center text-[10.5px] text-stone-400" style={{ border: `1px solid ${TM_BORDER}` }}>
</div> </div>
); );
@ -313,7 +313,7 @@ export function CalendarView() {
style={ style={
booked booked
? { background: ACCENT, color: "white" } ? { background: ACCENT, color: "white" }
: { background: "white", color: "#a8a29e", border: `1px solid ${TM_BORDER}` } : { background: "white", color: "#78716c", border: `1px solid ${TM_BORDER}` }
} }
title={booked ? `${cell.customer_name ?? "Booked"} · ${c.model}` : `Available · ${c.model}`} title={booked ? `${cell.customer_name ?? "Booked"} · ${c.model}` : `Available · ${c.model}`}
> >
@ -346,6 +346,12 @@ export function CalendarView() {
function BookingPanel({ drive, car, onClose }: { drive: TestDrive | null; car: Car | undefined; onClose: () => void }) { function BookingPanel({ drive, car, onClose }: { drive: TestDrive | null; car: Car | undefined; onClose: () => void }) {
const open = !!drive; const open = !!drive;
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
return ( return (
<> <>
<div <div
@ -368,26 +374,21 @@ function BookingPanel({ drive, car, onClose }: { drive: TestDrive | null; car: C
function BookingPanelBody({ drive, car, onClose }: { drive: TestDrive; car: Car | undefined; onClose: () => void }) { function BookingPanelBody({ drive, car, onClose }: { drive: TestDrive; car: Car | undefined; onClose: () => void }) {
const day = fmtDayLabel(istDayKey(drive.slot_start)); const day = fmtDayLabel(istDayKey(drive.slot_start));
const time = `${istTimeKey(drive.slot_start)} ${istTimeKey(drive.slot_end)} IST`; const time = `${istTimeKey(drive.slot_start)} ${istTimeKey(drive.slot_end)} IST`;
const hasFeedback = drive.feedback_rating != null || !!drive.feedback_liked_most || !!drive.feedback_concern || drive.feedback_would_recommend != null;
const completed = !!drive.completed_at;
return ( return (
<> <>
<div className="relative px-6 py-5 text-white shrink-0" style={{ background: TM_NAVY }}> <div className="relative px-6 py-5 text-white shrink-0" style={{ background: TM_NAVY }}>
<span className="absolute top-0 left-0 bottom-0 w-1.5" style={{ background: ACCENT }} /> <span className="absolute top-0 left-0 bottom-0 w-1.5" style={{ background: ACCENT }} />
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
<div className="text-[11px] font-semibold uppercase tracking-[0.15em]" style={{ color: ACCENT_SOFT }}> <div className="text-[11px] font-semibold uppercase tracking-[0.15em] inline-flex items-center gap-1.5" style={{ color: ACCENT_SOFT }}>
Booking <CalendarIcon size={11} /> Booking
</div> </div>
<div className="text-[22px] font-bold leading-tight mt-1"> <div className="text-[22px] font-bold leading-tight mt-1 tabular-nums">
{day.weekday}, {day.day} {day.month} · {time}
</div>
<div className="mt-2 text-[13px] text-white/85">
{drive.customer_name ?? "—"} {drive.customer_name ?? "—"}
</div> </div>
<div className="mt-2 text-[12.5px] text-white/80 flex items-center gap-2 flex-wrap">
<CalendarIcon size={13} />
<span className="font-semibold">{day.weekday} {day.day} {day.month}</span>
<span className="text-white/40">·</span>
<span className="tabular-nums">{time}</span>
</div>
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
@ -422,10 +423,6 @@ function BookingPanelBody({ drive, car, onClose }: { drive: TestDrive; car: Car
)} )}
<PanelSection label="Vehicle"> <PanelSection label="Vehicle">
<Row label="Model">
<span className="font-semibold" style={{ color: INK }}>{car?.model ?? "—"}</span>
</Row>
<Row label="Variant">{car?.variant || "—"}</Row>
<Row label="Color"> <Row label="Color">
{car?.color ? ( {car?.color ? (
<span className="inline-flex items-center gap-2"> <span className="inline-flex items-center gap-2">
@ -445,62 +442,25 @@ function BookingPanelBody({ drive, car, onClose }: { drive: TestDrive; car: Car
</Row> </Row>
<Row label="Phone"> <Row label="Phone">
{drive.customer_phone ? ( {drive.customer_phone ? (
<a href={`tel:${drive.customer_phone}`} className="inline-flex items-center gap-1.5 font-semibold" style={{ color: ACCENT }}> <a href={`tel:${drive.customer_phone}`} className="inline-flex items-center gap-1.5 font-semibold text-stone-700 hover:text-[#e31837] transition-colors tabular-nums">
<Phone size={12} /> <Phone size={12} className="text-stone-400" />
{drive.customer_phone} {drive.customer_phone}
</a> </a>
) : "—"} ) : "—"}
</Row> </Row>
</PanelSection> </PanelSection>
<PanelSection label="Booking"> <PanelSection label="Dealer calendar">
<Row label="Status">
<StatusPill status={completed ? "Completed" : "Booked"} tone={completed ? "completed" : "booked"} />
</Row>
<Row label="Slot"> <Row label="Slot">
<span className="font-semibold tabular-nums" style={{ color: INK }}>{day.weekday} {day.day} {day.month}, {time}</span> <span className="font-semibold tabular-nums" style={{ color: INK }}>{day.weekday} {day.day} {day.month}, {time}</span>
</Row> </Row>
<Row label="Booked at"> {drive.booked_at && (
{drive.booked_at ? new Date(drive.booked_at).toLocaleString("en-IN", { dateStyle: "medium", timeStyle: "short" }) : "—"} <Row label="Reserved at">
</Row> {new Date(drive.booked_at).toLocaleString("en-IN", { dateStyle: "medium", timeStyle: "short" })}
{completed && (
<Row label="Completed at">
{new Date(drive.completed_at!).toLocaleString("en-IN", { dateStyle: "medium", timeStyle: "short" })}
</Row> </Row>
)} )}
{drive.notes && <Row label="Notes">{drive.notes}</Row>} {drive.notes && <Row label="Notes">{drive.notes}</Row>}
</PanelSection> </PanelSection>
<PanelSection label="Feedback">
{hasFeedback ? (
<>
{drive.feedback_rating != null && (
<Row label="Rating">
<RatingStars value={Number(drive.feedback_rating)} />
</Row>
)}
{drive.feedback_would_recommend != null && (
<Row label="Recommends">
<StatusPill
status={drive.feedback_would_recommend ? "Yes" : "No"}
tone={drive.feedback_would_recommend ? "completed" : "failed"}
/>
</Row>
)}
{drive.feedback_liked_most && <Row label="Liked most">{drive.feedback_liked_most}</Row>}
{drive.feedback_concern && <Row label="Concern">{drive.feedback_concern}</Row>}
{drive.feedback_collected_at && (
<Row label="Collected at">
{new Date(drive.feedback_collected_at).toLocaleString("en-IN", { dateStyle: "medium", timeStyle: "short" })}
</Row>
)}
</>
) : (
<div className="text-[12.5px] text-stone-400 italic px-1">
{completed ? "No feedback captured yet." : "Feedback will appear here after the drive is completed."}
</div>
)}
</PanelSection>
</div> </div>
</> </>
); );
@ -530,18 +490,6 @@ function StatusPill({ status, tone }: { status: string; tone: "booked" | "comple
); );
} }
function RatingStars({ value }: { value: number }) {
const v = Math.max(0, Math.min(5, Math.round(value)));
return (
<span className="inline-flex items-center gap-1.5">
<span className="text-[14px] tabular-nums" style={{ color: ACCENT, letterSpacing: 1 }}>
{"★".repeat(v)}<span className="text-stone-300">{"★".repeat(5 - v)}</span>
</span>
<span className="text-[11px] font-semibold tabular-nums text-stone-500">{v}/5</span>
</span>
);
}
function colorSwatch(color: string): string { function colorSwatch(color: string): string {
const c = color.toLowerCase(); const c = color.toLowerCase();
if (c.includes("red") || c.includes("tango")) return "#c62828"; if (c.includes("red") || c.includes("tango")) return "#c62828";

File diff suppressed because it is too large Load Diff

View File

@ -49,7 +49,23 @@ export function extractColumnLabels(layout: any): Record<string, string> {
const cfg = n?.config; const cfg = n?.config;
if (cfg?.type === "rv_table" && Array.isArray(cfg.columns)) { if (cfg?.type === "rv_table" && Array.isArray(cfg.columns)) {
for (const c of cfg.columns) { for (const c of cfg.columns) {
if (c?.key_name && c?.display) out[c.key_name] = c.display; if (c?.key_name) {
const label = c.custom_label || c.display;
if (label) out[c.key_name] = label;
}
}
}
});
return out;
}
export function extractColumnKeys(layout: any): string[] {
const out: 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?.type !== "custom") out.push(c.key_name);
} }
} }
}); });
@ -120,7 +136,7 @@ export function findRecordsViewId(layout: LayoutElement[]): number | string | nu
export function useHeaderConfig() { export function useHeaderConfig() {
const [navItems, setNavItems] = useState<NavItem[]>([]); const [navItems, setNavItems] = useState<NavItem[]>([]);
const [logoText, setLogoText] = useState("Mahindra · Sales Ops"); const [logoText, setLogoText] = useState("Mahindra · AI Sales Ops");
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
useEffect(() => { useEffect(() => {
getHeaderConfig("desktop").then((cfg) => { getHeaderConfig("desktop").then((cfg) => {
@ -204,6 +220,7 @@ export interface UseRecordViewState {
export function useRecordView(viewId: number | string | null): UseRecordViewState { export function useRecordView(viewId: number | string | null): UseRecordViewState {
const [rvTemplateUid, setRvTemplateUid] = useState<string | null>(null); const [rvTemplateUid, setRvTemplateUid] = useState<string | null>(null);
const [layoutColumnKeys, setLayoutColumnKeys] = useState<string[] | null>(null);
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({}); const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
const [actionColumns, setActionColumns] = useState<ActionColumn[]>([]); const [actionColumns, setActionColumns] = useState<ActionColumn[]>([]);
const [analytics, setAnalytics] = useState<AnalyticsSpec[]>([]); const [analytics, setAnalytics] = useState<AnalyticsSpec[]>([]);
@ -221,11 +238,12 @@ export function useRecordView(viewId: number | string | null): UseRecordViewStat
useEffect(() => { useEffect(() => {
if (!viewId) return; if (!viewId) return;
setLoading(true); setError(null); setLoading(true); setError(null);
setRvTemplateUid(null); setColumnLabels({}); setActionColumns([]); setRvTemplateUid(null); setLayoutColumnKeys(null); setColumnLabels({}); setActionColumns([]);
setAnalytics([]); setTileValues([]); setChartData([]); setAnalytics([]); setTileValues([]); setChartData([]);
getRVScreen(viewId) getRVScreen(viewId)
.then((rv) => { .then((rv) => {
setColumnLabels(extractColumnLabels(rv.layout)); setColumnLabels(extractColumnLabels(rv.layout));
setLayoutColumnKeys(extractColumnKeys(rv.layout));
setActionColumns(extractActionColumns(rv.layout)); setActionColumns(extractActionColumns(rv.layout));
setAnalytics(extractAnalytics(rv.layout)); setAnalytics(extractAnalytics(rv.layout));
setRvTemplateUid(rv.rv_template_uid); setRvTemplateUid(rv.rv_template_uid);
@ -242,7 +260,14 @@ export function useRecordView(viewId: number | string | null): UseRecordViewStat
setTileValues(res.tile_values ?? []); setTileValues(res.tile_values ?? []);
setChartData(res.chart_data ?? []); setChartData(res.chart_data ?? []);
const hidden = new Set(RV_HIDDEN_COLUMNS[rvTemplateUid] ?? []); const hidden = new Set(RV_HIDDEN_COLUMNS[rvTemplateUid] ?? []);
setFields(res.config.fields.filter((f) => f.field_key !== "" && !hidden.has(f.field_key))); const picked = res.config.fields.filter((f) => f.field_key !== "" && !hidden.has(f.field_key));
// If the screen layout picks an explicit column set, use it as the authoritative list + order.
if (layoutColumnKeys && layoutColumnKeys.length > 0) {
const byKey = new Map(picked.map((f) => [f.field_key, f]));
setFields(layoutColumnKeys.map((k) => byKey.get(k)).filter((f): f is RecordViewField => !!f));
} else {
setFields(picked);
}
const dataRows = Array.isArray(res.data) ? res.data : []; const dataRows = Array.isArray(res.data) ? res.data : [];
setRows(dataRows); setRows(dataRows);
if (res.pagination) setPagination(res.pagination); if (res.pagination) setPagination(res.pagination);
@ -250,7 +275,7 @@ export function useRecordView(viewId: number | string | null): UseRecordViewStat
} catch (err: any) { setError(err.message); } } catch (err: any) { setError(err.message); }
finally { setLoading(false); setDataLoading(false); } finally { setLoading(false); setDataLoading(false); }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [rvTemplateUid, viewId, query, refreshTick]); }, [rvTemplateUid, viewId, query, refreshTick, layoutColumnKeys]);
useEffect(() => { fetchData(); }, [fetchData]); useEffect(() => { fetchData(); }, [fetchData]);

View File

@ -62,7 +62,7 @@ export default function Login() {
{/* Brand lockup — zino mark + Sales Ops subtitle */} {/* Brand lockup — zino mark + Sales Ops subtitle */}
<div className="relative z-10 leading-tight"> <div className="relative z-10 leading-tight">
<img src={`${import.meta.env.BASE_URL}zino.svg`} alt="Zino" className="h-7 w-auto" /> <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 className="text-[10.5px] uppercase tracking-[0.18em] text-white/60 mt-2">AI Sales Ops</div>
</div> </div>
{/* Headline + feature bullets */} {/* Headline + feature bullets */}
@ -82,7 +82,7 @@ export default function Login() {
].map((item) => ( ].map((item) => (
<div key={item.title} className="flex items-start gap-3.5"> <div key={item.title} className="flex items-start gap-3.5">
<div <div
className="w-8 h-8 rounded-none flex items-center justify-center shrink-0" className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{ background: ACCENT, color: "white" }} style={{ background: ACCENT, color: "white" }}
> >
{item.icon} {item.icon}