diff --git a/public/cars/bolero-neo.jpg b/public/cars/bolero-neo.jpg new file mode 100644 index 0000000..91fd55e Binary files /dev/null and b/public/cars/bolero-neo.jpg differ diff --git a/public/cars/scorpio-n.jpg b/public/cars/scorpio-n.jpg new file mode 100644 index 0000000..8e4278d Binary files /dev/null and b/public/cars/scorpio-n.jpg differ diff --git a/public/cars/thar-roxx.jpg b/public/cars/thar-roxx.jpg new file mode 100644 index 0000000..b3b50e6 Binary files /dev/null and b/public/cars/thar-roxx.jpg differ diff --git a/public/cars/xuv3xo.jpg b/public/cars/xuv3xo.jpg new file mode 100644 index 0000000..dfdb044 Binary files /dev/null and b/public/cars/xuv3xo.jpg differ diff --git a/public/cars/xuv700.jpg b/public/cars/xuv700.jpg new file mode 100644 index 0000000..4c343ac Binary files /dev/null and b/public/cars/xuv700.jpg differ diff --git a/src/api/viewService.ts b/src/api/viewService.ts index 29e48ee..adb3dd4 100644 --- a/src/api/viewService.ts +++ b/src/api/viewService.ts @@ -381,3 +381,35 @@ export interface AuditEntry { export function getAuditLog(instanceId: string): Promise { return request("GET", `/app/${APP_ID}/view/audit?instance_id=${encodeURIComponent(instanceId)}`); } + +// --------------------------------------------------------------------------- +// RDBMS lookup records — used by the demo calendar to pull rows from the +// `mahindra_demo` schema via the lookup-field config on the INIT activity. +// --------------------------------------------------------------------------- + +export interface RdbmsLookupResponse { + records: Record[]; + total: number; + limit: number; + offset: number; +} + +export interface RdbmsLookupQuery { + workflowId: number; + activityId: string; + fieldId: string; + limit?: number; + offset?: number; + search?: string; +} + +export function fetchRdbmsLookupRecords(templateUuid: string, q: RdbmsLookupQuery): Promise { + return request("POST", `/app/${APP_ID}/rdbms-templates/${encodeURIComponent(templateUuid)}/records`, { + workflow_id: q.workflowId, + activity_id: q.activityId, + field_id: q.fieldId, + limit: q.limit ?? 1000, + offset: q.offset ?? 0, + search: q.search ?? "", + }); +} diff --git a/src/components/variants/Calendar.tsx b/src/components/variants/Calendar.tsx new file mode 100644 index 0000000..7c85c5d --- /dev/null +++ b/src/components/variants/Calendar.tsx @@ -0,0 +1,562 @@ +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([]); + const [cars, setCars] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [weekStart, setWeekStart] = useState(null); + const [selected, setSelected] = useState(null); + const [modelFilter, setModelFilter] = useState(""); + + 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(); + cars.forEach((c) => m.set(Number(c.id), c)); + return m; + }, [cars]); + + const models = useMemo(() => { + const set = new Set(); + 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(); + 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(); + 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(); + 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
Loading calendar…
; + } + if (error) { + return ( +
+ Couldn't load calendar: {error} +
+ ); + } + if (!weekStart || timeSlots.length === 0) { + return
No slots configured.
; + } + + return ( + <> + {/* Header — page title + week nav */} +
+
+
+ Test-drive lifecycle +
+

+ Slots calendar +

+
+
+
+ + +
+ +
+ {fmtDayLabel(weekDays[0]).day} {fmtDayLabel(weekDays[0]).month} — {fmtDayLabel(weekDays[6]).day} {fmtDayLabel(weekDays[6]).month} +
+ +
+
+ + {/* Legend */} +
+
+ + Booked +
+
+ + Available +
+
+ {totalBookings} bookings across {weekDays.length} days +
+
+ + {/* Grid */} +
+ + + + {weekDays.map((d) => )} + + + + + {weekDays.map((dk) => { + const lbl = fmtDayLabel(dk); + const count = bookingsByDay.get(dk) ?? 0; + return ( + + ); + })} + + + + {timeSlots.map((tk) => ( + + + {weekDays.map((dk) => ( + + ))} + + ))} + +
+ IST + +
+
+
{lbl.weekday}
+
+ {lbl.day} {lbl.month} +
+
+ {count > 0 && ( + + {count} + + )} +
+
+ {tk} + +
+ {visibleCars.map((c) => { + const cell = grid.get(`${dk}|${tk}|${Number(c.id)}`); + if (!cell) { + return ( +
+ — +
+ ); + } + const booked = cell.status === "booked"; + return ( + + ); + })} +
+
+
+ + setSelected(null)} + /> + + ); +} + +function BookingPanel({ drive, car, onClose }: { drive: TestDrive | null; car: Car | undefined; onClose: () => void }) { + const open = !!drive; + return ( + <> +
+ + + ); +} + +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 ( + <> +
+ +
+
+
+ Booking +
+
+ {drive.customer_name ?? "—"} +
+
+ + {day.weekday} {day.day} {day.month} + · + {time} +
+
+ +
+
+ +
+ {/* Car image hero */} + {car && ( +
+ {car.model} { + 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; + }} + /> +
+
{car.model}
+ {car.variant &&
{car.variant}
} +
+
+ )} + + + + {car?.model ?? "—"} + + {car?.variant || "—"} + + {car?.color ? ( + + + {car.color} + + ) : "—"} + + + {fmtPriceInr(car?.price_inr)} + + + + + + {drive.customer_name ?? "—"} + + + {drive.customer_phone ? ( + + + {drive.customer_phone} + + ) : "—"} + + + + + + + + + {day.weekday} {day.day} {day.month}, {time} + + + {drive.booked_at ? new Date(drive.booked_at).toLocaleString("en-IN", { dateStyle: "medium", timeStyle: "short" }) : "—"} + + {completed && ( + + {new Date(drive.completed_at!).toLocaleString("en-IN", { dateStyle: "medium", timeStyle: "short" })} + + )} + {drive.notes && {drive.notes}} + + + + {hasFeedback ? ( + <> + {drive.feedback_rating != null && ( + + + + )} + {drive.feedback_would_recommend != null && ( + + + + )} + {drive.feedback_liked_most && {drive.feedback_liked_most}} + {drive.feedback_concern && {drive.feedback_concern}} + {drive.feedback_collected_at && ( + + {new Date(drive.feedback_collected_at).toLocaleString("en-IN", { dateStyle: "medium", timeStyle: "short" })} + + )} + + ) : ( +
+ {completed ? "No feedback captured yet." : "Feedback will appear here after the drive is completed."} +
+ )} +
+
+ + ); +} + +function PanelSection({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +function StatusPill({ status, tone }: { status: string; tone: "booked" | "completed" | "failed" }) { + const palette: Record = { + 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 ( + + + {status} + + ); +} + +function RatingStars({ value }: { value: number }) { + const v = Math.max(0, Math.min(5, Math.round(value))); + return ( + + + {"★".repeat(v)}{"★".repeat(5 - v)} + + {v}/5 + + ); +} + +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 ( +
+
{label}
+
{children}
+
+ ); +} diff --git a/src/components/variants/VariantB.tsx b/src/components/variants/VariantB.tsx index a9981d6..5ac953e 100644 --- a/src/components/variants/VariantB.tsx +++ b/src/components/variants/VariantB.tsx @@ -25,7 +25,8 @@ import { type ActionButton, type AnalyticsSpec, type ScreenButton, } from "../../hooks/usePrototypeData"; import FormModal from "../FormModal"; -import { SCREEN_TO_DV, DETAIL_VIEW_IDS, ACTIVITY_IDS, STATE_IDS } from "../../config"; +import { SCREEN_TO_DV, DETAIL_VIEW_IDS, ACTIVITY_IDS, STATE_IDS, DEMO_SLOTS_SCREEN_UUID } from "../../config"; +import { CalendarView } from "./Calendar"; // --------------------------------------------------------------------------- // Palette — Tech Mahindra brand. Red is the identity color (the "Tech" in @@ -155,6 +156,8 @@ export default function VariantB() { onAction={(a) => setFormModal(a)} breadcrumbLabel={activeNav?.label ?? "Pipeline"} /> + ) : activeScreenId === DEMO_SLOTS_SCREEN_UUID ? ( + ) : activeScreenId ? ( <>
diff --git a/src/config.ts b/src/config.ts index 0ef6b7b..e90c8e5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -51,6 +51,21 @@ export const STATE_IDS = { END_STATE: "33cce5a0-f493-4fac-a1b7-7b68fbcbffce", } as const; +// Demo calendar — backed by mahindra_demo schema via rdbms-template lookups on +// the INIT activity (storage_columns=id, display_columns=everything we need). +export const WORKFLOW_NUMERIC_ID = 29347; +export const DEMO_SLOTS_SCREEN_UUID = "98b1b946-f67d-4faa-b50f-4566f7e194d0"; +export const CALENDAR_LOOKUPS = { + TEST_DRIVES: { + rdbmsTemplateUuid: "c71a16b0-8fc6-4ef2-b52d-2672e4dad8db", + fieldId: "test_drives_lookup", + }, + CARS: { + rdbmsTemplateUuid: "3efdfe0f-8ed9-4fbc-95ce-0f3788622c9c", + fieldId: "cars_lookup", + }, +} as const; + // Roles export const ROLES = { SALES_AGENT: "Sales Agent",