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/
|
dist/
|
||||||
.env
|
.env
|
||||||
*.local
|
*.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 { 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>
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user