Demo Slots calendar: week grid, model filter, right-panel booking details with car images
This commit is contained in:
parent
db5d3cb34d
commit
19e1ddc0f8
BIN
public/cars/bolero-neo.jpg
Normal file
BIN
public/cars/bolero-neo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
BIN
public/cars/scorpio-n.jpg
Normal file
BIN
public/cars/scorpio-n.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
public/cars/thar-roxx.jpg
Normal file
BIN
public/cars/thar-roxx.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
BIN
public/cars/xuv3xo.jpg
Normal file
BIN
public/cars/xuv3xo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
public/cars/xuv700.jpg
Normal file
BIN
public/cars/xuv700.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
@ -381,3 +381,35 @@ export interface AuditEntry {
|
||||
export function getAuditLog(instanceId: string): Promise<AuditEntry[]> {
|
||||
return request("GET", `/app/${APP_ID}/view/audit?instance_id=${encodeURIComponent(instanceId)}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RDBMS lookup records — used by the demo calendar to pull rows from the
|
||||
// `mahindra_demo` schema via the lookup-field config on the INIT activity.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RdbmsLookupResponse {
|
||||
records: Record<string, unknown>[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface RdbmsLookupQuery {
|
||||
workflowId: number;
|
||||
activityId: string;
|
||||
fieldId: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export function fetchRdbmsLookupRecords(templateUuid: string, q: RdbmsLookupQuery): Promise<RdbmsLookupResponse> {
|
||||
return request("POST", `/app/${APP_ID}/rdbms-templates/${encodeURIComponent(templateUuid)}/records`, {
|
||||
workflow_id: q.workflowId,
|
||||
activity_id: q.activityId,
|
||||
field_id: q.fieldId,
|
||||
limit: q.limit ?? 1000,
|
||||
offset: q.offset ?? 0,
|
||||
search: q.search ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
562
src/components/variants/Calendar.tsx
Normal file
562
src/components/variants/Calendar.tsx
Normal file
@ -0,0 +1,562 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChevronLeft, ChevronRight, Phone, X, Calendar as CalendarIcon } from "lucide-react";
|
||||
import { fetchRdbmsLookupRecords } from "../../api/viewService";
|
||||
import { CALENDAR_LOOKUPS, WORKFLOW_NUMERIC_ID, ACTIVITY_IDS } from "../../config";
|
||||
|
||||
const ACCENT = "#e31837";
|
||||
const ACCENT_SOFT = "#fde2e8";
|
||||
const TM_NAVY = "#061f5c";
|
||||
const TM_BORDER = "#e4e4ed";
|
||||
const INK = "#17181a";
|
||||
const IST_OFFSET_MIN = 330;
|
||||
|
||||
interface TestDrive {
|
||||
id: number;
|
||||
dealer_id: number;
|
||||
car_id: number;
|
||||
slot_start: string;
|
||||
slot_end: string;
|
||||
status: "available" | "booked";
|
||||
customer_name?: string | null;
|
||||
customer_phone?: string | null;
|
||||
booked_at?: string | null;
|
||||
completed_at?: string | null;
|
||||
feedback_rating?: number | null;
|
||||
feedback_liked_most?: string | null;
|
||||
feedback_concern?: string | null;
|
||||
feedback_would_recommend?: boolean | null;
|
||||
feedback_collected_at?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
interface Car {
|
||||
id: number;
|
||||
model: string;
|
||||
variant?: string | null;
|
||||
color?: string | null;
|
||||
price_inr?: number | string | null;
|
||||
}
|
||||
|
||||
function carSlug(model: string): string {
|
||||
return model.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
}
|
||||
function carImageUrl(model: string): string {
|
||||
return `${import.meta.env.BASE_URL}cars/${carSlug(model)}.jpg`;
|
||||
}
|
||||
function fmtPriceInr(v: number | string | null | undefined): string {
|
||||
if (v == null || v === "") return "—";
|
||||
const n = typeof v === "string" ? Number(v) : v;
|
||||
if (!Number.isFinite(n)) return String(v);
|
||||
return n.toLocaleString("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
function toIST(iso: string): Date {
|
||||
// Shift UTC to IST by adding 5:30. Returns a Date whose UTC fields equal IST wall-clock.
|
||||
return new Date(new Date(iso).getTime() + IST_OFFSET_MIN * 60_000);
|
||||
}
|
||||
function istDayKey(iso: string): string {
|
||||
const d = toIST(iso);
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
|
||||
}
|
||||
function istTimeKey(iso: string): string {
|
||||
const d = toIST(iso);
|
||||
return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
|
||||
}
|
||||
function fmtDayLabel(key: string): { weekday: string; day: string; month: string } {
|
||||
const [y, m, d] = key.split("-").map(Number);
|
||||
const dt = new Date(Date.UTC(y, m - 1, d));
|
||||
return {
|
||||
weekday: dt.toLocaleDateString("en-US", { weekday: "short", timeZone: "UTC" }),
|
||||
day: String(d).padStart(2, "0"),
|
||||
month: dt.toLocaleDateString("en-US", { month: "short", timeZone: "UTC" }),
|
||||
};
|
||||
}
|
||||
function addDaysKey(key: string, days: number): string {
|
||||
const [y, m, d] = key.split("-").map(Number);
|
||||
const dt = new Date(Date.UTC(y, m - 1, d + days));
|
||||
return `${dt.getUTCFullYear()}-${String(dt.getUTCMonth() + 1).padStart(2, "0")}-${String(dt.getUTCDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function CalendarView() {
|
||||
const [drives, setDrives] = useState<TestDrive[]>([]);
|
||||
const [cars, setCars] = useState<Car[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [weekStart, setWeekStart] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<TestDrive | null>(null);
|
||||
const [modelFilter, setModelFilter] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
fetchRdbmsLookupRecords(CALENDAR_LOOKUPS.TEST_DRIVES.rdbmsTemplateUuid, {
|
||||
workflowId: WORKFLOW_NUMERIC_ID,
|
||||
activityId: ACTIVITY_IDS.INIT_ACTIVITY,
|
||||
fieldId: CALENDAR_LOOKUPS.TEST_DRIVES.fieldId,
|
||||
limit: 1000,
|
||||
}),
|
||||
fetchRdbmsLookupRecords(CALENDAR_LOOKUPS.CARS.rdbmsTemplateUuid, {
|
||||
workflowId: WORKFLOW_NUMERIC_ID,
|
||||
activityId: ACTIVITY_IDS.INIT_ACTIVITY,
|
||||
fieldId: CALENDAR_LOOKUPS.CARS.fieldId,
|
||||
limit: 100,
|
||||
}),
|
||||
])
|
||||
.then(([td, c]) => {
|
||||
if (cancelled) return;
|
||||
setDrives((td.records as unknown as TestDrive[]) ?? []);
|
||||
setCars((c.records as unknown as Car[]) ?? []);
|
||||
const days = (td.records as unknown as TestDrive[])
|
||||
.map((r) => istDayKey(r.slot_start))
|
||||
.sort();
|
||||
setWeekStart(days[0] ?? null);
|
||||
})
|
||||
.catch((e: any) => !cancelled && setError(e?.message ?? "Failed to load"))
|
||||
.finally(() => !cancelled && setLoading(false));
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const carById = useMemo(() => {
|
||||
const m = new Map<number, Car>();
|
||||
cars.forEach((c) => m.set(Number(c.id), c));
|
||||
return m;
|
||||
}, [cars]);
|
||||
|
||||
const models = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
cars.forEach((c) => c.model && set.add(c.model));
|
||||
return Array.from(set).sort();
|
||||
}, [cars]);
|
||||
|
||||
const visibleCars = useMemo(
|
||||
() => (modelFilter ? cars.filter((c) => c.model === modelFilter) : cars),
|
||||
[cars, modelFilter],
|
||||
);
|
||||
|
||||
const weekDays = useMemo(() => {
|
||||
if (!weekStart) return [];
|
||||
return Array.from({ length: 7 }, (_, i) => addDaysKey(weekStart, i));
|
||||
}, [weekStart]);
|
||||
|
||||
// time-slot keys present in the data, sorted
|
||||
const timeSlots = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
drives.forEach((d) => set.add(istTimeKey(d.slot_start)));
|
||||
return Array.from(set).sort();
|
||||
}, [drives]);
|
||||
|
||||
// index drives by (day, time, car) for O(1) lookup
|
||||
const grid = useMemo(() => {
|
||||
const m = new Map<string, TestDrive>();
|
||||
drives.forEach((d) => {
|
||||
const key = `${istDayKey(d.slot_start)}|${istTimeKey(d.slot_start)}|${d.car_id}`;
|
||||
m.set(key, d);
|
||||
});
|
||||
return m;
|
||||
}, [drives]);
|
||||
|
||||
const visibleCarIds = useMemo(() => new Set(visibleCars.map((c) => Number(c.id))), [visibleCars]);
|
||||
|
||||
// For the visible week, count bookings per day for the header summary.
|
||||
const bookingsByDay = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
drives.forEach((d) => {
|
||||
if (d.status !== "booked") return;
|
||||
if (!visibleCarIds.has(Number(d.car_id))) return;
|
||||
const k = istDayKey(d.slot_start);
|
||||
m.set(k, (m.get(k) ?? 0) + 1);
|
||||
});
|
||||
return m;
|
||||
}, [drives, visibleCarIds]);
|
||||
|
||||
const totalBookings = useMemo(
|
||||
() => drives.filter((d) => d.status === "booked" && visibleCarIds.has(Number(d.car_id))).length,
|
||||
[drives, visibleCarIds],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-stone-400 text-sm">Loading calendar…</div>;
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="px-4 py-3 text-sm" style={{ background: ACCENT_SOFT, color: "#5f0229", border: `1px solid ${ACCENT}` }}>
|
||||
Couldn't load calendar: {error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!weekStart || timeSlots.length === 0) {
|
||||
return <div className="text-stone-400 text-sm">No slots configured.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header — page title + week nav */}
|
||||
<div className="mb-6 flex items-end justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.15em]" style={{ color: ACCENT }}>
|
||||
Test-drive lifecycle
|
||||
</div>
|
||||
<h1 className="text-[24px] font-semibold tracking-tight leading-tight mt-1.5" style={{ color: INK }}>
|
||||
Slots calendar
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative">
|
||||
<select
|
||||
value={modelFilter}
|
||||
onChange={(e) => setModelFilter(e.target.value)}
|
||||
className="h-9 pl-3 pr-8 text-[12.5px] font-semibold bg-white appearance-none cursor-pointer focus:outline-none"
|
||||
style={{ border: `1px solid ${TM_BORDER}`, color: INK, borderRadius: 4 }}
|
||||
>
|
||||
<option value="">All models</option>
|
||||
{models.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
<span className="absolute right-2.5 top-1/2 -translate-y-1/2 text-stone-400 pointer-events-none text-[10px]">▾</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setWeekStart((k) => k && addDaysKey(k, -7))}
|
||||
className="h-9 w-9 inline-flex items-center justify-center bg-white hover:bg-stone-50 transition-colors"
|
||||
style={{ border: `1px solid ${TM_BORDER}`, color: INK }}
|
||||
aria-label="Previous week"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<div className="text-[13px] font-semibold tabular-nums px-3" style={{ color: INK }}>
|
||||
{fmtDayLabel(weekDays[0]).day} {fmtDayLabel(weekDays[0]).month} — {fmtDayLabel(weekDays[6]).day} {fmtDayLabel(weekDays[6]).month}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setWeekStart((k) => k && addDaysKey(k, 7))}
|
||||
className="h-9 w-9 inline-flex items-center justify-center bg-white hover:bg-stone-50 transition-colors"
|
||||
style={{ border: `1px solid ${TM_BORDER}`, color: INK }}
|
||||
aria-label="Next week"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mb-4 flex items-center gap-5 text-[11.5px] text-stone-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3" style={{ background: ACCENT }} />
|
||||
Booked
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 bg-white" style={{ border: `1px solid ${TM_BORDER}` }} />
|
||||
Available
|
||||
</div>
|
||||
<div className="ml-auto text-stone-400">
|
||||
{totalBookings} bookings across {weekDays.length} days
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="bg-white overflow-x-auto" style={{ border: `1px solid ${TM_BORDER}` }}>
|
||||
<table className="w-full" style={{ tableLayout: "fixed", borderCollapse: "collapse" }}>
|
||||
<colgroup>
|
||||
<col style={{ width: 80 }} />
|
||||
{weekDays.map((d) => <col key={d} />)}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left px-3 py-3 text-[10px] font-semibold uppercase tracking-[0.12em] text-stone-400" style={{ borderBottom: `1px solid ${TM_BORDER}` }}>
|
||||
IST
|
||||
</th>
|
||||
{weekDays.map((dk) => {
|
||||
const lbl = fmtDayLabel(dk);
|
||||
const count = bookingsByDay.get(dk) ?? 0;
|
||||
return (
|
||||
<th key={dk} className="text-left px-3 py-3" style={{ borderBottom: `1px solid ${TM_BORDER}`, borderLeft: `1px solid ${TM_BORDER}` }}>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.12em] text-stone-400">{lbl.weekday}</div>
|
||||
<div className="text-[15px] font-bold leading-none mt-1 tabular-nums" style={{ color: INK }}>
|
||||
{lbl.day} <span className="text-[11px] font-medium text-stone-400 ml-0.5">{lbl.month}</span>
|
||||
</div>
|
||||
</div>
|
||||
{count > 0 && (
|
||||
<span className="text-[10px] font-bold tabular-nums text-white px-1.5 py-0.5" style={{ background: ACCENT }}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{timeSlots.map((tk) => (
|
||||
<tr key={tk}>
|
||||
<td className="px-3 py-3 align-top text-[12px] font-semibold tabular-nums text-stone-500" style={{ borderTop: `1px solid ${TM_BORDER}` }}>
|
||||
{tk}
|
||||
</td>
|
||||
{weekDays.map((dk) => (
|
||||
<td key={dk + tk} className="px-2 py-2 align-top" style={{ borderTop: `1px solid ${TM_BORDER}`, borderLeft: `1px solid ${TM_BORDER}` }}>
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
{visibleCars.map((c) => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
const booked = cell.status === "booked";
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => booked && setSelected(cell)}
|
||||
className={`h-7 px-2 flex items-center justify-between gap-2 text-[10.5px] font-medium transition-colors ${booked ? "cursor-pointer" : "cursor-default"}`}
|
||||
style={
|
||||
booked
|
||||
? { background: ACCENT, color: "white" }
|
||||
: { background: "white", color: "#a8a29e", border: `1px solid ${TM_BORDER}` }
|
||||
}
|
||||
title={booked ? `${cell.customer_name ?? "Booked"} · ${c.model}` : `Available · ${c.model}`}
|
||||
>
|
||||
<span className="truncate">{c.model}</span>
|
||||
{booked && cell.customer_name && (
|
||||
<span className="truncate font-semibold text-white/90">
|
||||
{cell.customer_name.split(" ")[0]}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<BookingPanel
|
||||
drive={selected}
|
||||
car={selected ? carById.get(Number(selected.car_id)) : undefined}
|
||||
onClose={() => setSelected(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingPanel({ drive, car, onClose }: { drive: TestDrive | null; car: Car | undefined; onClose: () => void }) {
|
||||
const open = !!drive;
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`fixed inset-0 z-40 transition-opacity duration-200 ${open ? "opacity-100" : "opacity-0 pointer-events-none"}`}
|
||||
style={{ background: "rgba(6,31,92,0.35)" }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<aside
|
||||
className={`fixed top-0 right-0 bottom-0 z-50 w-full max-w-md bg-white shadow-2xl flex flex-col transition-transform duration-200 ease-out ${open ? "translate-x-0" : "translate-x-full"}`}
|
||||
style={{ borderLeft: `1px solid ${TM_BORDER}` }}
|
||||
role="dialog"
|
||||
aria-hidden={!open}
|
||||
>
|
||||
{drive && <BookingPanelBody drive={drive} car={car} onClose={onClose} />}
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<div className="text-[22px] font-bold leading-tight mt-1">
|
||||
{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}
|
||||
className="text-white/70 hover:text-white transition-colors shrink-0"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{/* Car image hero */}
|
||||
{car && (
|
||||
<div className="relative w-full bg-stone-100" style={{ aspectRatio: "16 / 9" }}>
|
||||
<img
|
||||
src={carImageUrl(car.model)}
|
||||
alt={car.model}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
const fallback = `${import.meta.env.BASE_URL}cars/xuv700.jpg`;
|
||||
if (img.src.endsWith(fallback)) { img.style.display = "none"; return; }
|
||||
img.src = fallback;
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 px-5 py-3" style={{ background: "linear-gradient(to top, rgba(6,31,92,0.85), transparent)" }}>
|
||||
<div className="text-white text-[18px] font-bold leading-none">{car.model}</div>
|
||||
{car.variant && <div className="text-white/80 text-[12px] mt-1">{car.variant}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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">
|
||||
<span className="w-3 h-3 rounded-full" style={{ background: colorSwatch(car.color), border: "1px solid rgba(0,0,0,0.1)" }} />
|
||||
{car.color}
|
||||
</span>
|
||||
) : "—"}
|
||||
</Row>
|
||||
<Row label="Ex-showroom">
|
||||
<span className="font-semibold tabular-nums" style={{ color: INK }}>{fmtPriceInr(car?.price_inr)}</span>
|
||||
</Row>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection label="Customer">
|
||||
<Row label="Name">
|
||||
<span className="font-semibold" style={{ color: INK }}>{drive.customer_name ?? "—"}</span>
|
||||
</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} />
|
||||
{drive.customer_phone}
|
||||
</a>
|
||||
) : "—"}
|
||||
</Row>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection label="Booking">
|
||||
<Row label="Status">
|
||||
<StatusPill status={completed ? "Completed" : "Booked"} tone={completed ? "completed" : "booked"} />
|
||||
</Row>
|
||||
<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" })}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelSection({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="px-6 py-5" style={{ borderBottom: `1px solid ${TM_BORDER}` }}>
|
||||
<div className="text-[10px] font-bold uppercase tracking-[0.15em] text-stone-400 mb-3">{label}</div>
|
||||
<div className="space-y-3">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ status, tone }: { status: string; tone: "booked" | "completed" | "failed" }) {
|
||||
const palette: Record<string, { bg: string; fg: string; dot: string }> = {
|
||||
booked: { bg: ACCENT_SOFT, fg: "#5f0229", dot: ACCENT },
|
||||
completed: { bg: "#e8f5ec", fg: "#0f5132", dot: "#2f855a" },
|
||||
failed: { bg: "#fde8e8", fg: "#7f1d1d", dot: "#dc2626" },
|
||||
};
|
||||
const p = palette[tone];
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-1 text-[11px] font-bold uppercase tracking-[0.04em]" style={{ background: p.bg, color: p.fg, borderRadius: 4 }}>
|
||||
<span className="w-1.5 h-1.5 rounded-full" style={{ background: p.dot }} />
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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";
|
||||
if (c.includes("white")) return "#f5f5f5";
|
||||
if (c.includes("black") || c.includes("napoli") || c.includes("stealth")) return "#1a1a1a";
|
||||
if (c.includes("silver") || c.includes("grey") || c.includes("gray")) return "#9ca3af";
|
||||
if (c.includes("blue")) return "#1e3a8a";
|
||||
return "#9ca3af";
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-4 text-[13px]">
|
||||
<div className="w-24 shrink-0 text-[11px] font-semibold uppercase tracking-[0.05em] text-stone-500">{label}</div>
|
||||
<div className="flex-1 min-w-0 break-words" style={{ color: INK }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -25,7 +25,8 @@ import {
|
||||
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";
|
||||
import { SCREEN_TO_DV, DETAIL_VIEW_IDS, ACTIVITY_IDS, STATE_IDS, DEMO_SLOTS_SCREEN_UUID } from "../../config";
|
||||
import { CalendarView } from "./Calendar";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Palette — Tech Mahindra brand. Red is the identity color (the "Tech" in
|
||||
@ -155,6 +156,8 @@ export default function VariantB() {
|
||||
onAction={(a) => setFormModal(a)}
|
||||
breadcrumbLabel={activeNav?.label ?? "Pipeline"}
|
||||
/>
|
||||
) : activeScreenId === DEMO_SLOTS_SCREEN_UUID ? (
|
||||
<CalendarView />
|
||||
) : activeScreenId ? (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
|
||||
@ -51,6 +51,21 @@ export const STATE_IDS = {
|
||||
END_STATE: "33cce5a0-f493-4fac-a1b7-7b68fbcbffce",
|
||||
} as const;
|
||||
|
||||
// Demo calendar — backed by mahindra_demo schema via rdbms-template lookups on
|
||||
// the INIT activity (storage_columns=id, display_columns=everything we need).
|
||||
export const WORKFLOW_NUMERIC_ID = 29347;
|
||||
export const DEMO_SLOTS_SCREEN_UUID = "98b1b946-f67d-4faa-b50f-4566f7e194d0";
|
||||
export const CALENDAR_LOOKUPS = {
|
||||
TEST_DRIVES: {
|
||||
rdbmsTemplateUuid: "c71a16b0-8fc6-4ef2-b52d-2672e4dad8db",
|
||||
fieldId: "test_drives_lookup",
|
||||
},
|
||||
CARS: {
|
||||
rdbmsTemplateUuid: "3efdfe0f-8ed9-4fbc-95ce-0f3788622c9c",
|
||||
fieldId: "cars_lookup",
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Roles
|
||||
export const ROLES = {
|
||||
SALES_AGENT: "Sales Agent",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user