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:
parent
41fa98479a
commit
6095d3f9d9
3
.gitignore
vendored
3
.gitignore
vendored
@ -2,3 +2,6 @@ node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.local
|
||||
|
||||
# Local MCP config (contains DB credentials)
|
||||
.mcp.json
|
||||
|
||||
BIN
public/cars/be-6.jpg
Normal file
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
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
BIN
public/cars/xuv400-ev.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@ -1,7 +1,7 @@
|
||||
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";
|
||||
import { CALENDAR_LOOKUPS, WORKFLOW_NUMERIC_ID, ACTIVITY_IDS, DEFAULT_DEALER_ID } from "../../config";
|
||||
|
||||
const ACCENT = "#e31837";
|
||||
const ACCENT_SOFT = "#fde2e8";
|
||||
@ -34,12 +34,21 @@ interface TestDrive {
|
||||
|
||||
interface Car {
|
||||
id: number;
|
||||
dealer_id: number;
|
||||
model: string;
|
||||
variant?: string | null;
|
||||
color?: 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 {
|
||||
return model.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
}
|
||||
@ -83,11 +92,13 @@ function addDaysKey(key: string, days: number): string {
|
||||
export function CalendarView() {
|
||||
const [drives, setDrives] = useState<TestDrive[]>([]);
|
||||
const [cars, setCars] = useState<Car[]>([]);
|
||||
const [dealers, setDealers] = useState<Dealer[]>([]);
|
||||
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>("");
|
||||
const [dealerId, setDealerId] = useState<number>(DEFAULT_DEALER_ID);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@ -124,11 +135,18 @@ export function CalendarView() {
|
||||
fieldId: CALENDAR_LOOKUPS.CARS.fieldId,
|
||||
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;
|
||||
setDrives(td);
|
||||
setCars((c.records as unknown as Car[]) ?? []);
|
||||
setDealers((d.records as unknown as Dealer[]) ?? []);
|
||||
const todayKey = istDayKey(new Date().toISOString());
|
||||
const days = td.map((r) => istDayKey(r.slot_start)).sort();
|
||||
setWeekStart(days.length ? todayKey : null);
|
||||
@ -144,15 +162,32 @@ export function CalendarView() {
|
||||
return m;
|
||||
}, [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 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();
|
||||
}, [cars]);
|
||||
}, [dealerCars]);
|
||||
|
||||
const visibleCars = useMemo(
|
||||
() => (modelFilter ? cars.filter((c) => c.model === modelFilter) : cars),
|
||||
[cars, modelFilter],
|
||||
() => (modelFilter ? dealerCars.filter((c) => c.model === modelFilter) : dealerCars),
|
||||
[dealerCars, modelFilter],
|
||||
);
|
||||
|
||||
const weekDays = useMemo(() => {
|
||||
@ -163,37 +198,48 @@ export function CalendarView() {
|
||||
// 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)));
|
||||
dealerDrives.forEach((d) => set.add(istTimeKey(d.slot_start)));
|
||||
return Array.from(set).sort();
|
||||
}, [drives]);
|
||||
}, [dealerDrives]);
|
||||
|
||||
// index drives by (day, time, car) for O(1) lookup
|
||||
const grid = useMemo(() => {
|
||||
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}`;
|
||||
m.set(key, d);
|
||||
});
|
||||
return m;
|
||||
}, [drives]);
|
||||
}, [dealerDrives]);
|
||||
|
||||
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.
|
||||
const bookingsByDay = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
drives.forEach((d) => {
|
||||
dealerDrives.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]);
|
||||
}, [dealerDrives, visibleCarIds]);
|
||||
|
||||
const totalBookings = useMemo(
|
||||
() => drives.filter((d) => d.status === "booked" && visibleCarIds.has(Number(d.car_id))).length,
|
||||
[drives, visibleCarIds],
|
||||
() => dealerDrives.filter((d) => d.status === "booked" && visibleCarIds.has(Number(d.car_id))).length,
|
||||
[dealerDrives, visibleCarIds],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
@ -223,6 +269,19 @@ export function CalendarView() {
|
||||
</h1>
|
||||
</div>
|
||||
<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">
|
||||
<select
|
||||
value={modelFilter}
|
||||
@ -326,7 +385,7 @@ export function CalendarView() {
|
||||
{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) => {
|
||||
{weekCars.map((c) => {
|
||||
const cell = grid.get(`${dk}|${tk}|${Number(c.id)}`);
|
||||
if (!cell) {
|
||||
return (
|
||||
@ -383,13 +442,14 @@ export function CalendarView() {
|
||||
<BookingPanel
|
||||
drive={selected}
|
||||
car={selected ? carById.get(Number(selected.car_id)) : undefined}
|
||||
dealer={selected ? dealerById.get(Number(selected.dealer_id)) : undefined}
|
||||
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;
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@ -410,13 +470,13 @@ function BookingPanel({ drive, car, onClose }: { drive: TestDrive | null; car: C
|
||||
role="dialog"
|
||||
aria-hidden={!open}
|
||||
>
|
||||
{drive && <BookingPanelBody drive={drive} car={car} onClose={onClose} />}
|
||||
{drive && <BookingPanelBody drive={drive} car={car} dealer={dealer} onClose={onClose} />}
|
||||
</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 time = `${istTimeKey(drive.slot_start)} – ${istTimeKey(drive.slot_end)} IST`;
|
||||
return (
|
||||
@ -504,6 +564,21 @@ function BookingPanelBody({ drive, car, onClose }: { drive: TestDrive; car: Car
|
||||
</PanelSection>
|
||||
|
||||
<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">
|
||||
<span className="font-semibold tabular-nums" style={{ color: INK }}>{day.weekday} {day.day} {day.month}, {time}</span>
|
||||
</Row>
|
||||
|
||||
@ -81,8 +81,14 @@ export const CALENDAR_LOOKUPS = {
|
||||
rdbmsTemplateUuid: "3efdfe0f-8ed9-4fbc-95ce-0f3788622c9c",
|
||||
fieldId: "cars_lookup",
|
||||
},
|
||||
DEALERS: {
|
||||
rdbmsTemplateUuid: "2632197b-9614-448e-8116-392cecabe355",
|
||||
fieldId: "dealers_lookup",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_DEALER_ID = 1;
|
||||
|
||||
// Roles
|
||||
export const ROLES = {
|
||||
SALES_AGENT: "Sales Agent",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user