572 lines
24 KiB
TypeScript
572 lines
24 KiB
TypeScript
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>
|
||
);
|
||
}
|