tech_mahindra/src/components/variants/Calendar.tsx
Bhanu Prakash Sai Potteri d16cd8d86e inital commit
2026-05-29 19:57:38 +05:30

572 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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);
// 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<TestDrive[]> {
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<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 flex-wrap">
<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="flex items-center gap-1.5">
<Building2 size={12} className="text-stone-500" /> Showroom
</div>
<div className="flex items-center gap-1.5">
<Home size={12} className="text-stone-500" /> Home
</div>
<div className="flex items-center gap-2">
<span className="w-3 h-3" style={{ background: ACCENT, boxShadow: "inset 0 0 0 2px #d4a017" }} />
Expert TD
</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-400" style={{ border: `1px solid ${TM_BORDER}` }}>
</div>
);
}
const booked = cell.status === "booked";
const isHome = cell.td_mode === "home";
const isExpert = !!cell.is_expert_td;
const ModeIcon = isHome ? Home : Building2;
return (
<button
key={c.id}
onClick={() => booked && setSelected(cell)}
className={`h-7 px-2 flex items-center justify-between gap-1.5 text-[10.5px] font-medium transition-colors ${booked ? "cursor-pointer" : "cursor-default"}`}
style={
booked
? {
background: ACCENT,
color: "white",
boxShadow: isExpert ? "inset 0 0 0 2px #d4a017" : undefined,
}
: { background: "white", color: "#78716c", border: `1px solid ${TM_BORDER}` }
}
title={
booked
? `${cell.customer_name ?? "Booked"} · ${c.model} · ${isHome ? "Home" : "Showroom"}${isExpert ? " · Expert TD" : ""}`
: `Available · ${c.model}`
}
>
<span className="flex items-center gap-1 min-w-0">
{booked && <ModeIcon size={10} className="shrink-0 opacity-90" />}
<span className="truncate">{c.model}</span>
</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;
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
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`;
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] inline-flex items-center gap-1.5" style={{ color: ACCENT_SOFT }}>
<CalendarIcon size={11} /> Booking
{drive.is_expert_td && (
<span
className="ml-1 inline-flex items-center gap-1 px-1.5 py-0.5 text-[9.5px] font-bold uppercase tracking-wide"
style={{ background: "#d4a017", color: "#1a1a1a", borderRadius: 3 }}
>
Expert TD
</span>
)}
</div>
<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>
<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="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 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="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="Mode">
<span className="inline-flex items-center gap-1.5 font-semibold" style={{ color: INK }}>
{drive.td_mode === "home" ? <Home size={12} /> : <Building2 size={12} />}
{drive.td_mode === "home" ? "Home visit" : "Showroom"}
</span>
</Row>
{drive.td_mode === "home" && drive.home_address && (
<Row label="Address">{drive.home_address}</Row>
)}
{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>
</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 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>
);
}