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:
parent
8d7b965418
commit
7830127e96
@ -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"); };
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
@ -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]);
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user