import { useEffect, useMemo, useState } from "react"; import { ChevronLeft, ChevronRight, Phone, X, Calendar as CalendarIcon, Home, Building2 } 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; td_mode?: "home" | "showroom" | null; is_expert_td?: boolean | null; home_address?: 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([]); const [cars, setCars] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [weekStart, setWeekStart] = useState(null); const [selected, setSelected] = useState(null); const [modelFilter, setModelFilter] = useState(""); useEffect(() => { let cancelled = false; setLoading(true); // The /rdbms-templates/.../records handler clamps limit to 200 server-side, // so loop with offset until we've drained `total`. Without this, ~80 of 280 // test_drives are silently dropped and entire cars (heap-order-last) vanish. const PAGE = 200; async function fetchAllDrives(): Promise { const out: TestDrive[] = []; let offset = 0; for (;;) { const resp = await fetchRdbmsLookupRecords(CALENDAR_LOOKUPS.TEST_DRIVES.rdbmsTemplateUuid, { workflowId: WORKFLOW_NUMERIC_ID, activityId: ACTIVITY_IDS.INIT_ACTIVITY, fieldId: CALENDAR_LOOKUPS.TEST_DRIVES.fieldId, limit: PAGE, offset, }); const batch = (resp.records as unknown as TestDrive[]) ?? []; out.push(...batch); if (batch.length < PAGE || out.length >= resp.total) break; offset += PAGE; } return out; } Promise.all([ fetchAllDrives(), 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); setCars((c.records as unknown as Car[]) ?? []); const days = td.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(); cars.forEach((c) => m.set(Number(c.id), c)); return m; }, [cars]); const models = useMemo(() => { const set = new Set(); 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(); 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(); 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(); 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
Loading calendar…
; } if (error) { return (
Couldn't load calendar: {error}
); } if (!weekStart || timeSlots.length === 0) { return
No slots configured.
; } return ( <> {/* Header — page title + week nav */}
Test-drive lifecycle

Slots calendar

{fmtDayLabel(weekDays[0]).day} {fmtDayLabel(weekDays[0]).month} — {fmtDayLabel(weekDays[6]).day} {fmtDayLabel(weekDays[6]).month}
{/* Legend */}
Booked
Available
Showroom
Home
Expert TD
{totalBookings} bookings across {weekDays.length} days
{/* Grid */}
{weekDays.map((d) => )} {weekDays.map((dk) => { const lbl = fmtDayLabel(dk); const count = bookingsByDay.get(dk) ?? 0; return ( ); })} {timeSlots.map((tk) => ( {weekDays.map((dk) => ( ))} ))}
IST
{lbl.weekday}
{lbl.day} {lbl.month}
{count > 0 && ( {count} )}
{tk}
{visibleCars.map((c) => { const cell = grid.get(`${dk}|${tk}|${Number(c.id)}`); if (!cell) { return (
); } const booked = cell.status === "booked"; const isHome = cell.td_mode === "home"; const isExpert = !!cell.is_expert_td; const ModeIcon = isHome ? Home : Building2; return ( ); })}
setSelected(null)} /> ); } 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 ( <>
); } 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`; return ( <>
Booking {drive.is_expert_td && ( ★ Expert TD )}
{day.weekday}, {day.day} {day.month} · {time}
{drive.customer_name ?? "—"}
{/* Car image hero */} {car && (
{car.model} { 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; }} />
{car.model}
{car.variant &&
{car.variant}
}
)} {car?.color ? ( {car.color} ) : "—"} {fmtPriceInr(car?.price_inr)} {drive.customer_name ?? "—"} {drive.customer_phone ? ( {drive.customer_phone} ) : "—"} {day.weekday} {day.day} {day.month}, {time} {drive.td_mode === "home" ? : } {drive.td_mode === "home" ? "Home visit" : "Showroom"} {drive.td_mode === "home" && drive.home_address && ( {drive.home_address} )} {drive.booked_at && ( {new Date(drive.booked_at).toLocaleString("en-IN", { dateStyle: "medium", timeStyle: "short" })} )} {drive.notes && {drive.notes}}
); } function PanelSection({ label, children }: { label: string; children: React.ReactNode }) { return (
{label}
{children}
); } function StatusPill({ status, tone }: { status: string; tone: "booked" | "completed" | "failed" }) { const palette: Record = { 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 ( {status} ); } 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 (
{label}
{children}
); }