calendar: per-dealer view with dealer selector + EV car images

Port yesterday's studio work to dev (code only; env untouched):
- per-dealer grid scoping + dealer selector (DEALERS lookup, DEFAULT_DEALER_ID=1)
- weekCars fix: skip cars with no slots this week
- EV car images: XEV 9e, BE 6, XUV400 EV
- gitignore .mcp.json

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bhanu Prakash Sai Potteri 2026-06-25 11:50:14 +05:30
parent 41fa98479a
commit 6095d3f9d9
6 changed files with 102 additions and 18 deletions

3
.gitignore vendored
View File

@ -2,3 +2,6 @@ node_modules/
dist/ dist/
.env .env
*.local *.local
# Local MCP config (contains DB credentials)
.mcp.json

BIN
public/cars/be-6.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
public/cars/xev-9e.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

BIN
public/cars/xuv400-ev.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { ChevronLeft, ChevronRight, Phone, X, Calendar as CalendarIcon, Home, Building2 } from "lucide-react"; import { ChevronLeft, ChevronRight, Phone, X, Calendar as CalendarIcon, Home, Building2 } from "lucide-react";
import { fetchRdbmsLookupRecords } from "../../api/viewService"; import { fetchRdbmsLookupRecords } from "../../api/viewService";
import { CALENDAR_LOOKUPS, WORKFLOW_NUMERIC_ID, ACTIVITY_IDS } from "../../config"; import { CALENDAR_LOOKUPS, WORKFLOW_NUMERIC_ID, ACTIVITY_IDS, DEFAULT_DEALER_ID } from "../../config";
const ACCENT = "#e31837"; const ACCENT = "#e31837";
const ACCENT_SOFT = "#fde2e8"; const ACCENT_SOFT = "#fde2e8";
@ -34,12 +34,21 @@ interface TestDrive {
interface Car { interface Car {
id: number; id: number;
dealer_id: number;
model: string; model: string;
variant?: string | null; variant?: string | null;
color?: string | null; color?: string | null;
price_inr?: number | string | null; price_inr?: number | string | null;
} }
interface Dealer {
id: number;
name: string;
city?: string | null;
address?: string | null;
phone?: string | null;
}
function carSlug(model: string): string { function carSlug(model: string): string {
return model.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); return model.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
} }
@ -83,11 +92,13 @@ function addDaysKey(key: string, days: number): string {
export function CalendarView() { export function CalendarView() {
const [drives, setDrives] = useState<TestDrive[]>([]); const [drives, setDrives] = useState<TestDrive[]>([]);
const [cars, setCars] = useState<Car[]>([]); const [cars, setCars] = useState<Car[]>([]);
const [dealers, setDealers] = useState<Dealer[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [weekStart, setWeekStart] = useState<string | null>(null); const [weekStart, setWeekStart] = useState<string | null>(null);
const [selected, setSelected] = useState<TestDrive | null>(null); const [selected, setSelected] = useState<TestDrive | null>(null);
const [modelFilter, setModelFilter] = useState<string>(""); const [modelFilter, setModelFilter] = useState<string>("");
const [dealerId, setDealerId] = useState<number>(DEFAULT_DEALER_ID);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -124,11 +135,18 @@ export function CalendarView() {
fieldId: CALENDAR_LOOKUPS.CARS.fieldId, fieldId: CALENDAR_LOOKUPS.CARS.fieldId,
limit: 100, limit: 100,
}), }),
fetchRdbmsLookupRecords(CALENDAR_LOOKUPS.DEALERS.rdbmsTemplateUuid, {
workflowId: WORKFLOW_NUMERIC_ID,
activityId: ACTIVITY_IDS.INIT_ACTIVITY,
fieldId: CALENDAR_LOOKUPS.DEALERS.fieldId,
limit: 100,
}),
]) ])
.then(([td, c]) => { .then(([td, c, d]) => {
if (cancelled) return; if (cancelled) return;
setDrives(td); setDrives(td);
setCars((c.records as unknown as Car[]) ?? []); setCars((c.records as unknown as Car[]) ?? []);
setDealers((d.records as unknown as Dealer[]) ?? []);
const todayKey = istDayKey(new Date().toISOString()); const todayKey = istDayKey(new Date().toISOString());
const days = td.map((r) => istDayKey(r.slot_start)).sort(); const days = td.map((r) => istDayKey(r.slot_start)).sort();
setWeekStart(days.length ? todayKey : null); setWeekStart(days.length ? todayKey : null);
@ -144,15 +162,32 @@ export function CalendarView() {
return m; return m;
}, [cars]); }, [cars]);
const dealerById = useMemo(() => {
const m = new Map<number, Dealer>();
dealers.forEach((d) => m.set(Number(d.id), d));
return m;
}, [dealers]);
// Calendar shows one dealer at a time. Cars + drives carry dealer_id, so the
// whole grid (columns, slot times, bookings) is scoped to the selected dealer.
const dealerCars = useMemo(
() => cars.filter((c) => Number(c.dealer_id) === dealerId),
[cars, dealerId],
);
const dealerDrives = useMemo(
() => drives.filter((d) => Number(d.dealer_id) === dealerId),
[drives, dealerId],
);
const models = useMemo(() => { const models = useMemo(() => {
const set = new Set<string>(); const set = new Set<string>();
cars.forEach((c) => c.model && set.add(c.model)); dealerCars.forEach((c) => c.model && set.add(c.model));
return Array.from(set).sort(); return Array.from(set).sort();
}, [cars]); }, [dealerCars]);
const visibleCars = useMemo( const visibleCars = useMemo(
() => (modelFilter ? cars.filter((c) => c.model === modelFilter) : cars), () => (modelFilter ? dealerCars.filter((c) => c.model === modelFilter) : dealerCars),
[cars, modelFilter], [dealerCars, modelFilter],
); );
const weekDays = useMemo(() => { const weekDays = useMemo(() => {
@ -163,37 +198,48 @@ export function CalendarView() {
// time-slot keys present in the data, sorted // time-slot keys present in the data, sorted
const timeSlots = useMemo(() => { const timeSlots = useMemo(() => {
const set = new Set<string>(); const set = new Set<string>();
drives.forEach((d) => set.add(istTimeKey(d.slot_start))); dealerDrives.forEach((d) => set.add(istTimeKey(d.slot_start)));
return Array.from(set).sort(); return Array.from(set).sort();
}, [drives]); }, [dealerDrives]);
// index drives by (day, time, car) for O(1) lookup // index drives by (day, time, car) for O(1) lookup
const grid = useMemo(() => { const grid = useMemo(() => {
const m = new Map<string, TestDrive>(); const m = new Map<string, TestDrive>();
drives.forEach((d) => { dealerDrives.forEach((d) => {
const key = `${istDayKey(d.slot_start)}|${istTimeKey(d.slot_start)}|${d.car_id}`; const key = `${istDayKey(d.slot_start)}|${istTimeKey(d.slot_start)}|${d.car_id}`;
m.set(key, d); m.set(key, d);
}); });
return m; return m;
}, [drives]); }, [dealerDrives]);
const visibleCarIds = useMemo(() => new Set(visibleCars.map((c) => Number(c.id))), [visibleCars]); const visibleCarIds = useMemo(() => new Set(visibleCars.map((c) => Number(c.id))), [visibleCars]);
// Only render rows for cars that actually have ≥1 slot in the displayed week,
// otherwise cars with no slots this week (e.g. seeding gaps) show as dead "—" rows.
const weekCars = useMemo(() => {
const days = new Set(weekDays);
const idsThisWeek = new Set<number>();
dealerDrives.forEach((d) => {
if (days.has(istDayKey(d.slot_start))) idsThisWeek.add(Number(d.car_id));
});
return visibleCars.filter((c) => idsThisWeek.has(Number(c.id)));
}, [visibleCars, dealerDrives, weekDays]);
// For the visible week, count bookings per day for the header summary. // For the visible week, count bookings per day for the header summary.
const bookingsByDay = useMemo(() => { const bookingsByDay = useMemo(() => {
const m = new Map<string, number>(); const m = new Map<string, number>();
drives.forEach((d) => { dealerDrives.forEach((d) => {
if (d.status !== "booked") return; if (d.status !== "booked") return;
if (!visibleCarIds.has(Number(d.car_id))) return; if (!visibleCarIds.has(Number(d.car_id))) return;
const k = istDayKey(d.slot_start); const k = istDayKey(d.slot_start);
m.set(k, (m.get(k) ?? 0) + 1); m.set(k, (m.get(k) ?? 0) + 1);
}); });
return m; return m;
}, [drives, visibleCarIds]); }, [dealerDrives, visibleCarIds]);
const totalBookings = useMemo( const totalBookings = useMemo(
() => drives.filter((d) => d.status === "booked" && visibleCarIds.has(Number(d.car_id))).length, () => dealerDrives.filter((d) => d.status === "booked" && visibleCarIds.has(Number(d.car_id))).length,
[drives, visibleCarIds], [dealerDrives, visibleCarIds],
); );
if (loading) { if (loading) {
@ -223,6 +269,19 @@ export function CalendarView() {
</h1> </h1>
</div> </div>
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<div className="relative">
<select
value={dealerId}
onChange={(e) => { setDealerId(Number(e.target.value)); setModelFilter(""); setSelected(null); }}
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 }}
>
{dealers.map((d) => (
<option key={d.id} value={d.id}>{d.name}{d.city ? ` · ${d.city}` : ""}</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>
<div className="relative"> <div className="relative">
<select <select
value={modelFilter} value={modelFilter}
@ -326,7 +385,7 @@ export function CalendarView() {
{weekDays.map((dk) => ( {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}` }}> <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"> <div className="grid grid-cols-1 gap-1">
{visibleCars.map((c) => { {weekCars.map((c) => {
const cell = grid.get(`${dk}|${tk}|${Number(c.id)}`); const cell = grid.get(`${dk}|${tk}|${Number(c.id)}`);
if (!cell) { if (!cell) {
return ( return (
@ -383,13 +442,14 @@ export function CalendarView() {
<BookingPanel <BookingPanel
drive={selected} drive={selected}
car={selected ? carById.get(Number(selected.car_id)) : undefined} car={selected ? carById.get(Number(selected.car_id)) : undefined}
dealer={selected ? dealerById.get(Number(selected.dealer_id)) : undefined}
onClose={() => setSelected(null)} onClose={() => setSelected(null)}
/> />
</> </>
); );
} }
function BookingPanel({ drive, car, onClose }: { drive: TestDrive | null; car: Car | undefined; onClose: () => void }) { function BookingPanel({ drive, car, dealer, onClose }: { drive: TestDrive | null; car: Car | undefined; dealer: Dealer | undefined; onClose: () => void }) {
const open = !!drive; const open = !!drive;
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@ -410,13 +470,13 @@ function BookingPanel({ drive, car, onClose }: { drive: TestDrive | null; car: C
role="dialog" role="dialog"
aria-hidden={!open} aria-hidden={!open}
> >
{drive && <BookingPanelBody drive={drive} car={car} onClose={onClose} />} {drive && <BookingPanelBody drive={drive} car={car} dealer={dealer} onClose={onClose} />}
</aside> </aside>
</> </>
); );
} }
function BookingPanelBody({ drive, car, onClose }: { drive: TestDrive; car: Car | undefined; onClose: () => void }) { function BookingPanelBody({ drive, car, dealer, onClose }: { drive: TestDrive; car: Car | undefined; dealer: Dealer | undefined; onClose: () => void }) {
const day = fmtDayLabel(istDayKey(drive.slot_start)); const day = fmtDayLabel(istDayKey(drive.slot_start));
const time = `${istTimeKey(drive.slot_start)} ${istTimeKey(drive.slot_end)} IST`; const time = `${istTimeKey(drive.slot_start)} ${istTimeKey(drive.slot_end)} IST`;
return ( return (
@ -504,6 +564,21 @@ function BookingPanelBody({ drive, car, onClose }: { drive: TestDrive; car: Car
</PanelSection> </PanelSection>
<PanelSection label="Dealer calendar"> <PanelSection label="Dealer calendar">
{dealer && (
<Row label="Dealer">
<span className="inline-flex items-center gap-1.5 font-semibold" style={{ color: INK }}>
<Building2 size={12} className="text-stone-400" />
{dealer.name}{dealer.city ? `, ${dealer.city}` : ""}
</span>
</Row>
)}
{dealer?.phone && (
<Row label="Dealer phone">
<a href={`tel:${dealer.phone}`} className="font-semibold text-stone-700 hover:text-[#e31837] transition-colors tabular-nums">
{dealer.phone}
</a>
</Row>
)}
<Row label="Slot"> <Row label="Slot">
<span className="font-semibold tabular-nums" style={{ color: INK }}>{day.weekday} {day.day} {day.month}, {time}</span> <span className="font-semibold tabular-nums" style={{ color: INK }}>{day.weekday} {day.day} {day.month}, {time}</span>
</Row> </Row>

View File

@ -81,8 +81,14 @@ export const CALENDAR_LOOKUPS = {
rdbmsTemplateUuid: "3efdfe0f-8ed9-4fbc-95ce-0f3788622c9c", rdbmsTemplateUuid: "3efdfe0f-8ed9-4fbc-95ce-0f3788622c9c",
fieldId: "cars_lookup", fieldId: "cars_lookup",
}, },
DEALERS: {
rdbmsTemplateUuid: "2632197b-9614-448e-8116-392cecabe355",
fieldId: "dealers_lookup",
},
} as const; } as const;
export const DEFAULT_DEALER_ID = 1;
// Roles // Roles
export const ROLES = { export const ROLES = {
SALES_AGENT: "Sales Agent", SALES_AGENT: "Sales Agent",