From 348e0c5cb4d25ebed99fb641e3c9cbbb4072a18b Mon Sep 17 00:00:00 2001 From: Bhanu Prakash Sai Potteri Date: Fri, 22 May 2026 19:34:42 +0530 Subject: [PATCH] Initial scaffold for Mahindra test-drive lead manager (app 164) --- .env | 2 + src/App.tsx | 37 ++ src/api/viewService.ts | 351 ++++++++++++++++++ src/components/AppShell.tsx | 366 +++++++++++++++++++ src/components/DetailViewPanel.tsx | 372 +++++++++++++++++++ src/components/DynamicHeader.tsx | 211 +++++++++++ src/components/FormModal.tsx | 250 +++++++++++++ src/components/ProtectedRoute.tsx | 9 + src/components/RecordViewTable.tsx | 557 +++++++++++++++++++++++++++++ src/components/ScreenRenderer.tsx | 110 ++++++ src/components/Skeleton.tsx | 50 +++ src/config.ts | 46 +++ src/hooks/AuthContext.tsx | 17 + src/hooks/ThemeContext.tsx | 36 ++ src/hooks/useAuth.ts | 93 +++++ src/index.css | 38 ++ src/main.tsx | 24 ++ src/pages/Login.tsx | 176 +++++++++ src/zino-sdk/DynamicForm.tsx | 175 +++++++++ src/zino-sdk/auth.ts | 64 ++++ src/zino-sdk/client.ts | 86 +++++ src/zino-sdk/index.ts | 61 ++++ src/zino-sdk/mock.ts | 243 +++++++++++++ src/zino-sdk/provider.tsx | 143 ++++++++ src/zino-sdk/types.ts | 285 +++++++++++++++ src/zino-sdk/views.ts | 197 ++++++++++ src/zino-sdk/workflow.ts | 223 ++++++++++++ 27 files changed, 4222 insertions(+) create mode 100644 .env create mode 100644 src/App.tsx create mode 100644 src/api/viewService.ts create mode 100644 src/components/AppShell.tsx create mode 100644 src/components/DetailViewPanel.tsx create mode 100644 src/components/DynamicHeader.tsx create mode 100644 src/components/FormModal.tsx create mode 100644 src/components/ProtectedRoute.tsx create mode 100644 src/components/RecordViewTable.tsx create mode 100644 src/components/ScreenRenderer.tsx create mode 100644 src/components/Skeleton.tsx create mode 100644 src/config.ts create mode 100644 src/hooks/AuthContext.tsx create mode 100644 src/hooks/ThemeContext.tsx create mode 100644 src/hooks/useAuth.ts create mode 100644 src/index.css create mode 100644 src/main.tsx create mode 100644 src/pages/Login.tsx create mode 100644 src/zino-sdk/DynamicForm.tsx create mode 100644 src/zino-sdk/auth.ts create mode 100644 src/zino-sdk/client.ts create mode 100644 src/zino-sdk/index.ts create mode 100644 src/zino-sdk/mock.ts create mode 100644 src/zino-sdk/provider.tsx create mode 100644 src/zino-sdk/types.ts create mode 100644 src/zino-sdk/views.ts create mode 100644 src/zino-sdk/workflow.ts diff --git a/.env b/.env new file mode 100644 index 0000000..4533bcf --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +VITE_ZINO_API_URL=https://sandbox.getzino.in +VITE_ZINO_MOCK=false diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..242b3f7 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,37 @@ +import { Routes, Route, Navigate } from "react-router-dom"; +import { ProtectedRoute } from "./components/ProtectedRoute"; +import Login from "./pages/Login"; +import AppShell from "./components/AppShell"; + +export default function App() { + return ( + + } /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + + ); +} diff --git a/src/api/viewService.ts b/src/api/viewService.ts new file mode 100644 index 0000000..170cc34 --- /dev/null +++ b/src/api/viewService.ts @@ -0,0 +1,351 @@ +import { APP_ID } from "../config"; + +const TOKEN_KEY = "zino_token"; + +function baseUrl(): string { + return (import.meta.env.VITE_ZINO_API_URL || "").replace(/\/+$/, ""); +} + +function headers(): Record { + const h: Record = { "Content-Type": "application/json" }; + const token = localStorage.getItem(TOKEN_KEY); + if (token) h["Authorization"] = `Bearer ${token}`; + return h; +} + +async function request(method: string, path: string, body?: unknown): Promise { + const res = await fetch(`${baseUrl()}${path}`, { + method, + headers: headers(), + body: body ? JSON.stringify(body) : undefined, + }); + if (res.status === 401) { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem("zino_user"); + window.location.href = "/login"; + throw new Error("Unauthorized"); + } + if (!res.ok) { + let msg = res.statusText; + try { const e = await res.json(); if (e.error) msg = e.error; } catch {} + throw new Error(msg); + } + if (res.status === 204) return undefined as T; + return res.json() as Promise; +} + +// --------------------------------------------------------------------------- +// Header +// --------------------------------------------------------------------------- + +export interface HeaderConfig { + slots: { left: string[]; right: string[]; center: string[] }; + layout: { type: string; height: string; position: string; full_width: boolean }; + components: { + nav?: { items: NavItem[] }; + logo?: { text?: string; image_url?: string; display_type: string }; + profile?: { shape: string; show_name: boolean }; + }; +} + +export interface NavItem { + id: string; + icon: string; + type: string; + label: string; + screen_id: number; +} + +export async function getHeaderConfig(deviceType = "desktop"): Promise { + const raw = await request("GET", `/app/${APP_ID}/header/${deviceType}`); + return raw.config ?? raw; +} + +// --------------------------------------------------------------------------- +// Screens +// --------------------------------------------------------------------------- + +export interface ScreenListItem { + id: number; + source_screen_id: number; + screen_name: string; + device: string; + description: string; +} + +export interface ScreenLayout { + id: number; + source_screen_id: number; + screen_name: string; + layout: LayoutElement[]; +} + +export interface LayoutElement { + type: string; + style?: Record; + config: Record; + classes?: string[]; +} + +export function getScreens(deviceType = "desktop"): Promise { + return request("GET", `/app/${APP_ID}/screens?device_type=${deviceType}`); +} + +export function getScreen(sourceScreenId: number): Promise { + return request("GET", `/app/${APP_ID}/screens/${sourceScreenId}`); +} + +// --------------------------------------------------------------------------- +// RV Screens +// --------------------------------------------------------------------------- + +export interface RVScreen { + id: number; + source_screen_id: number; + screen_name: string; + rv_template_id: number; + layout?: any; +} + +// Accepts either the numeric source_screen_id OR the source_uuid — both +// are accepted by the view-service's rv-screens endpoint. +export function getRVScreen(idOrUuid: number | string): Promise { + return request("GET", `/app/${APP_ID}/rv-screens/${idOrUuid}`); +} + +// --------------------------------------------------------------------------- +// Record View (POST-based) +// --------------------------------------------------------------------------- + +export interface SearchQuery { + page?: number; + limit?: number; + sort_by?: string; + sort_dir?: "asc" | "desc"; + search?: string; + filters?: Array<{ field_key: string; value: string; data_type?: string }>; +} + +export interface RecordViewField { + field_key: string; + output_label: string; + data_type: string; + is_filter: boolean; + is_search: boolean; + type?: string; + activity_id?: string; +} + +export interface RecordViewResponse { + config: { fields: RecordViewField[] }; + data: Record[]; + pagination?: { + page: number; + limit: number; + total_count: number; + total_pages: number; + }; +} + +export function getRecordView( + rvTemplateId: number, + rvScreenId: string | number, + query: SearchQuery = {}, +): Promise { + return request("POST", `/app/${APP_ID}/recordview`, { + rv_template_id: rvTemplateId, + rv_screen_id: String(rvScreenId), + search_query: { + page: query.page || 1, + limit: query.limit || 50, + sort_by: query.sort_by || "", + sort_dir: query.sort_dir || "desc", + search: query.search || "", + filters: query.filters || [], + }, + }); +} + +export function getRecordViewLegacy( + rvUid: string, + query: SearchQuery = {}, +): Promise { + const qs = new URLSearchParams(); + qs.set("rv_id", rvUid); + if (query.page) qs.set("page", String(query.page)); + if (query.limit) qs.set("limit", String(query.limit)); + if (query.sort_by) qs.set("sort_by", query.sort_by); + if (query.sort_dir) qs.set("sort_dir", query.sort_dir); + if (query.search) qs.set("search", query.search); + if (query.filters) { + for (const f of query.filters) { + qs.set(`filter.${f.field_key}`, f.value); + } + } + return request("GET", `/recordview?${qs.toString()}`); +} + +// --------------------------------------------------------------------------- +// DV Screens +// --------------------------------------------------------------------------- + +export interface DVScreen { + id: number; + source_screen_id: number; + screen_name: string; + dv_template_id: number; + layout?: any; +} + +export function getDVScreen(idOrUuid: number | string): Promise { + return request("GET", `/app/${APP_ID}/dv-screens/${idOrUuid}`); +} + +// --------------------------------------------------------------------------- +// Detail View +// --------------------------------------------------------------------------- + +export interface DetailViewResponse { + config: { fields: Array<{ field_key: string; output_label: string; data_type: string }> }; + data: Record; +} + +export function getDetailView(dvSourceId: number, instanceId: string): Promise { + return request( + "GET", + `/app/${APP_ID}/detailview/${dvSourceId}?instance_id=${encodeURIComponent(instanceId)}`, + ); +} + +export function getDetailViewLegacy(dvUid: string, instanceId: string): Promise { + return request( + "GET", + `/detailview?dv_id=${encodeURIComponent(dvUid)}&instance_id=${encodeURIComponent(instanceId)}`, + ); +} + +// --------------------------------------------------------------------------- +// Form Screens +// --------------------------------------------------------------------------- + +export interface FormScreenField { + id: string; + uid: string; + name: string; + type: string; + data_type: string; + mandatory: boolean; + value?: any; + options?: Array<{ label: string; value: string }>; +} + +export interface FormScreenResponse { + id: number; + activity_uid: string; + activity_name: string; + device_type: string; + fields: FormScreenField[]; + grid_config: Array<{ i: string; x: number; y: number; w: number; h: number }>; + layout: any[]; +} + +export function getFormScreen( + activityId: string, + instanceId?: string, + deviceType = "desktop", +): Promise { + return request("POST", `/app/${APP_ID}/form-screens`, { + activity_id: activityId, + instance_id: instanceId || "", + device_type: deviceType, + }); +} + +// --------------------------------------------------------------------------- +// Workflow Actions +// --------------------------------------------------------------------------- + +export interface WorkflowResponse { + success: boolean; + status_code?: number; + message?: string; + data?: Record; + instance_id?: string; +} + +export function startWorkflow( + workflowId: string, + activityId: string, + data?: Record, +): Promise { + return request("POST", `/app/${APP_ID}/start`, { workflow_id: workflowId, activity_id: activityId, data }); +} + +export function performActivity( + workflowId: string, + instanceId: string, + activityId: string, + data?: Record, +): Promise { + return request("POST", `/app/${APP_ID}/activity`, { + workflow_id: workflowId, + instance_id: instanceId, + activity_id: activityId, + data, + }); +} + +export function submitForm( + workflowId: string, + activityId: string, + formData: Record, + instanceId?: string, + deviceType = "desktop", +): Promise { + return request("POST", `/app/${APP_ID}/form/submit`, { + workflow_id: workflowId, + activity_id: activityId, + device_type: deviceType, + form_data: formData, + ...(instanceId ? { instance_id: instanceId } : {}), + }); +} + +// --------------------------------------------------------------------------- +// Instance +// --------------------------------------------------------------------------- + +export interface InstanceResponse { + instance_id: string; + workflow_id: string; + current_state_id: string; + current_state_name: string; + data: Record; + created_at: string; + updated_at: string; +} + +export function getInstance(workflowId: string, instanceId: string): Promise { + return request("POST", `/app/${APP_ID}/instance`, { + workflow_id: workflowId, + instance_id: instanceId, + }); +} + +// --------------------------------------------------------------------------- +// Audit +// --------------------------------------------------------------------------- + +export interface AuditEntry { + id: number; + user_id: string; + user_roles: string[]; + activity_id: string; + data: Record; + execution_state: string; + created_at: string; +} + +export function getAuditLog(instanceId: string): Promise { + return request("GET", `/app/${APP_ID}/audit?instance_id=${encodeURIComponent(instanceId)}`); +} diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx new file mode 100644 index 0000000..667d7b0 --- /dev/null +++ b/src/components/AppShell.tsx @@ -0,0 +1,366 @@ +import { useEffect, useState, useCallback } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { Loader2, CheckCircle2, RefreshCw, Clock, Sparkles } from "lucide-react"; +import DynamicHeader from "./DynamicHeader"; +import ScreenRenderer from "./ScreenRenderer"; +import { TableSkeleton } from "./Skeleton"; +import DetailViewPanel from "./DetailViewPanel"; +import FormModal from "./FormModal"; +import { + getHeaderConfig, getScreen, getAuditLog, getInstance, + type LayoutElement, type NavItem, type AuditEntry, +} from "../api/viewService"; +import { WORKFLOW_ID, ACTIVITY_IDS, STATE_IDS, DETAIL_VIEW_IDS } from "../config"; + +// State → contextual action buttons shown above the detail view. +const ACTIVITY_META: Record = { + [ACTIVITY_IDS.MARK_TEST_DRIVE_DONE]: { label: "Mark Test Drive Done", icon: , style: "primary" }, + [ACTIVITY_IDS.RETRY_SCHEDULING_CALL]: { label: "Retry Scheduling Call", icon: , style: "primary" }, + [ACTIVITY_IDS.RETRY_FEEDBACK_CALL]: { label: "Retry Feedback Call", icon: , style: "primary" }, +}; + +const BTN_STYLES: Record = { + primary: "text-white mh-glow hover:scale-[1.02]", + ghost: "text-stone-600 dark:text-zinc-300 bg-white dark:bg-zinc-900 border border-stone-200 dark:border-zinc-700 hover:bg-stone-50 dark:hover:bg-zinc-800", +}; + +// Friendly state labels for the breadcrumb / banner. +const STATE_LABEL: Record = { + [STATE_IDS.AWAITING_SCHEDULER_CALL]: "Awaiting Scheduler Call", + [STATE_IDS.SCHEDULED]: "Scheduled", + [STATE_IDS.AWAITING_FEEDBACK_CALL]: "Awaiting Feedback Call", + [STATE_IDS.FEEDBACK_COLLECTED]: "Feedback Collected", + [STATE_IDS.SCHEDULING_CALL_FAILED]: "Scheduling Call Failed", + [STATE_IDS.FEEDBACK_CALL_FAILED]: "Feedback Call Failed", + [STATE_IDS.END_STATE]: "Closed", +}; + +// Color hint for state pill. +function stateTone(stateId: string | null) { + if (!stateId) return { bg: "rgba(168,162,158,0.15)", fg: "#57534E", ring: "rgba(168,162,158,0.30)" }; + if (stateId === STATE_IDS.FEEDBACK_COLLECTED || stateId === STATE_IDS.END_STATE) + return { bg: "rgba(16,185,129,0.12)", fg: "#047857", ring: "rgba(16,185,129,0.30)" }; + if (stateId === STATE_IDS.SCHEDULING_CALL_FAILED || stateId === STATE_IDS.FEEDBACK_CALL_FAILED) + return { bg: "rgba(239,68,68,0.12)", fg: "#B91C1C", ring: "rgba(239,68,68,0.30)" }; + if (stateId === STATE_IDS.AWAITING_SCHEDULER_CALL || stateId === STATE_IDS.AWAITING_FEEDBACK_CALL) + return { bg: "rgba(200,16,46,0.10)", fg: "#C8102E", ring: "rgba(200,16,46,0.28)" }; + if (stateId === STATE_IDS.SCHEDULED) + return { bg: "rgba(245,158,11,0.14)", fg: "#B45309", ring: "rgba(245,158,11,0.30)" }; + return { bg: "rgba(168,162,158,0.15)", fg: "#57534E", ring: "rgba(168,162,158,0.30)" }; +} + +export default function AppShell() { + const navigate = useNavigate(); + const params = useParams(); + const screenIdParam = params.screenId ? Number(params.screenId) : null; + const instanceIdParam = params.instanceId; + + const [activeScreenId, setActiveScreenId] = useState(screenIdParam); + const [layout, setLayout] = useState([]); + const [loading, setLoading] = useState(true); + const [ready, setReady] = useState(false); + + const [detailInstanceId, setDetailInstanceId] = useState(instanceIdParam || null); + const [refreshKey, setRefreshKey] = useState(0); + const [formModal, setFormModal] = useState<{ activityId: string; instanceId: string; title: string } | null>(null); + const [instanceState, setInstanceState] = useState(null); + const [audit, setAudit] = useState([]); + const [sidebarOpen, setSidebarOpen] = useState(true); + const [navItems, setNavItems] = useState([]); + + // Init — pick the first nav screen if no URL screen is set. + useEffect(() => { + if (!screenIdParam && !instanceIdParam) { + getHeaderConfig("desktop") + .then((cfg) => { + const items = cfg.components?.nav?.items ?? []; + setNavItems(items); + if (items.length > 0) handleNavigate(items[0].screen_id); + }) + .catch(console.error) + .finally(() => setReady(true)); + } else { + // Still need nav items for the breadcrumb / sidebar. + getHeaderConfig("desktop") + .then((cfg) => setNavItems(cfg.components?.nav?.items ?? [])) + .catch(console.error); + setReady(true); + if (instanceIdParam) setDetailInstanceId(instanceIdParam); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Sync detail state with browser history. + useEffect(() => { + if (!params.instanceId && detailInstanceId) { + setDetailInstanceId(null); + setInstanceState(null); + setAudit([]); + } else if (params.instanceId && params.instanceId !== detailInstanceId) { + setDetailInstanceId(params.instanceId); + } + }, [params.instanceId]); // eslint-disable-line react-hooks/exhaustive-deps + + // Load active screen layout. + useEffect(() => { + if (!activeScreenId || detailInstanceId) return; + setLoading(true); + getScreen(activeScreenId) + .then((s) => setLayout(s.layout ?? [])) + .catch(console.error) + .finally(() => setLoading(false)); + }, [activeScreenId, detailInstanceId]); + + // Load instance state + audit. + useEffect(() => { + if (!detailInstanceId) return; + getInstance(WORKFLOW_ID, detailInstanceId) + .then((inst) => setInstanceState(inst.current_state_id)) + .catch(() => {}); + getAuditLog(detailInstanceId).then(setAudit).catch(() => setAudit([])); + }, [detailInstanceId, refreshKey]); + + const handleNavigate = useCallback((id: number) => { + setActiveScreenId(id); + setDetailInstanceId(null); + setInstanceState(null); + setAudit([]); + navigate(`/screen/${id}`); + }, [navigate]); + + const handleRowClick = useCallback((instanceId: string) => { + setDetailInstanceId(instanceId); + navigate(`/screen/${activeScreenId}/detail/${instanceId}`); + }, [activeScreenId, navigate]); + + const handleBack = () => { + setDetailInstanceId(null); + setInstanceState(null); + setAudit([]); + navigate(`/screen/${activeScreenId}`); + }; + + const handleFormSuccess = () => { + setFormModal(null); + setRefreshKey((k) => k + 1); + }; + + const actions = (() => { + if (!instanceState) return []; + if (instanceState === STATE_IDS.SCHEDULED) return [ACTIVITY_IDS.MARK_TEST_DRIVE_DONE]; + if (instanceState === STATE_IDS.SCHEDULING_CALL_FAILED) return [ACTIVITY_IDS.RETRY_SCHEDULING_CALL]; + if (instanceState === STATE_IDS.FEEDBACK_CALL_FAILED) return [ACTIVITY_IDS.RETRY_FEEDBACK_CALL]; + return []; + })(); + + const aiInFlight = + instanceState === STATE_IDS.AWAITING_SCHEDULER_CALL || + instanceState === STATE_IDS.AWAITING_FEEDBACK_CALL; + + if (!ready) { + return ( +
+
+ + Loading… +
+
+ ); + } + + const tone = stateTone(instanceState); + const stateLabel = instanceState ? STATE_LABEL[instanceState] || instanceState : ""; + + return ( +
+ + +
+
+ {detailInstanceId ? ( +
+ {/* Breadcrumb + actions */} +
+ + + {actions.length > 0 && ( +
+ {actions.map((id) => { + const m = ACTIVITY_META[id]; + if (!m) return null; + return ( + + ); + })} +
+ )} +
+ + {/* AI banner */} + {aiInFlight && ( +
+
+ +
+
+
Maya is on the line
+
+ The AI scheduler is placing the call right now. This page refreshes on completion. +
+
+
+ )} + + {/* Detail panel — uses the deployed instance detail view */} + + + {/* Activity timeline */} + {audit.length > 0 && ( +
+
+
+ +
+

Activity Timeline

+ + {audit.length} events + +
+
+
+
+
+ {audit.map((e, i) => { + const isFirst = i === 0; + const isLast = i === audit.length - 1; + return ( +
+
+
+
+ + {fmtAct(e.activity_id)} + + {isLast && ( + + Latest + + )} +
+ + {new Date(e.created_at).toLocaleString("en-IN", { + day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit", + })} + +
+
+ ); + })} +
+
+
+
+ )} +
+ ) : loading ? ( + + ) : layout.length > 0 ? ( + setRefreshKey((k) => k + 1)} + /> + ) : ( +
+ Select a screen from the navigation +
+ )} +
+
+ + {formModal && ( + setFormModal(null)} + onSuccess={handleFormSuccess} + /> + )} +
+ ); +} + +// Friendlier label for our UUID-style activity IDs. +function fmtAct(id: string): string { + switch (id) { + case ACTIVITY_IDS.INIT_ACTIVITY: return "Lead Captured"; + case ACTIVITY_IDS.MARK_TEST_DRIVE_DONE: return "Test Drive Completed"; + case ACTIVITY_IDS.AI_CONFIRM_SCHEDULING_SUCCESS: return "AI · Scheduling Confirmed"; + case ACTIVITY_IDS.AI_CONFIRM_SCHEDULING_FAILED: return "AI · Scheduling Failed"; + case ACTIVITY_IDS.RETRY_SCHEDULING_CALL: return "Retry Scheduling Call"; + case ACTIVITY_IDS.AI_CONFIRM_FEEDBACK_COLLECTED: return "AI · Feedback Collected"; + case ACTIVITY_IDS.AI_CONFIRM_FEEDBACK_FAILED: return "AI · Feedback Failed"; + case ACTIVITY_IDS.RETRY_FEEDBACK_CALL: return "Retry Feedback Call"; + default: return id.slice(0, 8) + "…"; + } +} diff --git a/src/components/DetailViewPanel.tsx b/src/components/DetailViewPanel.tsx new file mode 100644 index 0000000..43b6297 --- /dev/null +++ b/src/components/DetailViewPanel.tsx @@ -0,0 +1,372 @@ +import { useEffect, useState, useRef } from "react"; +import { Hash, Calendar, Sparkles, TrendingUp, MessageSquare, ChevronDown, ChevronUp } from "lucide-react"; +import { getDVScreen, getDetailView } from "../api/viewService"; + +interface Field { field_key: string; output_label: string; data_type: string } +interface Props { viewId: number | string; instanceId: string } + +type GroupKey = "hero" | "customer" | "vehicle" | "booking" | "feedback" | "ai" | "general"; + +// --------------------------------------------------------------------------- +// Classification — Mahindra test-drive workflow +// --------------------------------------------------------------------------- + +function classify(key: string): GroupKey { + const k = key.toLowerCase(); + if (k === "customer_name") return "hero"; + if (k.startsWith("customer_") || k === "lead_source" || k === "phone" || k === "email") return "customer"; + if (k === "model_interest" || k.startsWith("vehicle_") || k === "variant" || k === "color" || k === "fuel_type") return "vehicle"; + if (k.startsWith("booking_") || k === "dealer_name" || k === "dealer_id" || k === "slot_start" || k === "slot_end") return "booking"; + if (k.startsWith("feedback_") || k === "test_drive_outcome" || k === "would_recommend" || k === "rating") return "feedback"; + if (k.startsWith("ai_") || k.startsWith("call_") || k.includes("summary") || k.includes("failure_reason") || + k.includes("confidence") || k.includes("reasoning") || k.includes("escalation")) return "ai"; + return "general"; +} + +// --------------------------------------------------------------------------- +// Status +// --------------------------------------------------------------------------- + +const STATUS: Record = { + "Awaiting Scheduler Call": { + pill: "text-rose-700 bg-rose-50 ring-rose-200 dark:text-rose-300 dark:bg-rose-950/30 dark:ring-rose-900", + dot: "bg-rose-500", bar: "bg-rose-500", pulse: true, + }, + "Scheduled": { + pill: "text-amber-700 bg-amber-50 ring-amber-200 dark:text-amber-300 dark:bg-amber-950/30 dark:ring-amber-900", + dot: "bg-amber-500", bar: "bg-amber-500", + }, + "Awaiting Feedback Call": { + pill: "text-rose-700 bg-rose-50 ring-rose-200 dark:text-rose-300 dark:bg-rose-950/30 dark:ring-rose-900", + dot: "bg-rose-500", bar: "bg-rose-500", pulse: true, + }, + "Feedback Collected": { + pill: "text-emerald-700 bg-emerald-50 ring-emerald-200 dark:text-emerald-300 dark:bg-emerald-950/30 dark:ring-emerald-900", + dot: "bg-emerald-500", bar: "bg-emerald-500", + }, + "Scheduling Call Failed": { + pill: "text-red-700 bg-red-50 ring-red-200 dark:text-red-300 dark:bg-red-950/30 dark:ring-red-900", + dot: "bg-red-500", bar: "bg-red-500", + }, + "Feedback Call Failed": { + pill: "text-red-700 bg-red-50 ring-red-200 dark:text-red-300 dark:bg-red-950/30 dark:ring-red-900", + dot: "bg-red-500", bar: "bg-red-500", + }, + "End State": { + pill: "text-stone-600 bg-stone-100 ring-stone-200 dark:text-zinc-400 dark:bg-zinc-800 dark:ring-zinc-700", + dot: "bg-stone-400", bar: "bg-stone-400", + }, +}; +const DS = { pill: "text-stone-600 bg-stone-100 ring-stone-200 dark:text-zinc-400 dark:bg-zinc-800 dark:ring-zinc-700", dot: "bg-stone-400", bar: "bg-stone-300" }; + +const SKIP_KEYS = new Set(["analysis_so_far"]); + +// --------------------------------------------------------------------------- +// Skeleton +// --------------------------------------------------------------------------- + +function DetailSkeleton() { + return ( +
+
+
+
+
+
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+ ))} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +export default function DetailViewPanel({ viewId, instanceId }: Props) { + const [data, setData] = useState | null>(null); + const [fields, setFields] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!instanceId) return; + setLoading(true); setError(null); + getDVScreen(viewId) + .then(dv => getDetailView(dv.dv_template_id, instanceId)) + .catch(() => getDetailView(Number(viewId) || 0, instanceId)) + .then(res => { + setFields(res.config?.fields?.filter((f: any) => f.field_key !== "") ?? []); + setData(res.data ?? {}); + }) + .catch((e: any) => setError(e.message)) + .finally(() => setLoading(false)); + }, [viewId, instanceId]); + + if (loading) return ; + if (error) return

{error}

; + if (!data || !fields.length) return null; + + const sys = new Set(["instance_id", "current_state_name", "created_at", "updated_at"]); + const groups: Record = { hero: [], customer: [], vehicle: [], booking: [], feedback: [], ai: [], general: [] }; + + for (const f of fields) { + if (sys.has(f.field_key) || SKIP_KEYS.has(f.field_key)) continue; + const v = data[f.field_key]; + const g = classify(f.field_key); + if (g !== "hero" && (v == null || v === "")) continue; + groups[g].push(f); + } + + const heroField = groups.hero[0]; + const heroValue = heroField ? String(data[heroField.field_key] ?? "") : ""; + const stateName = String(data.current_state_name ?? ""); + const sc = STATUS[stateName] ?? DS; + + const phone = String(data.customer_phone ?? ""); + const model = String(data.model_interest ?? ""); + + const leftGroups = [ + { label: "Customer", fields: groups.customer }, + { label: "Vehicle", fields: groups.vehicle }, + { label: "Other", fields: groups.general }, + ].filter(g => g.fields.length > 0); + + const rightGroups = [ + { label: "Booking", fields: groups.booking }, + { label: "Feedback", fields: groups.feedback }, + ].filter(g => g.fields.length > 0); + + const isScore = (f: Field) => f.field_key.toLowerCase().includes("confidence") || f.field_key.toLowerCase().includes("rating") || f.field_key.toLowerCase().includes("score"); + const isLongText = (f: Field) => { const v = data[f.field_key]; return typeof v === "string" && v.length > 55; }; + + const aiShort = groups.ai.filter(f => isScore(f) || !isLongText(f)); + const aiLong = groups.ai.filter(f => !isScore(f) && isLongText(f)); + + return ( +
+ + {/* Status bar */} +
+ + {/* ── Header ──────────────────────────────────────────────────── */} +
+
+
+ {heroValue && ( +

+ {heroValue} +

+ )} + {stateName && ( + + + {stateName} + + )} +
+ + {model && ( +
+

Model Interest

+

{model}

+
+ )} +
+ +
+ {phone && ( + {phone} + )} + {phone && (data.created_at || data.instance_id) && ( + · + )} + {data.created_at && ( + + {fmtDate(String(data.created_at))} + + )} + {data.instance_id && ( + + Lead #{String(data.instance_id)} + + )} +
+
+ + {/* ── Two-column metadata ──────────────────────────────────────── */} + {(leftGroups.length > 0 || rightGroups.length > 0) && ( +
+
+ {leftGroups.map(({ label, fields: fs }) => ( +
+

{label}

+
+ {fs.map(f => ( +
+
{fmtLabel(f.output_label)}
+
+ {fmtVal(data[f.field_key], f.data_type, f.field_key)} +
+
+ ))} +
+
+ ))} +
+ +
+ {rightGroups.length > 0 ? rightGroups.map(({ label, fields: fs }) => ( +
+

+ {label === "Booking" && } + {label === "Feedback" && } + {label} +

+
+ {fs.map(f => ( +
+
{fmtLabel(f.output_label)}
+
+ {fmtVal(data[f.field_key], f.data_type, f.field_key)} +
+
+ ))} +
+
+ )) : ( +
+ Booking and feedback details will appear here once the AI completes its call. +
+ )} +
+
+ )} + + {/* ── AI Analysis ─────────────────────────────────────────────── */} + {groups.ai.length > 0 && ( +
+

+ AI · Call Outcome +

+ + {aiShort.length > 0 && ( +
+ {aiShort.map(f => { + const val = data[f.field_key]; + const score = isScore(f); + const num = score ? Number(val) : null; + const pct = num !== null ? Math.min(num > 1 ? num : num * 100, 100) : 0; + const barCls = pct >= 70 ? "bg-emerald-500" : pct >= 40 ? "bg-amber-500" : "bg-red-500"; + const pctCls = pct >= 70 ? "text-emerald-600 dark:text-emerald-400" : pct >= 40 ? "text-amber-600 dark:text-amber-400" : "text-red-500"; + return ( +
+
{fmtLabel(f.output_label)}
+ {score && num !== null ? ( +
+
+
+
+ {pct.toFixed(0)}% +
+ ) : ( +
+ {fmtVal(val, f.data_type, f.field_key)} +
+ )} +
+ ); + })} +
+ )} + + {aiLong.length > 0 && ( +
+ {aiLong.map(f => { + const val = data[f.field_key]; + const isClarif = f.field_key.includes("clarification") || f.field_key.includes("response"); + return ( +
+
+ {isClarif && } + {fmtLabel(f.output_label)} +
+ +
+ ); + })} +
+ )} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// ClampedText +// --------------------------------------------------------------------------- + +function ClampedText({ text }: { text: string }) { + const [expanded, setExpanded] = useState(false); + const [clamped, setClamped] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (el) setClamped(el.scrollHeight > el.clientHeight + 2); + }, [text]); + + return ( +
+

+ {text} +

+ {(clamped || expanded) && ( + + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function fmtLabel(label: string): string { + return label.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()); +} + +function fmtVal(val: unknown, type: string, key: string): string { + if (val == null || val === "") return "—"; + if (type === "number") { + const n = Number(val); + if (["amount","tax","price","cost"].some(x => key.toLowerCase().includes(x))) return fmtCurrency(n); + return n.toLocaleString("en-IN"); + } + if (type === "datetime" || type === "date") return fmtDate(String(val)); + return String(val); +} + +function fmtCurrency(n: number): string { + return n.toLocaleString("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 0 }); +} + +function fmtDate(val: string): string { + try { return new Date(val).toLocaleDateString("en-IN", { day: "2-digit", month: "short", year: "numeric" }); } + catch { return val; } +} diff --git a/src/components/DynamicHeader.tsx b/src/components/DynamicHeader.tsx new file mode 100644 index 0000000..952e13d --- /dev/null +++ b/src/components/DynamicHeader.tsx @@ -0,0 +1,211 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + LogOut, Car, PhoneOutgoing, CalendarCheck2, ClipboardList, MessageSquareHeart, Trophy, + Settings, PanelLeftClose, PanelLeftOpen, Sun, Moon, +} from "lucide-react"; +import { getHeaderConfig, type HeaderConfig, type NavItem } from "../api/viewService"; +import { useAuthContext } from "../hooks/AuthContext"; +import { useTheme } from "../hooks/ThemeContext"; + +// Map a screen name (from header config) → an icon. Best-effort, falls back to a car. +function iconFor(label: string) { + const k = label.toLowerCase(); + if (k.includes("lead")) return ; + if (k.includes("active") || k.includes("scheduling")) return ; + if (k.includes("scheduled")) return ; + if (k.includes("feedback")) return ; + if (k.includes("completed") || k.includes("done")) return ; + return ; +} + +interface Props { + activeScreenId: number | null; + onNavigate: (screenId: number) => void; + onSidebarChange?: (open: boolean) => void; +} + +export default function DynamicHeader({ activeScreenId, onNavigate, onSidebarChange }: Props) { + const { user, logout } = useAuthContext(); + const { theme, toggleTheme } = useTheme(); + const navigate = useNavigate(); + const [config, setConfig] = useState(null); + const [profileOpen, setProfileOpen] = useState(false); + const [sidebarOpen, setSidebarOpen] = useState(true); + + useEffect(() => { + getHeaderConfig("desktop").then(setConfig).catch(console.error); + }, []); + + const navItems = config?.components?.nav?.items ?? []; + const logoText = (config?.components as any)?.logo?.text || "Mahindra · Sales Ops"; + + const handleLogout = () => { logout(); navigate("/login"); }; + + const initials = (user?.name || "U") + .split(" ").map((n) => n[0]).join("").slice(0, 2).toUpperCase(); + const roleName = user?.roles?.[0] ?? ""; + + const toggleSidebar = () => { + const next = !sidebarOpen; + setSidebarOpen(next); + onSidebarChange?.(next); + }; + + return ( + <> + {/* ── Top bar ─────────────────────────────────────────────── */} +
+
+ +
+
+ +
+ + {logoText} + +
+
+ +
+ + +
+ + + {profileOpen && ( + <> +
setProfileOpen(false)} /> +
+
+
+
+ {initials} +
+
+
{user?.name}
+
{user?.email}
+
+
+ {roleName && ( +
+ + {roleName} + +
+ )} +
+
+ +
+ +
+
+ + )} +
+
+
+ + {/* ── Sidebar ─────────────────────────────────────────────── */} + + + ); +} diff --git a/src/components/FormModal.tsx b/src/components/FormModal.tsx new file mode 100644 index 0000000..1a382f7 --- /dev/null +++ b/src/components/FormModal.tsx @@ -0,0 +1,250 @@ +import { useEffect, useState, useRef } from "react"; +import { X, ArrowRight, AlertCircle, CheckCircle2, ChevronDown } from "lucide-react"; +import { getFormScreen, startWorkflow, performActivity } from "../api/viewService"; +import { WORKFLOW_ID } from "../config"; + +interface FormField { id: string; uid: string; name: string; type: string; data_type: string; mandatory: boolean; value?: any; options?: Array<{ label: string; value: string }>; } +interface GridItem { i: string; x: number; y: number; w: number; h: number; } + +interface Props { + activityId: string; + instanceId?: string; + title?: string; + onClose: () => void; + onSuccess: () => void; +} + +export default function FormModal({ activityId, instanceId, title, onClose, onSuccess }: Props) { + const [fields, setFields] = useState([]); + const [gridConfig, setGridConfig] = useState([]); + const [formTitle, setFormTitle] = useState(title || ""); + const [formData, setFormData] = useState>({}); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [touched, setTouched] = useState>(new Set()); + const [success, setSuccess] = useState(false); + const [visible, setVisible] = useState(false); + const modalRef = useRef(null); + + useEffect(() => { requestAnimationFrame(() => setVisible(true)); }, []); + + useEffect(() => { + getFormScreen(activityId, instanceId) + .then((res: any) => { + const f: FormField[] = res.fields ?? res.form_json?.fields ?? []; + setFields(f); + setGridConfig(res.grid_config ?? []); + setFormTitle(res.activity_name || title || "Form"); + const pre: Record = {}; + f.forEach((field) => { if (field.value != null) pre[field.id] = String(field.value); }); + setFormData(pre); + }) + .catch((err: any) => setError(err.message)) + .finally(() => setLoading(false)); + }, [activityId, instanceId, title]); + + const animateClose = (callback: () => void) => { + setVisible(false); + setTimeout(callback, 200); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setTouched(new Set(fields.map((f) => f.id))); + + const missing = fields.filter((f) => f.mandatory && !formData[f.id]?.trim()); + if (missing.length > 0) { + setError(`Please fill in: ${missing.map((f) => f.name).join(", ")}`); + return; + } + + setSubmitting(true); setError(null); + try { + const data: Record = {}; + for (const f of fields) { const v = formData[f.id] ?? ""; data[f.id] = f.data_type === "number" ? (v ? Number(v) : 0) : v; } + instanceId ? await performActivity(WORKFLOW_ID, instanceId, activityId, data) : await startWorkflow(WORKFLOW_ID, activityId, data); + setSuccess(true); + setTimeout(() => animateClose(onSuccess), 800); + } catch (err: any) { setError(err.message || "Failed"); } + finally { setSubmitting(false); } + }; + + const sorted = [...fields].sort((a, b) => { + const ga = gridConfig.find((g) => g.i === a.uid), gb = gridConfig.find((g) => g.i === b.uid); + if (!ga || !gb) return 0; + return ga.y !== gb.y ? ga.y - gb.y : ga.x - gb.x; + }); + + const rows: FormField[][] = []; let cy = -1; + for (const f of sorted) { const g = gridConfig.find((gi) => gi.i === f.uid); const y = g?.y ?? rows.length; if (y !== cy) { rows.push([]); cy = y; } rows[rows.length - 1].push(f); } + + const isMissing = (f: FormField) => f.mandatory && touched.has(f.id) && !formData[f.id]?.trim(); + + return ( +
animateClose(onClose)}> +
+ +
e.stopPropagation()} + > + {success && ( +
+
+ +
+
Submitted successfully
+
+ )} + + {/* ── Header ── */} +
+
+

{formTitle}

+

+ {instanceId ? "Update the details below" : "Fill in the details to submit"} +

+
+ +
+ + {/* ── Body ── */} +
+ {loading ? ( +
+ {[0, 1, 2, 3].map((r) => ( +
+
+
+
+ ))} +
+ ) : ( +
+ {rows.map((row, ri) => ( +
1 ? "grid-cols-2" : "grid-cols-1"}`}> + {row.map((f) => ( +
+ + {renderInput(f, formData[f.id] ?? "", (v) => { + setFormData((p) => ({ ...p, [f.id]: v })); + setTouched((t) => new Set(t).add(f.id)); + if (error) setError(null); + }, isMissing(f))} + {isMissing(f) && ( +

+ Required +

+ )} +
+ ))} +
+ ))} +
+ )} + + {error && ( +
+ + {error} +
+ )} +
+ + {/* ── Footer ── */} + {!loading && ( +
+
+ * Required +
+
+ + +
+
+ )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Input renderer +// --------------------------------------------------------------------------- + +function renderInput(f: FormField, value: string, onChange: (v: string) => void, hasError: boolean) { + const base = `w-full h-10 border rounded-xl px-3.5 text-[13px] bg-stone-50/50 dark:bg-zinc-800/70 focus:bg-white dark:focus:bg-zinc-800 text-stone-800 dark:text-zinc-100 focus:outline-none focus:ring-2 transition placeholder:text-stone-400 dark:placeholder:text-zinc-600 ${ + hasError + ? "border-red-300 dark:border-red-700 focus:ring-red-500/20 focus:border-red-400" + : "border-stone-200 dark:border-zinc-700 focus:ring-[#C8102E]/20 focus:border-[#C8102E] dark:focus:border-rose-500" + }`; + + if (f.options && f.options.length > 0) { + return ( +
+ + +
+ ); + } + + switch (f.data_type) { + case "longtext": + return ( +