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 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"); };
|
||||
|
||||
|
||||
@ -123,13 +123,13 @@ export default function FormModal({ activityId, instanceId, title, onClose, onSu
|
||||
|
||||
<div
|
||||
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)" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{success && (
|
||||
<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 }} />
|
||||
</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)}`);
|
||||
if (!cell) {
|
||||
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>
|
||||
);
|
||||
@ -313,7 +313,7 @@ export function CalendarView() {
|
||||
style={
|
||||
booked
|
||||
? { 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}`}
|
||||
>
|
||||
@ -346,6 +346,12 @@ export function CalendarView() {
|
||||
|
||||
function BookingPanel({ drive, car, onClose }: { drive: TestDrive | null; car: Car | undefined; onClose: () => void }) {
|
||||
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 (
|
||||
<>
|
||||
<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 }) {
|
||||
const day = fmtDayLabel(istDayKey(drive.slot_start));
|
||||
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 (
|
||||
<>
|
||||
<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 }} />
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.15em]" style={{ color: ACCENT_SOFT }}>
|
||||
Booking
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.15em] inline-flex items-center gap-1.5" style={{ color: ACCENT_SOFT }}>
|
||||
<CalendarIcon size={11} /> Booking
|
||||
</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 ?? "—"}
|
||||
</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>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@ -422,10 +423,6 @@ function BookingPanelBody({ drive, car, onClose }: { drive: TestDrive; car: Car
|
||||
)}
|
||||
|
||||
<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">
|
||||
{car?.color ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
@ -445,62 +442,25 @@ function BookingPanelBody({ drive, car, onClose }: { drive: TestDrive; car: Car
|
||||
</Row>
|
||||
<Row label="Phone">
|
||||
{drive.customer_phone ? (
|
||||
<a href={`tel:${drive.customer_phone}`} className="inline-flex items-center gap-1.5 font-semibold" style={{ color: ACCENT }}>
|
||||
<Phone size={12} />
|
||||
<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} className="text-stone-400" />
|
||||
{drive.customer_phone}
|
||||
</a>
|
||||
) : "—"}
|
||||
</Row>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection label="Booking">
|
||||
<Row label="Status">
|
||||
<StatusPill status={completed ? "Completed" : "Booked"} tone={completed ? "completed" : "booked"} />
|
||||
</Row>
|
||||
<PanelSection label="Dealer calendar">
|
||||
<Row label="Slot">
|
||||
<span className="font-semibold tabular-nums" style={{ color: INK }}>{day.weekday} {day.day} {day.month}, {time}</span>
|
||||
</Row>
|
||||
<Row label="Booked at">
|
||||
{drive.booked_at ? new Date(drive.booked_at).toLocaleString("en-IN", { dateStyle: "medium", timeStyle: "short" }) : "—"}
|
||||
</Row>
|
||||
{completed && (
|
||||
<Row label="Completed at">
|
||||
{new Date(drive.completed_at!).toLocaleString("en-IN", { dateStyle: "medium", timeStyle: "short" })}
|
||||
{drive.booked_at && (
|
||||
<Row label="Reserved at">
|
||||
{new Date(drive.booked_at).toLocaleString("en-IN", { dateStyle: "medium", timeStyle: "short" })}
|
||||
</Row>
|
||||
)}
|
||||
{drive.notes && <Row label="Notes">{drive.notes}</Row>}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
@ -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 {
|
||||
const c = color.toLowerCase();
|
||||
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;
|
||||
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;
|
||||
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() {
|
||||
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);
|
||||
useEffect(() => {
|
||||
getHeaderConfig("desktop").then((cfg) => {
|
||||
@ -204,6 +220,7 @@ export interface UseRecordViewState {
|
||||
|
||||
export function useRecordView(viewId: number | string | null): UseRecordViewState {
|
||||
const [rvTemplateUid, setRvTemplateUid] = useState<string | null>(null);
|
||||
const [layoutColumnKeys, setLayoutColumnKeys] = useState<string[] | null>(null);
|
||||
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
|
||||
const [actionColumns, setActionColumns] = useState<ActionColumn[]>([]);
|
||||
const [analytics, setAnalytics] = useState<AnalyticsSpec[]>([]);
|
||||
@ -221,11 +238,12 @@ export function useRecordView(viewId: number | string | null): UseRecordViewStat
|
||||
useEffect(() => {
|
||||
if (!viewId) return;
|
||||
setLoading(true); setError(null);
|
||||
setRvTemplateUid(null); setColumnLabels({}); setActionColumns([]);
|
||||
setRvTemplateUid(null); setLayoutColumnKeys(null); setColumnLabels({}); setActionColumns([]);
|
||||
setAnalytics([]); setTileValues([]); setChartData([]);
|
||||
getRVScreen(viewId)
|
||||
.then((rv) => {
|
||||
setColumnLabels(extractColumnLabels(rv.layout));
|
||||
setLayoutColumnKeys(extractColumnKeys(rv.layout));
|
||||
setActionColumns(extractActionColumns(rv.layout));
|
||||
setAnalytics(extractAnalytics(rv.layout));
|
||||
setRvTemplateUid(rv.rv_template_uid);
|
||||
@ -242,7 +260,14 @@ export function useRecordView(viewId: number | string | null): UseRecordViewStat
|
||||
setTileValues(res.tile_values ?? []);
|
||||
setChartData(res.chart_data ?? []);
|
||||
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 : [];
|
||||
setRows(dataRows);
|
||||
if (res.pagination) setPagination(res.pagination);
|
||||
@ -250,7 +275,7 @@ export function useRecordView(viewId: number | string | null): UseRecordViewStat
|
||||
} catch (err: any) { setError(err.message); }
|
||||
finally { setLoading(false); setDataLoading(false); }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rvTemplateUid, viewId, query, refreshTick]);
|
||||
}, [rvTemplateUid, viewId, query, refreshTick, layoutColumnKeys]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
|
||||
@ -62,7 +62,7 @@ export default function Login() {
|
||||
{/* 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 className="text-[10.5px] uppercase tracking-[0.18em] text-white/60 mt-2">AI Sales Ops</div>
|
||||
</div>
|
||||
|
||||
{/* Headline + feature bullets */}
|
||||
@ -82,7 +82,7 @@ export default function Login() {
|
||||
].map((item) => (
|
||||
<div key={item.title} className="flex items-start gap-3.5">
|
||||
<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" }}
|
||||
>
|
||||
{item.icon}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user