tech_mahindra/src/components/variants/Calendar.tsx

563 lines
23 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 } 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>
);
}