inital commit

This commit is contained in:
Bhanu Prakash Sai Potteri 2026-05-29 19:57:38 +05:30
commit d16cd8d86e
53 changed files with 12114 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.local

File diff suppressed because it is too large Load Diff

16
index.html Normal file
View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/zino.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Poppins:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
<title>Test Drive Lead Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3120
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "tech-mahindra",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc -b",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.60.5",
"lucide-react": "^0.441.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0",
"recharts": "^2.13.3"
},
"devDependencies": {
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.41",
"tailwindcss": "^3.4.10",
"typescript": "~5.5.4",
"vite": "^5.4.2"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
public/cars/bolero-neo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
public/cars/scorpio-n.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
public/cars/thar-roxx.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
public/cars/xuv3xo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
public/cars/xuv700.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

9
public/zino.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 27 KiB

BIN
src/.DS_Store vendored Normal file

Binary file not shown.

37
src/App.tsx Normal file
View File

@ -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 (
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/screen/:screenId/detail/:instanceId"
element={
<ProtectedRoute>
<AppShell />
</ProtectedRoute>
}
/>
<Route
path="/screen/:screenId"
element={
<ProtectedRoute>
<AppShell />
</ProtectedRoute>
}
/>
<Route
path="/"
element={
<ProtectedRoute>
<AppShell />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

422
src/api/viewService.ts Normal file
View File

@ -0,0 +1,422 @@
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<string, string> {
const h: Record<string, string> = { "Content-Type": "application/json" };
const token = localStorage.getItem(TOKEN_KEY);
if (token) h["Authorization"] = `Bearer ${token}`;
return h;
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
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 = `${import.meta.env.BASE_URL}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<T>;
}
// ---------------------------------------------------------------------------
// 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_uuid: string;
}
export async function getHeaderConfig(deviceType = "desktop"): Promise<HeaderConfig> {
const raw = await request<any>("GET", `/app/${APP_ID}/view/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<string, string>;
config: Record<string, any>;
classes?: string[];
}
export function getScreens(deviceType = "desktop"): Promise<ScreenListItem[]> {
return request("GET", `/app/${APP_ID}/view/screens?device_type=${deviceType}`);
}
export function getScreen(idOrUuid: number | string): Promise<ScreenLayout> {
return request("GET", `/app/${APP_ID}/view/screens/${idOrUuid}`);
}
// ---------------------------------------------------------------------------
// RV Screens
// ---------------------------------------------------------------------------
export interface RVScreen {
id: number;
source_screen_id: number;
screen_name: string;
rv_template_uid: string;
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<RVScreen> {
return request("GET", `/app/${APP_ID}/view/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 TileValue {
tile_uid: string;
key: string;
value: unknown;
}
export interface ChartDataRow {
dimension: unknown;
series?: string;
value: unknown;
}
export interface ChartDataResponse {
chart_uid: string;
key: string;
rows: ChartDataRow[];
}
export interface RecordViewResponse {
config: { fields: RecordViewField[] };
data: Record<string, unknown>[];
pagination?: {
page: number;
limit: number;
total_count: number;
total_pages: number;
};
tile_values?: TileValue[];
chart_data?: ChartDataResponse[];
}
export function getRecordView(
rvTemplateUid: string,
rvScreenId: string | number,
query: SearchQuery = {},
): Promise<RecordViewResponse> {
return request("POST", `/app/${APP_ID}/view/recordview`, {
rv_template_uid: rvTemplateUid,
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<RecordViewResponse> {
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_uid: string;
layout?: any;
}
export function getDVScreen(idOrUuid: number | string): Promise<DVScreen> {
return request("GET", `/app/${APP_ID}/view/dv-screens/${idOrUuid}`);
}
// ---------------------------------------------------------------------------
// Detail View
// ---------------------------------------------------------------------------
export interface DetailViewResponse {
config: { fields: Array<{ field_key: string; output_label: string; data_type: string }> };
data: Record<string, unknown>;
}
export function getDetailView(dvSourceIdOrUid: number | string, instanceId: string): Promise<DetailViewResponse> {
return request(
"GET",
`/app/${APP_ID}/view/detailview/${encodeURIComponent(String(dvSourceIdOrUid))}?instance_id=${encodeURIComponent(instanceId)}`,
);
}
export function getDetailViewLegacy(dvUid: string, instanceId: string): Promise<DetailViewResponse> {
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;
properties?: {
options?: Array<{ label: string; value: string }>;
country_code?: 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<FormScreenResponse> {
const numericInstanceId = instanceId ? Number(instanceId) : undefined;
return request("POST", `/app/${APP_ID}/view/form-screens`, {
activity_id: activityId,
device_type: deviceType,
...(numericInstanceId && !Number.isNaN(numericInstanceId)
? { instance_id: numericInstanceId }
: {}),
});
}
// ---------------------------------------------------------------------------
// Workflow Actions
// ---------------------------------------------------------------------------
export interface WorkflowResponse {
success: boolean;
status_code?: number;
message?: string;
data?: Record<string, unknown>;
instance_id?: string;
}
// Core-service expects the workflow UUID as `workflow_uuid` — the numeric
// `workflow_id` field was removed when the clone-portable identifier rolled out.
export function startWorkflow(
workflowUuid: string,
activityId: string,
data?: Record<string, unknown>,
): Promise<WorkflowResponse> {
return request("POST", `/app/${APP_ID}/start`, {
workflow_uuid: workflowUuid,
activity_id: activityId,
data,
});
}
export function performActivity(
workflowUuid: string,
instanceId: string,
activityId: string,
data?: Record<string, unknown>,
): Promise<WorkflowResponse> {
const numericInstanceId = Number(instanceId);
return request("POST", `/app/${APP_ID}/activity`, {
workflow_uuid: workflowUuid,
instance_id: Number.isNaN(numericInstanceId) ? instanceId : numericInstanceId,
activity_id: activityId,
data,
});
}
export function submitForm(
workflowUuid: string,
activityId: string,
formData: Record<string, unknown>,
instanceId?: string,
deviceType = "desktop",
): Promise<WorkflowResponse> {
const numericInstanceId = instanceId ? Number(instanceId) : undefined;
return request("POST", `/app/${APP_ID}/form/submit`, {
workflow_uuid: workflowUuid,
activity_id: activityId,
device_type: deviceType,
form_data: formData,
...(numericInstanceId && !Number.isNaN(numericInstanceId)
? { instance_id: numericInstanceId }
: {}),
});
}
// ---------------------------------------------------------------------------
// Instance
// ---------------------------------------------------------------------------
export interface InstanceResponse {
instance_id: string;
workflow_id: string;
current_state_id: string;
current_state_name: string;
data: Record<string, unknown>;
created_at: string;
updated_at: string;
}
export function getInstance(workflowUuid: string, instanceId: string): Promise<InstanceResponse> {
return request("POST", `/app/${APP_ID}/instance`, {
workflow_uuid: workflowUuid,
instance_id: Number(instanceId),
});
}
// ---------------------------------------------------------------------------
// Audit
// ---------------------------------------------------------------------------
export interface AuditEntry {
id: number;
user_id: string;
user_roles: string[];
activity_id: string;
data: Record<string, unknown>;
execution_state: string;
created_at: string;
// Optional human-readable summary line. Populated by useInstanceMeta when
// the audit is synthesised from instance.data._activities.
context?: string;
}
export function getAuditLog(instanceId: string): Promise<AuditEntry[]> {
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<string, unknown>[];
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<RdbmsLookupResponse> {
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 ?? "",
});
}

368
src/components/AppShell.tsx Normal file
View File

@ -0,0 +1,368 @@
import { useEffect, useState, useCallback } from "react";
import { useNavigate, useParams, useSearchParams } 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 VariantB from "./variants/VariantB";
import {
getHeaderConfig, getScreen, getAuditLog, getInstance,
type LayoutElement, type NavItem, type AuditEntry,
} from "../api/viewService";
import { WORKFLOW_ID, ACTIVITY_IDS, STATE_IDS, DETAIL_VIEW_IDS, SCREEN_TO_DV } from "../config";
import { fmtAct } from "../lib/activity";
// State → contextual action buttons shown above the detail view.
const ACTIVITY_META: Record<string, { label: string; icon: React.ReactNode; style: "primary" | "ghost" }> = {
[ACTIVITY_IDS.MARK_TEST_DRIVE_DONE]: { label: "Mark Test Drive Done", icon: <CheckCircle2 size={14} />, style: "primary" },
[ACTIVITY_IDS.RETRY_SCHEDULING_CALL]: { label: "Retry Scheduling Call", icon: <RefreshCw size={14} />, style: "primary" },
[ACTIVITY_IDS.RETRY_FEEDBACK_CALL]: { label: "Retry Feedback Call", icon: <RefreshCw size={14} />, style: "primary" },
[ACTIVITY_IDS.MARK_EXPERT_TD_DONE]: { label: "Mark Expert TD Done", icon: <CheckCircle2 size={14} />, style: "primary" },
};
const BTN_STYLES: Record<string, string> = {
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<string, string> = {
[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.EXPERT_TD_SCHEDULED]: "Expert TD Scheduled",
[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 || stateId === STATE_IDS.EXPERT_TD_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 [searchParams] = useSearchParams();
// ?variant=current escape hatch to the old red UI; default is Variant B (Insights Studio).
if (searchParams.get("variant") === "current") return <CurrentAppShell />;
return <VariantB />;
}
function CurrentAppShell() {
const navigate = useNavigate();
const params = useParams();
const screenIdParam = params.screenId || null;
const instanceIdParam = params.instanceId;
const [activeScreenId, setActiveScreenId] = useState<string | null>(screenIdParam);
const [layout, setLayout] = useState<LayoutElement[]>([]);
const [loading, setLoading] = useState(true);
const [ready, setReady] = useState(false);
const [detailInstanceId, setDetailInstanceId] = useState<string | null>(instanceIdParam || null);
const [refreshKey, setRefreshKey] = useState(0);
const [formModal, setFormModal] = useState<{ activityId: string; instanceId: string; title: string } | null>(null);
const [instanceState, setInstanceState] = useState<string | null>(null);
const [audit, setAudit] = useState<AuditEntry[]>([]);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [navItems, setNavItems] = useState<NavItem[]>([]);
// 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_uuid);
})
.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((uuid: string) => {
setActiveScreenId(uuid);
setDetailInstanceId(null);
setInstanceState(null);
setAudit([]);
navigate(`/screen/${uuid}`);
}, [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];
if (instanceState === STATE_IDS.EXPERT_TD_SCHEDULED) return [ACTIVITY_IDS.MARK_EXPERT_TD_DONE];
return [];
})();
const aiInFlight =
instanceState === STATE_IDS.AWAITING_SCHEDULER_CALL ||
instanceState === STATE_IDS.AWAITING_FEEDBACK_CALL;
if (!ready) {
return (
<div className="min-h-screen bg-stone-50 dark:bg-zinc-950 flex justify-center items-center">
<div className="flex flex-col items-center gap-3">
<Loader2 size={22} className="animate-spin text-[#C8102E]" />
<span className="text-[12px] text-stone-400 dark:text-zinc-600">Loading</span>
</div>
</div>
);
}
const tone = stateTone(instanceState);
const stateLabel = instanceState ? STATE_LABEL[instanceState] || instanceState : "";
return (
<div className="min-h-screen bg-stone-50/60 dark:bg-zinc-950">
<DynamicHeader
activeScreenId={activeScreenId}
onNavigate={handleNavigate}
onSidebarChange={setSidebarOpen}
/>
<main className={`transition-all duration-200 py-6 px-7 ${sidebarOpen ? "ml-60" : "ml-0"}`}>
<div className="w-full max-w-7xl mx-auto">
{detailInstanceId ? (
<div className="space-y-4">
{/* Breadcrumb + actions */}
<div className="flex items-center justify-between">
<nav className="flex items-center gap-1.5 text-[13px]">
<button
onClick={handleBack}
className="text-stone-400 dark:text-zinc-500 hover:text-[#C8102E] dark:hover:text-rose-400 transition-colors font-medium"
>
{navItems.find((n) => n.screen_uuid === activeScreenId)?.label ?? "Pipeline"}
</button>
<span className="text-stone-300 dark:text-zinc-700">/</span>
<span className="text-stone-700 dark:text-zinc-200 font-medium">
Lead #{detailInstanceId}
</span>
{stateLabel && (
<span
className="ml-2 text-[10px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded-full"
style={{ background: tone.bg, color: tone.fg, border: `1px solid ${tone.ring}` }}
>
{stateLabel}
</span>
)}
</nav>
{actions.length > 0 && (
<div className="flex items-center gap-2">
{actions.map((id) => {
const m = ACTIVITY_META[id];
if (!m) return null;
return (
<button
key={id}
onClick={() => setFormModal({ activityId: id, instanceId: detailInstanceId, title: m.label })}
className={`h-9 px-4 text-[12px] font-semibold rounded-xl transition-all flex items-center gap-1.5 ${BTN_STYLES[m.style]}`}
style={m.style === "primary" ? { background: "linear-gradient(135deg, #C8102E, #E84258)" } : {}}
>
{m.icon}{m.label}
</button>
);
})}
</div>
)}
</div>
{/* AI banner */}
{aiInFlight && (
<div
className="rounded-2xl px-5 py-3.5 border flex items-center gap-3"
style={{ background: "rgba(200,16,46,0.06)", borderColor: "rgba(200,16,46,0.22)" }}
>
<div className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 gradient-mh">
<Sparkles size={14} className="text-white" />
</div>
<div className="flex-1 min-w-0">
<div className="text-[12.5px] font-semibold text-[#9D0D24]">Maya is on the line</div>
<div className="text-[11.5px] text-stone-500 dark:text-zinc-400 mt-0.5">
The AI scheduler is placing the call right now. This page refreshes on completion.
</div>
</div>
</div>
)}
{/* Detail panel picks the dv-screen matching the active rv;
falls back to the shared dv-template UID when no mapping exists. */}
<DetailViewPanel
key={`dv-${detailInstanceId}-${refreshKey}`}
viewId={
(activeScreenId && SCREEN_TO_DV[activeScreenId]) ||
DETAIL_VIEW_IDS.INSTANCE_DETAIL
}
instanceId={detailInstanceId}
/>
{/* Activity timeline */}
{audit.length > 0 && (
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-stone-200 dark:border-zinc-700/60 overflow-hidden">
<div className="px-5 py-3 border-b border-stone-100 dark:border-zinc-800 flex items-center gap-2.5">
<div
className="w-6 h-6 rounded-lg flex items-center justify-center"
style={{ background: "rgba(200,16,46,0.10)" }}
>
<Clock size={13} className="text-[#C8102E]" />
</div>
<h3 className="text-[13px] font-semibold text-stone-700 dark:text-zinc-200">Activity Timeline</h3>
<span className="text-[11px] text-stone-300 dark:text-zinc-600 font-medium">
{audit.length} events
</span>
</div>
<div className="px-5 py-4">
<div className="relative pl-6">
<div
className="absolute left-[7px] top-2 bottom-2 w-px"
style={{ background: "linear-gradient(to bottom, rgba(200,16,46,0.35), rgba(232,66,88,0.15), transparent)" }}
/>
<div className="space-y-4">
{audit.map((e, i) => {
const isFirst = i === 0;
const isLast = i === audit.length - 1;
return (
<div key={e.id} className="flex items-start gap-3 relative">
<div
className={`absolute -left-6 w-3.5 h-3.5 rounded-full border-2 border-white dark:border-zinc-900 mt-0.5 shadow-sm ${
isLast ? "ring-2 ring-rose-200 dark:ring-rose-900" : ""
}`}
style={{
background: isLast ? "#C8102E" : isFirst ? "#10b981" : "#d1d5db",
boxShadow: isLast ? "0 0 8px rgba(200,16,46,0.5)" : undefined,
}}
/>
<div className="flex-1 py-0.5">
<div className="flex items-center gap-2">
<span
className={`text-[13px] font-medium ${
isLast ? "text-[#9D0D24] dark:text-rose-300" : "text-stone-700 dark:text-zinc-200"
}`}
>
{fmtAct(e.activity_id)}
</span>
{isLast && (
<span
className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full"
style={{ background: "rgba(200,16,46,0.10)", color: "#C8102E" }}
>
Latest
</span>
)}
</div>
<span className="text-[11px] text-stone-400 dark:text-zinc-500">
{new Date(e.created_at).toLocaleString("en-IN", {
day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit",
})}
</span>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
)}
</div>
) : loading ? (
<TableSkeleton rows={6} cols={6} />
) : layout.length > 0 ? (
<ScreenRenderer
key={`screen-${activeScreenId}-${refreshKey}`}
layout={layout}
onRowClick={handleRowClick}
onRefresh={() => setRefreshKey((k) => k + 1)}
/>
) : (
<div className="text-center py-20 text-sm text-stone-400 dark:text-zinc-600">
Select a screen from the navigation
</div>
)}
</div>
</main>
{formModal && (
<FormModal
activityId={formModal.activityId}
instanceId={formModal.instanceId}
title={formModal.title}
onClose={() => setFormModal(null)}
onSuccess={handleFormSuccess}
/>
)}
</div>
);
}

View File

@ -0,0 +1,51 @@
import { BarChart as RBarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid, ResponsiveContainer, Cell } from "recharts";
interface Row {
dimension: unknown;
value: unknown;
}
interface Props {
title?: string;
rows: Row[];
loading?: boolean;
}
const RED_PALETTE = ["#C8102E", "#E84258", "#F26471", "#F69199", "#FABFBF"];
export default function BarChart({ title, rows, loading }: Props) {
const data = (rows ?? [])
.map(r => ({ name: String(r.dimension ?? "—"), value: Number(r.value ?? 0) }))
.filter(d => d.name && d.name !== "—" && d.value > 0);
return (
<div className="flex-1 min-w-[280px] bg-white dark:bg-zinc-900 rounded-2xl border border-stone-200/80 dark:border-zinc-700/60 px-5 py-4 shadow-sm">
{title && (
<div className="text-[11px] font-semibold uppercase tracking-widest text-stone-400 dark:text-zinc-500 mb-3">
{title}
</div>
)}
{loading ? (
<div className="space-y-2">
{[0,1,2,3].map(i => <div key={i} className="h-5 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse" />)}
</div>
) : data.length === 0 ? (
<div className="text-[12px] text-stone-400 dark:text-zinc-500 py-4 text-center">No data</div>
) : (
<div style={{ width: "100%", height: 220 }}>
<ResponsiveContainer>
<RBarChart data={data} margin={{ top: 5, right: 10, left: -10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
<XAxis dataKey="name" tick={{ fontSize: 11, fill: "#78716c" }} interval={0} />
<YAxis tick={{ fontSize: 11, fill: "#78716c" }} allowDecimals={false} />
<Tooltip cursor={{ fill: "#fef2f2" }} contentStyle={{ fontSize: 12, borderRadius: 8 }} />
<Bar dataKey="value" radius={[6, 6, 0, 0]}>
{data.map((_, i) => <Cell key={i} fill={RED_PALETTE[i % RED_PALETTE.length]} />)}
</Bar>
</RBarChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,517 @@
import { useEffect, useState, useRef } from "react";
import { Hash, Calendar, Sparkles, TrendingUp, MessageSquare, ChevronDown, ChevronUp, Activity } from "lucide-react";
import { getDVScreen, getDetailView, getInstance, type InstanceResponse } from "../api/viewService";
import { WORKFLOW_ID, STATE_IDS, ACTIVITY_IDS } from "../config";
import { fmtAct } from "../lib/activity";
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<string, { pill: string; dot: string; bar: string; pulse?: boolean }> = {
"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 (
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-stone-200/80 dark:border-zinc-700/60 overflow-hidden">
<div className="h-[3px] bg-stone-100 dark:bg-zinc-800" />
<div className="px-7 pt-5 pb-4 border-b border-stone-100 dark:border-zinc-800 space-y-3">
<div className="h-6 w-48 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse" />
<div className="h-4 w-32 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse" />
</div>
<div className="px-7 py-5 grid grid-cols-2 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-2">
<div className="h-3 w-20 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse" />
<div className="h-4 w-32 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse" />
</div>
))}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
export default function DetailViewPanel({ viewId, instanceId }: Props) {
const [data, setData] = useState<Record<string, unknown> | null>(null);
const [fields, setFields] = useState<Field[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!instanceId) return;
setLoading(true); setError(null);
getDVScreen(viewId)
.then(dv => getDetailView(dv.dv_template_uid, instanceId))
.catch(() => getDetailView(viewId, 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 <DetailSkeleton />;
if (error) return <p className="text-sm text-red-500 dark:text-red-400">{error}</p>;
if (!data || !fields.length) return null;
const sys = new Set(["instance_id", "current_state_name", "created_at", "updated_at"]);
const groups: Record<GroupKey, Field[]> = { 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 g = classify(f.field_key);
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 = fmtPhone(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 (
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-stone-200/80 dark:border-zinc-700/60 overflow-hidden">
{/* Status bar */}
<div className={`h-[3px] ${sc.bar}`} />
{/* ── Header ──────────────────────────────────────────────────── */}
<div className="px-7 pt-5 pb-4 border-b border-stone-100 dark:border-zinc-800">
<div className="flex items-start justify-between gap-6 mb-3">
<div className="min-w-0 flex items-center gap-3 flex-wrap">
{heroValue && (
<h1 className="text-[20px] font-bold tracking-tight text-stone-900 dark:text-white leading-none">
{heroValue}
</h1>
)}
{stateName && (
<span className={`inline-flex items-center gap-1.5 text-[11px] font-semibold px-2.5 py-1 rounded-full ring-1 shrink-0 ${sc.pill}`}>
<span className={`w-1.5 h-1.5 rounded-full ${sc.dot} ${sc.pulse ? "animate-pulse" : ""}`} />
{stateName}
</span>
)}
</div>
{model && (
<div className="text-right shrink-0">
<p className="text-[10px] font-medium text-stone-400 dark:text-zinc-600 mb-0.5 uppercase tracking-wider">Model Interest</p>
<p className="text-[18px] font-bold text-stone-900 dark:text-white leading-none">{model}</p>
</div>
)}
</div>
<div className="flex items-center gap-3 flex-wrap text-[12px]">
{phone && (
<span className="font-medium text-stone-600 dark:text-zinc-300">{phone}</span>
)}
{phone && (data.created_at || data.instance_id) && (
<span className="text-stone-200 dark:text-zinc-700">·</span>
)}
{data.created_at && (
<span className="flex items-center gap-1 text-stone-400 dark:text-zinc-500">
<Calendar size={11} />{fmtDate(String(data.created_at))}
</span>
)}
{data.instance_id && (
<span className="flex items-center gap-1 text-stone-400 dark:text-zinc-500 font-mono text-[11px]">
<Hash size={10} />Lead #{String(data.instance_id)}
</span>
)}
</div>
</div>
{/* ── Two-column metadata ──────────────────────────────────────── */}
{(leftGroups.length > 0 || rightGroups.length > 0) && (
<div className="grid lg:grid-cols-2 divide-y lg:divide-y-0 lg:divide-x divide-stone-100 dark:divide-zinc-800 border-b border-stone-100 dark:border-zinc-800">
<div className="px-7 py-5 space-y-6">
{leftGroups.map(({ label, fields: fs }) => (
<section key={label}>
<p className="text-[10px] font-bold uppercase tracking-widest text-stone-400 dark:text-zinc-600 mb-3">{label}</p>
<dl className="grid grid-cols-2 gap-x-8 gap-y-4">
{fs.map(f => (
<div key={f.field_key}>
<dt className="text-[11px] text-stone-400 dark:text-zinc-500 mb-0.5">{fmtLabel(f.output_label)}</dt>
<dd className="text-[14px] font-medium text-stone-900 dark:text-zinc-100 break-words">
{fmtVal(data[f.field_key], f.data_type, f.field_key)}
</dd>
</div>
))}
</dl>
</section>
))}
</div>
<div className="px-7 py-5 space-y-6">
{rightGroups.length > 0 ? rightGroups.map(({ label, fields: fs }) => (
<section key={label}>
<p className="text-[10px] font-bold uppercase tracking-widest text-stone-400 dark:text-zinc-600 mb-3 flex items-center gap-1.5">
{label === "Booking" && <TrendingUp size={11} className="text-amber-500" />}
{label === "Feedback" && <MessageSquare size={11} className="text-emerald-500" />}
{label}
</p>
<dl className="grid grid-cols-2 gap-x-8 gap-y-4">
{fs.map(f => (
<div key={f.field_key}>
<dt className="text-[11px] text-stone-400 dark:text-zinc-500 mb-0.5">{fmtLabel(f.output_label)}</dt>
<dd className="text-[14px] font-medium text-stone-900 dark:text-zinc-100 break-words">
{fmtVal(data[f.field_key], f.data_type, f.field_key)}
</dd>
</div>
))}
</dl>
</section>
)) : (
<div className="text-[13px] text-stone-300 dark:text-zinc-700 italic">
Booking and feedback details will appear here once the AI completes its call.
</div>
)}
</div>
</div>
)}
{/* ── AI Analysis ─────────────────────────────────────────────── */}
{groups.ai.length > 0 && (
<div className="px-7 py-5">
<p className="text-[10px] font-bold uppercase tracking-widest text-stone-400 dark:text-zinc-600 mb-4 flex items-center gap-1.5">
<Sparkles size={10} className="text-[#C8102E]" />AI · Call Outcome
</p>
{aiShort.length > 0 && (
<dl className="grid grid-cols-2 sm:grid-cols-3 gap-x-8 gap-y-5 mb-5">
{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 (
<div key={f.field_key}>
<dt className="text-[11px] text-stone-400 dark:text-zinc-500 mb-1">{fmtLabel(f.output_label)}</dt>
{score && num !== null ? (
<dd className="flex items-center gap-2.5">
<div className="flex-1 h-1.5 bg-stone-100 dark:bg-zinc-800 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${barCls}`} style={{ width: `${pct}%` }} />
</div>
<span className={`text-[12px] font-bold tabular-nums w-8 text-right ${pctCls}`}>{pct.toFixed(0)}%</span>
</dd>
) : (
<dd className="text-[14px] font-semibold text-stone-900 dark:text-zinc-100">
{fmtVal(val, f.data_type, f.field_key)}
</dd>
)}
</div>
);
})}
</dl>
)}
{aiLong.length > 0 && (
<div className="space-y-4 border-t border-stone-100 dark:border-zinc-800 pt-4">
{aiLong.map(f => {
const val = data[f.field_key];
const isClarif = f.field_key.includes("clarification") || f.field_key.includes("response");
return (
<div key={f.field_key} className="border-b border-stone-50 dark:border-zinc-800/50 pb-4 last:border-0 last:pb-0">
<div className="flex items-center gap-1.5 mb-1.5">
{isClarif && <MessageSquare size={11} className="text-amber-400 shrink-0" />}
<span className="text-[11px] font-medium text-stone-400 dark:text-zinc-500">{fmtLabel(f.output_label)}</span>
</div>
<ClampedText text={String(val ?? "—")} />
</div>
);
})}
</div>
)}
</div>
)}
{/* ── Activity timeline ──────────────────────────────────────── */}
{data.instance_id && <TimelineSection instanceId={String(data.instance_id)} />}
</div>
);
}
// ---------------------------------------------------------------------------
// TimelineSection — derived from instance.data._activities (no audit endpoint
// needed). INIT is synthesised from instance.created_at since the platform
// doesn't write INIT into _activities.
// ---------------------------------------------------------------------------
interface ActivityRecord {
_system?: { created_at?: string; user_id?: string; user_roles?: string[] | null };
}
interface TimelineEntry {
activityId: string;
createdAt: string;
userRoles: string[] | null;
synthetic: boolean;
}
const FAILURE_STATE_IDS = new Set<string>([
STATE_IDS.SCHEDULING_CALL_FAILED,
STATE_IDS.FEEDBACK_CALL_FAILED,
]);
function TimelineSection({ instanceId }: { instanceId: string }) {
const [inst, setInst] = useState<InstanceResponse | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!instanceId) return;
let cancelled = false;
setInst(null); setError(null);
getInstance(WORKFLOW_ID, instanceId)
.then((r) => !cancelled && setInst(r))
.catch((e: any) => !cancelled && setError(e?.message ?? "failed to load"));
return () => { cancelled = true; };
}, [instanceId]);
if (error) return null;
if (inst === null) {
return (
<div className="px-7 py-5 border-t border-stone-100 dark:border-zinc-800 text-[12px] text-stone-300 dark:text-zinc-700">
Loading activity timeline
</div>
);
}
const activities = (inst.data as { _activities?: Record<string, ActivityRecord> } | null)?._activities;
const entries: TimelineEntry[] = [];
// Synthesise INIT from instance.created_at — _activities never includes it.
entries.push({
activityId: ACTIVITY_IDS.INIT_ACTIVITY,
createdAt: inst.created_at,
userRoles: null,
synthetic: true,
});
if (activities) {
for (const [activityId, record] of Object.entries(activities)) {
entries.push({
activityId,
createdAt: record._system?.created_at ?? inst.created_at,
userRoles: record._system?.user_roles ?? null,
synthetic: false,
});
}
}
entries.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
const lastIdx = entries.length - 1;
const inFailureState = FAILURE_STATE_IDS.has(inst.current_state_id);
return (
<div className="px-7 py-5 border-t border-stone-100 dark:border-zinc-800">
<p className="text-[10px] font-bold uppercase tracking-widest text-stone-400 dark:text-zinc-600 mb-4 flex items-center gap-1.5">
<Activity size={10} className="text-[#C8102E]" /> Activity Timeline
</p>
<ol className="relative pl-6">
<span aria-hidden className="absolute left-[7px] top-1 bottom-1 w-px bg-stone-200 dark:bg-zinc-700" />
{entries.map((e, i) => {
const isLast = i === lastIdx;
const isFirst = i === 0;
const failed = isLast && inFailureState;
const dotBg = failed ? "#dc2626" : isLast ? "#C8102E" : isFirst ? "#10b981" : "#d1d5db";
const actor = (e.userRoles && e.userRoles.length > 0) ? e.userRoles.join(", ") : "system";
return (
<li key={`${e.activityId}-${e.createdAt}`} className="relative pb-4 last:pb-0">
<span
aria-hidden
className={`absolute -left-[22px] top-1 w-3.5 h-3.5 rounded-full border-2 border-white dark:border-zinc-900 shadow-sm ${isLast ? "ring-2 ring-rose-200 dark:ring-rose-900" : ""}`}
style={{ background: dotBg, boxShadow: isLast ? "0 0 8px rgba(200,16,46,0.5)" : undefined }}
/>
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-[13px] font-medium ${
failed
? "text-red-600 dark:text-red-400"
: isLast
? "text-[#9D0D24] dark:text-rose-300"
: "text-stone-700 dark:text-zinc-200"
}`}>
{fmtAct(e.activityId)}
</span>
{failed && (
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-300">
Failed
</span>
)}
{isLast && !failed && (
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full"
style={{ background: "rgba(200,16,46,0.10)", color: "#C8102E" }}>
Latest
</span>
)}
</div>
<div className="text-[11px] text-stone-400 dark:text-zinc-500 mt-0.5">
{new Date(e.createdAt).toLocaleString("en-IN", {
day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit",
})}
<span className="mx-1.5 text-stone-200 dark:text-zinc-700">·</span>
<span className="text-stone-500 dark:text-zinc-400">{actor}</span>
</div>
</li>
);
})}
</ol>
</div>
);
}
// ---------------------------------------------------------------------------
// ClampedText
// ---------------------------------------------------------------------------
function ClampedText({ text }: { text: string }) {
const [expanded, setExpanded] = useState(false);
const [clamped, setClamped] = useState(false);
const ref = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const el = ref.current;
if (el) setClamped(el.scrollHeight > el.clientHeight + 2);
}, [text]);
return (
<div>
<p
ref={ref}
className={`text-[13px] leading-relaxed text-stone-700 dark:text-zinc-200 transition-all ${expanded ? "" : "line-clamp-3"}`}
>
{text}
</p>
{(clamped || expanded) && (
<button
onClick={() => setExpanded(e => !e)}
className="mt-1.5 flex items-center gap-1 text-[11px] font-semibold text-rose-600 dark:text-rose-400 hover:text-rose-700 dark:hover:text-rose-300 transition-colors"
>
{expanded ? <><ChevronUp size={12} /> Show less</> : <><ChevronDown size={12} /> Show more</>}
</button>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// 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 === "phone" && typeof val === "object") {
const p = val as { phone_with_dial_code?: string; phone?: string; dial_code?: string };
return p.phone_with_dial_code || (p.dial_code && p.phone ? `${p.dial_code}${p.phone}` : p.phone || "—");
}
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; }
}
function fmtPhone(val: unknown): string {
if (val == null || val === "") return "";
if (typeof val === "object") {
const p = val as { phone_with_dial_code?: string; phone?: string; dial_code?: string };
return p.phone_with_dial_code || (p.dial_code && p.phone ? `${p.dial_code}${p.phone}` : p.phone || "");
}
return String(val);
}

View File

@ -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 <ClipboardList size={16} />;
if (k.includes("active") || k.includes("scheduling")) return <PhoneOutgoing size={16} />;
if (k.includes("scheduled")) return <CalendarCheck2 size={16} />;
if (k.includes("feedback")) return <MessageSquareHeart size={16} />;
if (k.includes("completed") || k.includes("done")) return <Trophy size={16} />;
return <Car size={16} />;
}
interface Props {
activeScreenId: string | null;
onNavigate: (screenUuid: string) => 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<HeaderConfig | null>(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 · AI 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 ─────────────────────────────────────────────── */}
<header className="bg-white/90 dark:bg-zinc-950/90 backdrop-blur-md border-b border-stone-200 dark:border-zinc-800/70 sticky top-0 z-50 h-14 flex items-center px-4 justify-between">
<div className="flex items-center gap-3">
<button
onClick={toggleSidebar}
className="w-8 h-8 rounded-lg flex items-center justify-center text-stone-400 dark:text-zinc-500 hover:text-stone-700 dark:hover:text-zinc-200 hover:bg-stone-100 dark:hover:bg-zinc-800 transition-colors"
>
{sidebarOpen ? <PanelLeftClose size={17} /> : <PanelLeftOpen size={17} />}
</button>
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg flex items-center justify-center gradient-mh shadow-md shadow-rose-500/20">
<Car size={14} className="text-white" />
</div>
<span className="text-[13px] font-bold text-stone-800 dark:text-zinc-100 tracking-tight">
{logoText}
</span>
</div>
</div>
<div className="flex items-center gap-1.5">
<button
onClick={toggleTheme}
title={theme === "dark" ? "Switch to light" : "Switch to dark"}
className="w-8 h-8 rounded-lg flex items-center justify-center text-stone-400 dark:text-zinc-500 hover:text-[#C8102E] dark:hover:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-950/40 transition-colors"
>
{theme === "dark" ? <Sun size={16} /> : <Moon size={16} />}
</button>
<div className="relative">
<button
onClick={() => setProfileOpen(!profileOpen)}
className="w-8 h-8 rounded-full flex items-center justify-center ring-2 ring-white dark:ring-zinc-900 shadow-sm hover:ring-rose-200 dark:hover:ring-rose-800 transition-all gradient-mh"
>
<span className="text-[11px] font-bold text-white leading-none">{initials}</span>
</button>
{profileOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setProfileOpen(false)} />
<div className="absolute right-0 top-full mt-2 w-60 bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl shadow-stone-900/10 dark:shadow-black/40 border border-stone-200 dark:border-zinc-700/70 overflow-hidden z-50">
<div className="px-4 py-3.5 border-b border-stone-100 dark:border-zinc-800">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full flex items-center justify-center shrink-0 gradient-mh">
<span className="text-[13px] font-bold text-white leading-none">{initials}</span>
</div>
<div className="min-w-0">
<div className="text-[13px] font-semibold text-stone-900 dark:text-zinc-100 truncate">{user?.name}</div>
<div className="text-[11px] text-stone-400 dark:text-zinc-500 truncate">{user?.email}</div>
</div>
</div>
{roleName && (
<div className="mt-2.5">
<span
className="text-[10px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded-full"
style={{ background: "rgba(200,16,46,0.10)", color: "#C8102E", border: "1px solid rgba(200,16,46,0.22)" }}
>
{roleName}
</span>
</div>
)}
</div>
<div className="py-1">
<button className="w-full text-left px-4 py-2.5 text-[13px] text-stone-600 dark:text-zinc-300 hover:bg-stone-50 dark:hover:bg-zinc-800 flex items-center gap-2.5 transition-colors">
<Settings size={14} className="text-stone-400 dark:text-zinc-500" /> Settings
</button>
<div className="h-px bg-stone-100 dark:bg-zinc-800 mx-3 my-0.5" />
<button
onClick={handleLogout}
className="w-full text-left px-4 py-2.5 text-[13px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30 flex items-center gap-2.5 transition-colors"
>
<LogOut size={14} /> Sign out
</button>
</div>
</div>
</>
)}
</div>
</div>
</header>
{/* ── Sidebar ─────────────────────────────────────────────── */}
<aside
className={`fixed top-14 left-0 bottom-0 z-40 flex flex-col transition-all duration-200 ${sidebarOpen ? "w-60" : "w-0 overflow-hidden"}`}
style={{ background: "linear-gradient(180deg, #fffbfb 0%, #fff5f5 100%)" }}
>
<div className="flex flex-col h-full dark:bg-zinc-950 border-r border-rose-100/70 dark:border-zinc-800/80">
{/* Identity strip */}
<div className="px-5 pt-5 pb-4 border-b border-rose-100/60 dark:border-zinc-800/60">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0 gradient-mh shadow-md shadow-rose-500/20">
<Car size={13} className="text-white" />
</div>
<div>
<div className="text-[13px] font-bold text-stone-800 dark:text-zinc-100 leading-none">{logoText}</div>
<div className="text-[10px] text-stone-400 dark:text-zinc-600 mt-0.5">Test-drive lifecycle</div>
</div>
</div>
</div>
{/* Nav */}
<div className="flex-1 overflow-y-auto px-3 py-3">
<div className="text-[10px] font-bold text-stone-400 dark:text-zinc-600 uppercase tracking-widest px-2 mb-2">
Pipeline
</div>
<nav className="space-y-0.5">
{navItems.map((item: NavItem) => {
const active = activeScreenId === item.screen_uuid;
return (
<button
key={item.id}
onClick={() => onNavigate(item.screen_uuid)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-[13px] font-medium transition-all group ${
active
? "bg-rose-100/70 dark:bg-rose-950/40 text-[#9D0D24] dark:text-rose-300"
: "text-stone-500 dark:text-zinc-400 hover:bg-white dark:hover:bg-zinc-800/70 hover:text-stone-900 dark:hover:text-zinc-100"
}`}
>
<span
className={`flex items-center justify-center w-7 h-7 rounded-lg shrink-0 transition-colors ${
active
? "bg-rose-200/70 dark:bg-rose-900/50 text-[#C8102E] dark:text-rose-400"
: "bg-white dark:bg-zinc-800 text-stone-400 dark:text-zinc-500 shadow-sm group-hover:text-[#C8102E] dark:group-hover:text-rose-400"
}`}
>
{iconFor(item.label)}
</span>
<span className="truncate">{item.label}</span>
</button>
);
})}
</nav>
</div>
{/* Bottom — user card */}
<div className="px-3 py-3 border-t border-rose-100/60 dark:border-zinc-800/60">
<div
className="flex items-center gap-2.5 px-2 py-2 rounded-xl hover:bg-white dark:hover:bg-zinc-800/70 transition-colors cursor-pointer group"
onClick={handleLogout}
title="Sign out"
>
<div className="w-7 h-7 rounded-full flex items-center justify-center shrink-0 text-[11px] font-bold text-white gradient-mh">
{initials}
</div>
<div className="min-w-0 flex-1">
<div className="text-[12px] font-semibold text-stone-700 dark:text-zinc-200 truncate leading-none">{user?.name}</div>
<div className="text-[10px] text-stone-400 dark:text-zinc-500 truncate mt-0.5">{roleName}</div>
</div>
<LogOut size={13} className="text-stone-300 dark:text-zinc-600 group-hover:text-[#C8102E] transition-colors shrink-0" />
</div>
</div>
</div>
</aside>
</>
);
}

View File

@ -0,0 +1,329 @@
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";
// TM brand tokens — mirror VariantB.tsx. Inline to avoid a one-off tokens
// module; if a third consumer appears (Login likely), extract then.
const ACCENT = "#e31837";
const ACCENT_DARK = "#5f0229";
const ACCENT_SOFT = "#fde2e8";
const TM_BORDER = "#e4e4ed";
const INK = "#17181a";
interface FormField {
id: string;
uid: string;
name: string;
type: string;
data_type: string;
mandatory: boolean;
value?: any;
properties?: {
options?: Array<{ label: string; value: string }>;
country_code?: 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<FormField[]>([]);
const [gridConfig, setGridConfig] = useState<GridItem[]>([]);
const [formTitle, setFormTitle] = useState(title || "");
const [formData, setFormData] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [touched, setTouched] = useState<Set<string>>(new Set());
const [success, setSuccess] = useState(false);
const [visible, setVisible] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => { requestAnimationFrame(() => setVisible(true)); }, []);
useEffect(() => {
getFormScreen(activityId, instanceId)
.then((res: any) => {
const raw: FormField[] = res.fields ?? res.form_json?.fields ?? [];
const f = raw
.filter((field) => field.data_type !== "lookup")
.map((field) => ({ ...field, mandatory: false }));
setFields(f);
setGridConfig(res.grid_config ?? []);
setFormTitle(res.activity_name || title || "Form");
const pre: Record<string, string> = {};
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<string, unknown> = {};
for (const f of fields) {
const v = formData[f.id] ?? "";
if (f.data_type === "number") {
data[f.id] = v ? Number(v) : 0;
} else if (f.data_type === "phone") {
const dial = f.properties?.country_code ?? "";
const digits = v.replace(/\D/g, "");
data[f.id] = digits
? { dial_code: dial, phone: digits, phone_with_dial_code: `${dial}${digits}` }
: "";
} else {
data[f.id] = 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 (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={() => animateClose(onClose)}>
<div className={`absolute inset-0 bg-stone-900/40 transition-opacity duration-200 ${visible ? "opacity-100" : "opacity-0"}`} />
<div
ref={modalRef}
className={`relative bg-white rounded-2xl w-full max-w-xl mx-4 max-h-[90vh] flex flex-col transition-all duration-200 ${visible ? "opacity-100 scale-100 translate-y-0" : "opacity-0 scale-95 translate-y-2"}`}
style={{ border: `1px solid ${TM_BORDER}`, boxShadow: "0 24px 60px -20px rgba(6, 31, 92, 0.25)" }}
onClick={(e) => e.stopPropagation()}
>
{success && (
<div className="absolute inset-0 z-10 bg-white/97 flex flex-col items-center justify-center gap-3">
<div className="w-12 h-12 rounded-xl flex items-center justify-center" style={{ background: ACCENT_SOFT }}>
<CheckCircle2 size={24} style={{ color: ACCENT }} />
</div>
<div className="text-[14px] font-bold tracking-tight" style={{ color: INK }}>Submitted</div>
</div>
)}
{/* ── Header ── */}
<div className="flex items-start justify-between px-7 py-5" style={{ borderBottom: `1px solid ${TM_BORDER}` }}>
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.15em] mb-1.5" style={{ color: ACCENT }}>
{instanceId ? "Update" : "Create"}
</div>
<h2 className="text-[20px] font-bold tracking-tight leading-tight truncate" style={{ color: INK }}>
{instanceId ? formTitle : "New Lead"}
</h2>
</div>
<button
onClick={() => animateClose(onClose)}
className="w-8 h-8 flex items-center justify-center text-stone-400 hover:text-stone-700 transition-colors shrink-0 -mr-2"
>
<X size={18} />
</button>
</div>
{/* ── Body ── */}
<div className="flex-1 overflow-y-auto px-7 py-6">
{loading ? (
<div className="space-y-5">
{[0, 1, 2, 3].map((r) => (
<div key={r} className="grid grid-cols-2 gap-4">
<div><div className="h-3 w-20 bg-stone-100 animate-pulse mb-2" /><div className="h-10 w-full bg-stone-100 animate-pulse" /></div>
<div><div className="h-3 w-24 bg-stone-100 animate-pulse mb-2" /><div className="h-10 w-full bg-stone-100 animate-pulse" /></div>
</div>
))}
</div>
) : (
<form id="act-form" onSubmit={handleSubmit} className="space-y-5">
{rows.map((row, ri) => (
<div key={ri} className={`grid gap-4 ${row.length > 1 ? "grid-cols-2" : "grid-cols-1"}`}>
{row.map((f) => (
<div key={f.id}>
<label className="block text-[11px] font-semibold uppercase tracking-[0.03em] text-stone-500 mb-2">
{f.name}
{f.mandatory && <span className="ml-1" style={{ color: ACCENT }}>*</span>}
</label>
{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) && (
<p className="text-[11px] mt-1.5 flex items-center gap-1" style={{ color: ACCENT }}>
<AlertCircle size={11} /> Required
</p>
)}
</div>
))}
</div>
))}
</form>
)}
{error && (
<div
className="mt-5 flex items-start gap-2.5 text-[13px] px-4 py-3"
style={{ background: ACCENT_SOFT, border: `1px solid #fbb4be`, color: ACCENT_DARK }}
>
<AlertCircle size={15} className="shrink-0 mt-0.5" style={{ color: ACCENT }} />
<span className="font-medium">{error}</span>
</div>
)}
</div>
{/* ── Footer ── */}
{!loading && (
<div className="px-7 py-4 flex items-center justify-between" style={{ borderTop: `1px solid ${TM_BORDER}` }}>
<div className="text-[11px] uppercase tracking-[0.05em] text-stone-400 font-semibold">
<span style={{ color: ACCENT }}>*</span> Required
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => animateClose(onClose)}
className="h-9 px-4 text-[13px] font-semibold transition-colors"
style={{ color: INK, border: `1px solid ${TM_BORDER}`, borderRadius: 4, background: "white" }}
>
Cancel
</button>
<button
type="submit"
form="act-form"
disabled={submitting}
className="h-9 px-5 text-[13px] font-semibold text-white disabled:opacity-60 inline-flex items-center gap-2 transition-colors hover:brightness-110"
style={{ background: ACCENT, borderRadius: 4 }}
>
{submitting ? (
<><div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Submitting</>
) : (
<>{instanceId ? "Update" : "Submit"} <ArrowRight size={14} /></>
)}
</button>
</div>
</div>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Input renderer
// ---------------------------------------------------------------------------
function renderInput(f: FormField, value: string, onChange: (v: string) => void, hasError: boolean) {
const base = `w-full h-10 px-3 text-[13.5px] bg-white text-stone-900 focus:outline-none transition placeholder:text-stone-400`;
const style: React.CSSProperties = {
border: `1px solid ${hasError ? ACCENT : TM_BORDER}`,
borderRadius: 4,
};
const onFocus = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
e.currentTarget.style.borderColor = hasError ? ACCENT : ACCENT;
e.currentTarget.style.boxShadow = `0 0 0 3px ${ACCENT_SOFT}`;
};
const onBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
e.currentTarget.style.borderColor = hasError ? ACCENT : TM_BORDER;
e.currentTarget.style.boxShadow = "none";
};
const options = f.properties?.options;
if (options && options.length > 0) {
return (
<div className="relative">
<select
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={onFocus}
onBlur={onBlur}
className={`${base} appearance-none pr-9 cursor-pointer`}
style={style}
>
<option value="">Select {f.name.toLowerCase()}</option>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<ChevronDown size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 pointer-events-none" />
</div>
);
}
switch (f.data_type) {
case "longtext":
return (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={onFocus}
onBlur={onBlur}
rows={3}
className={`${base} h-auto py-2.5`}
style={style}
placeholder={`Enter ${f.name.toLowerCase()}`}
/>
);
case "number":
return <input type="number" step="any" value={value} onChange={(e) => onChange(e.target.value)} onFocus={onFocus} onBlur={onBlur} className={base} style={style} placeholder="0" />;
case "date":
return <input type="date" value={value} onChange={(e) => onChange(e.target.value)} onFocus={onFocus} onBlur={onBlur} className={base} style={style} />;
case "email":
return <input type="email" value={value} onChange={(e) => onChange(e.target.value)} onFocus={onFocus} onBlur={onBlur} className={base} style={style} placeholder={`Enter ${f.name.toLowerCase()}`} />;
case "phone": {
const cc = f.properties?.country_code;
if (!cc) {
return <input type="tel" value={value} onChange={(e) => onChange(e.target.value)} onFocus={onFocus} onBlur={onBlur} className={base} style={style} placeholder={`Enter ${f.name.toLowerCase()}`} />;
}
return (
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-[13px] font-semibold text-stone-500 pointer-events-none select-none">
{cc}
</span>
<input
type="tel"
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={onFocus}
onBlur={onBlur}
className={`${base} pl-12`}
style={style}
placeholder="Phone number"
/>
</div>
);
}
default:
return <input type="text" value={value} onChange={(e) => onChange(e.target.value)} onFocus={onFocus} onBlur={onBlur} className={base} style={style} placeholder={`Enter ${f.name.toLowerCase()}`} />;
}
}

View File

@ -0,0 +1,53 @@
import { PieChart as RPieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from "recharts";
interface Row {
dimension: unknown;
value: unknown;
}
interface Props {
title?: string;
rows: Row[];
loading?: boolean;
}
const RED_PALETTE = ["#C8102E", "#E84258", "#F26471", "#F69199", "#FABFBF", "#FFD7D7"];
export default function PieChart({ title, rows, loading }: Props) {
const data = (rows ?? [])
.map(r => ({ name: String(r.dimension ?? "—"), value: Number(r.value ?? 0) }))
.filter(d => d.name && d.name !== "—" && d.value > 0);
return (
<div className="flex-1 min-w-[280px] bg-white dark:bg-zinc-900 rounded-2xl border border-stone-200/80 dark:border-zinc-700/60 px-5 py-4 shadow-sm">
{title && (
<div className="text-[11px] font-semibold uppercase tracking-widest text-stone-400 dark:text-zinc-500 mb-3">
{title}
</div>
)}
{loading ? (
<div className="h-[220px] bg-stone-100 dark:bg-zinc-800 rounded-full animate-pulse" />
) : data.length === 0 ? (
<div className="text-[12px] text-stone-400 dark:text-zinc-500 py-4 text-center">No data</div>
) : (
<div style={{ width: "100%", height: 220 }}>
<ResponsiveContainer>
<RPieChart>
<Pie data={data} dataKey="value" nameKey="name" innerRadius={45} outerRadius={75} paddingAngle={2}>
{data.map((_, i) => <Cell key={i} fill={RED_PALETTE[i % RED_PALETTE.length]} />)}
</Pie>
<Tooltip contentStyle={{ fontSize: 12, borderRadius: 8 }} />
<Legend
verticalAlign="middle"
align="right"
layout="vertical"
iconType="circle"
wrapperStyle={{ fontSize: 11 }}
/>
</RPieChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,9 @@
import { Navigate, useLocation } from "react-router-dom";
import { useAuthContext } from "../hooks/AuthContext";
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuthContext();
const location = useLocation();
if (!isAuthenticated) return <Navigate to="/login" state={{ from: location }} replace />;
return <>{children}</>;
}

View File

@ -0,0 +1,713 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { useNavigate } from "react-router-dom";
import {
ChevronLeft, ChevronRight, Search, ArrowUp, ArrowDown,
X, Inbox, ArrowRight, RefreshCw, CheckCircle2,
} from "lucide-react";
import { getRVScreen, getRecordView, type RecordViewField, type SearchQuery, type TileValue, type ChartDataResponse } from "../api/viewService";
import FormModal from "./FormModal";
import Tile from "./Tile";
import BarChart from "./BarChart";
import PieChart from "./PieChart";
// Per-column max width (px). Long-text columns get the widest cap; the rest
// stay tight so a single overflowing summary can't push the whole table wide.
function colMaxWidth(dataType: string, fieldKey: string): number {
const k = fieldKey.toLowerCase();
if (dataType === "longtext") return 360;
if (k.includes("summary") || k.includes("reason") || k.includes("transcript") || k.includes("recording")) return 320;
if (k === "instance_id" || k.endsWith("_id")) return 110;
if (dataType === "number") return 130;
if (dataType === "date" || dataType === "datetime") return 140;
if (k.includes("email")) return 220;
if (k.includes("phone")) return 160;
return 200;
}
// ---------------------------------------------------------------------------
// Action column (config-driven from rv-screen layout)
// ---------------------------------------------------------------------------
interface ActionCondition {
field_key: string;
operator: string;
value: unknown;
data_type?: string;
}
interface ActionConditions {
logic?: "AND" | "OR";
conditions: ActionCondition[];
}
interface ActionButton {
name: string;
icon?: string;
classes?: string[];
job_template?: string;
params?: { activity_uid?: string; workflow_uuid?: string; [k: string]: unknown };
show_conditions?: ActionConditions | null;
action_end?: Array<{ type: string }>;
}
interface ActionColumn {
id: string;
display: string;
buttons: ActionButton[];
}
const ICONS: Record<string, React.ReactNode> = {
refresh: <RefreshCw size={13} />,
task_alt: <CheckCircle2 size={13} />,
};
function iconFor(name?: string): React.ReactNode {
return (name && ICONS[name]) || <RefreshCw size={13} />;
}
// Walks the rv-screen layout to find columns with `type: "custom"` and their
// inner button configs.
function extractActionColumns(layout: any): ActionColumn[] {
const out: ActionColumn[] = [];
const walk = (nodes: any[]): void => {
for (const n of nodes ?? []) {
const cfg = n?.config;
if (cfg?.type === "rv_table" && Array.isArray(cfg.columns)) {
for (const col of cfg.columns) {
if (col?.type === "custom" && Array.isArray(col.config)) {
const buttons = col.config.filter((b: any) => b?.type === "button");
if (buttons.length > 0) {
out.push({ id: col.id, display: col.display || "Actions", buttons });
}
}
}
}
if (Array.isArray(n?.children)) walk(n.children);
}
};
walk(Array.isArray(layout) ? layout : []);
return out;
}
function evalShowConditions(c: ActionConditions | null | undefined, row: Record<string, unknown>): boolean {
if (!c || !Array.isArray(c.conditions) || c.conditions.length === 0) return true;
const results = c.conditions.map((cond) => {
const v = row[cond.field_key];
switch (cond.operator) {
case "equals": return v === cond.value;
case "not_equals": return v !== cond.value;
case "is_empty": return v == null || v === "";
case "is_not_empty": return v != null && v !== "";
case "in": return Array.isArray(cond.value) && cond.value.includes(v as never);
case "not_in": return Array.isArray(cond.value) && !cond.value.includes(v as never);
default: return false;
}
});
return (c.logic === "OR") ? results.some(Boolean) : results.every(Boolean);
}
function buttonClass(classes?: string[]): string {
const ghost = classes?.includes("z-btn-secondary");
const base = "h-7 px-2.5 text-[11px] font-semibold rounded-lg inline-flex items-center gap-1 transition-all";
return ghost
? `${base} text-stone-600 dark:text-zinc-300 bg-white dark:bg-zinc-800 border border-stone-200 dark:border-zinc-700 hover:bg-stone-50 dark:hover:bg-zinc-700`
: `${base} text-white mh-glow hover:scale-[1.02]`;
}
// ---------------------------------------------------------------------------
// Skeleton
// ---------------------------------------------------------------------------
function TableSkeleton({ rows, cols }: { rows: number; cols: number }) {
return (
<div className="rounded-2xl border border-stone-200/80 dark:border-zinc-700/60 bg-white dark:bg-zinc-900 overflow-hidden shadow-sm">
<div className="px-5 py-3 border-b border-stone-100 dark:border-zinc-800 flex items-center gap-3">
<div className="h-5 w-16 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse" />
<div className="ml-auto h-8 w-44 bg-stone-100 dark:bg-zinc-800 rounded-lg animate-pulse" />
</div>
<table className="w-full">
<thead>
<tr className="bg-stone-50/80 dark:bg-zinc-800/40 border-b border-stone-200/80 dark:border-zinc-700/80">
{Array.from({ length: cols }).map((_, i) => (
<th key={i} className={`py-2.5 ${i === 0 ? "pl-5" : "px-4"}`}>
<div className="h-3 bg-stone-200 dark:bg-zinc-700 rounded animate-pulse w-20" />
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-stone-100/80 dark:divide-zinc-800/60">
{Array.from({ length: rows }).map((_, r) => (
<tr key={r}>
{Array.from({ length: cols }).map((_, c) => (
<td key={c} className={`py-3.5 ${c === 0 ? "pl-5 pr-4" : "px-4"}`}>
<div className="h-3 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse" style={{ width: `${60 + Math.random() * 40}%` }} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
interface Props {
// Either the numeric source_screen_id or the source_uuid; the view-service
// accepts both at /app/:appId/rv-screens/:idOrUuid.
viewId: number | string;
onRowClick?: (instanceId: string) => void;
toolbarAction?: React.ReactNode;
}
type AnalyticsSpec =
| { kind: "tile"; uid: string; title?: string }
| { kind: "chart"; uid: string; title?: string; chartType?: string };
// Walk rv-screen layout to find rv_tile / rv_chart elements (the platform
// places them as sibling blocks above the rv_table).
function extractAnalyticsElements(layout: any): AnalyticsSpec[] {
const out: AnalyticsSpec[] = [];
const walk = (nodes: any[]): void => {
for (const n of nodes ?? []) {
const cfg = n?.config;
if (cfg?.type === "rv_tile" && cfg.tile_uid) out.push({ kind: "tile", uid: cfg.tile_uid, title: cfg.title });
if (cfg?.type === "rv_chart" && cfg.chart_uid) out.push({ kind: "chart", uid: cfg.chart_uid, title: cfg.title, chartType: cfg.chart_type });
if (Array.isArray(n?.children)) walk(n.children);
}
};
walk(Array.isArray(layout) ? layout : []);
return out;
}
// Walks the rv-screen layout to find the rv_table element and returns a
// key_name → display map for column header labels (the recordview response's
// output_label is empty for v2 envelope screens).
function extractColumnLabels(layout: any): Record<string, string> {
const out: Record<string, string> = {};
const walk = (nodes: any[]): void => {
for (const n of nodes ?? []) {
const cfg = n?.config;
if (cfg?.type === "rv_table" && Array.isArray(cfg.columns)) {
for (const c of cfg.columns) {
if (c?.key_name && c?.display) out[c.key_name] = c.display;
}
}
if (Array.isArray(n?.children)) walk(n.children);
}
};
walk(Array.isArray(layout) ? layout : []);
return out;
}
export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: Props) {
const navigate = useNavigate();
const [rvTemplateUid, setRvTemplateUid] = useState<string | null>(null);
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
const [actionColumns, setActionColumns] = useState<ActionColumn[]>([]);
const [analyticsSpecs, setAnalyticsSpecs] = useState<AnalyticsSpec[]>([]);
const [tileValues, setTileValues] = useState<TileValue[]>([]);
const [chartData, setChartData] = useState<ChartDataResponse[]>([]);
const [fields, setFields] = useState<RecordViewField[]>([]);
const [rows, setRows] = useState<Record<string, unknown>[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 10, total_count: 0, total_pages: 0 });
const [query, setQuery] = useState<SearchQuery>({ page: 1, limit: 10, sort_dir: "desc" });
const [searchInput, setSearchInput] = useState("");
const [loading, setLoading] = useState(true);
const [dataLoading, setDataLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formModal, setFormModal] = useState<{ activityId: string; instanceId: string; title: string } | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setLoading(true);
setRvTemplateUid(null);
setColumnLabels({});
setActionColumns([]);
setAnalyticsSpecs([]);
setTileValues([]);
setChartData([]);
getRVScreen(viewId)
.then((rv) => {
setColumnLabels(extractColumnLabels(rv.layout));
setActionColumns(extractActionColumns(rv.layout));
setAnalyticsSpecs(extractAnalyticsElements(rv.layout));
setRvTemplateUid(rv.rv_template_uid);
})
.catch((err) => { setError(err.message); setLoading(false); });
}, [viewId]);
const fetchData = useCallback(async () => {
if (!rvTemplateUid) return;
if (!loading) setDataLoading(true);
setError(null);
try {
const res = await getRecordView(rvTemplateUid, viewId, query);
setTileValues(res.tile_values ?? []);
setChartData(res.chart_data ?? []);
setFields(res.config.fields.filter((f) => f.field_key !== ""));
const dataRows = Array.isArray(res.data) ? res.data : [];
setRows(dataRows);
if (res.pagination) {
setPagination(res.pagination);
} else {
setPagination((p) => ({ ...p, total_count: dataRows.length, total_pages: Math.ceil(dataRows.length / p.limit) || 1 }));
}
} catch (err: any) { setError(err.message); }
finally { setLoading(false); setDataLoading(false); }
}, [rvTemplateUid, viewId, query]);
useEffect(() => { fetchData(); }, [fetchData]);
const handleSort = (key: string) => setQuery((q) => ({
...q, page: 1, sort_by: key,
sort_dir: q.sort_by === key && q.sort_dir === "asc" ? "desc" : "asc",
}));
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setQuery((q) => ({ ...q, page: 1, search: searchInput }));
};
const clearSearch = () => {
setSearchInput("");
setQuery((q) => ({ ...q, page: 1, search: "" }));
};
const handlePage = (p: number) => setQuery((q) => ({ ...q, page: p }));
const handleRowClick = (row: Record<string, unknown>) => {
const id = String(row.instance_id ?? row._pk_value ?? "");
if (!id) return;
onRowClick ? onRowClick(id) : navigate(`/detail/${id}`);
};
const tileSpecs = analyticsSpecs.filter(a => a.kind === "tile");
const chartSpecs = analyticsSpecs.filter(a => a.kind === "chart");
const analyticsRow = analyticsSpecs.length > 0 ? (
<div className="space-y-3 mb-4">
{tileSpecs.length > 0 && (
<div className="flex flex-wrap items-stretch gap-3">
{tileSpecs.map((a, i) => {
const tv = tileValues.find(t => t.tile_uid === a.uid);
return <Tile key={`tile-${i}`} title={a.title} value={tv?.value as number | undefined} loading={loading || dataLoading} />;
})}
</div>
)}
{chartSpecs.length > 0 && (
<div className="flex flex-wrap items-stretch gap-3">
{chartSpecs.map((a, i) => {
const cd = chartData.find(c => c.chart_uid === a.uid);
const Chart = (a as { chartType?: string }).chartType === "pie" ? PieChart : BarChart;
return <Chart key={`chart-${i}`} title={a.title} rows={cd?.rows ?? []} loading={loading || dataLoading} />;
})}
</div>
)}
</div>
) : null;
if (loading) return <>{analyticsRow}<TableSkeleton rows={6} cols={fields.length || 6} /></>;
if (error) {
return (
<>
{analyticsRow}
<div className="rounded-2xl border border-red-100 dark:border-red-900/50 bg-red-50/50 dark:bg-red-950/20 p-10 text-center">
<p className="text-sm text-red-600 dark:text-red-400 mb-2">{error}</p>
<button onClick={fetchData} className="text-xs text-rose-600 dark:text-rose-400 hover:text-rose-800 font-semibold">Try again</button>
</div>
</>
);
}
const primaryFieldKey = fields.find(
(f) => f.field_key !== "current_state_name" && f.field_key !== "instance_id" && f.data_type === "text"
)?.field_key;
const { page, total_count, total_pages, limit } = pagination;
const from = total_count > 0 ? (page - 1) * limit + 1 : 0;
const to = Math.min(page * limit, total_count);
const pages = buildPageRange(page, total_pages);
return (
<>
{analyticsRow}
<div className="rounded-2xl border border-stone-200/80 dark:border-zinc-700/60 bg-white dark:bg-zinc-900 overflow-hidden shadow-sm dark:shadow-black/20">
{/* ── Toolbar ───────────────────────────────────────────────────────── */}
<div className="px-5 py-3 flex items-center justify-between gap-4 border-b border-stone-100 dark:border-zinc-800">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
<span className="text-[15px] font-bold text-stone-800 dark:text-zinc-100 tabular-nums leading-none">{total_count}</span>
<span className="text-[13px] text-stone-400 dark:text-zinc-500 leading-none">records</span>
</div>
{query.search && (
<div className="flex items-center gap-1.5 pl-3 border-l border-stone-200 dark:border-zinc-700">
<span className="text-[11px] text-stone-400 dark:text-zinc-500">Filtered:</span>
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[11px] font-semibold border"
style={{ background: "rgba(200,16,46,0.08)", borderColor: "rgba(200,16,46,0.22)", color: "#C8102E" }}
>
{query.search}
<button onClick={clearSearch} className="ml-0.5 hover:opacity-60"><X size={9} /></button>
</span>
</div>
)}
{toolbarAction && (
<div className="pl-3 border-l border-stone-200 dark:border-zinc-700">
{toolbarAction}
</div>
)}
</div>
<div className="flex items-center gap-1.5">
<button
onClick={fetchData}
disabled={dataLoading}
title="Refresh"
className="w-8 h-8 rounded-lg flex items-center justify-center text-stone-400 dark:text-zinc-500 hover:text-stone-600 dark:hover:text-zinc-300 hover:bg-stone-100 dark:hover:bg-zinc-800 transition-colors disabled:opacity-40"
>
<RefreshCw size={14} className={dataLoading ? "animate-spin" : ""} />
</button>
<form onSubmit={handleSearch}>
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -transtone-y-1/2 text-stone-400 dark:text-zinc-500 pointer-events-none" />
<input
ref={searchRef}
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search…"
className="w-44 h-8 pl-8 pr-7 text-[13px] border border-stone-200 dark:border-zinc-700 rounded-lg bg-stone-50 dark:bg-zinc-800 text-stone-800 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-rose-500/20 focus:border-rose-400 dark:focus:border-rose-500 focus:bg-white dark:focus:bg-zinc-800 transition placeholder:text-stone-400 dark:placeholder:text-zinc-600"
/>
{searchInput && (
<button
type="button"
onClick={clearSearch}
className="absolute right-2 top-1/2 -transtone-y-1/2 text-stone-300 dark:text-zinc-600 hover:text-stone-500"
>
<X size={12} />
</button>
)}
</div>
</form>
</div>
</div>
{/* ── Table ─────────────────────────────────────────────────────────── */}
<div className="overflow-x-auto relative">
{dataLoading && (
<div className="absolute inset-0 bg-white/60 dark:bg-zinc-900/60 z-10 flex items-center justify-center backdrop-blur-[1px]">
<div className="w-5 h-5 border-2 border-rose-200 dark:border-rose-900 border-t-rose-600 rounded-full animate-spin" />
</div>
)}
<table className="w-full" style={{ tableLayout: "fixed" }}>
<colgroup>
{fields.map((f) => (
<col key={f.field_key} style={{ width: colMaxWidth(f.data_type, f.field_key) }} />
))}
{actionColumns.map((ac) => (
<col key={ac.id} style={{ width: 160 }} />
))}
<col style={{ width: 56 }} />
</colgroup>
<thead>
<tr className="bg-stone-50/80 dark:bg-zinc-800/40 border-b border-stone-200/80 dark:border-zinc-700/80">
{fields.map((f, i) => {
const isSorted = query.sort_by === f.field_key;
return (
<th
key={f.field_key}
onClick={() => handleSort(f.field_key)}
style={{ maxWidth: colMaxWidth(f.data_type, f.field_key) }}
className={`py-2.5 text-left text-[12px] font-semibold cursor-pointer select-none whitespace-nowrap transition-colors group/th
${i === 0 ? "pl-5 pr-4" : "px-4"}
${isSorted
? "text-rose-600 dark:text-rose-400"
: "text-stone-500 dark:text-zinc-400 hover:text-stone-700 dark:hover:text-zinc-200"
}
`}
>
<span className="inline-flex items-center gap-1">
{columnLabels[f.field_key] || f.output_label || f.field_key}
{isSorted
? (query.sort_dir === "asc"
? <ArrowUp size={11} className="text-rose-500" />
: <ArrowDown size={11} className="text-rose-500" />)
: <ArrowDown size={10} className="text-stone-300 dark:text-zinc-700 opacity-0 group-hover/th:opacity-100 transition-opacity" />
}
</span>
</th>
);
})}
{actionColumns.map((ac) => (
<th key={ac.id} className="py-2.5 px-4 text-left text-[12px] font-semibold text-stone-500 dark:text-zinc-400 whitespace-nowrap">
{ac.display}
</th>
))}
<th className="py-2.5 pr-4 w-16" />
</tr>
</thead>
<tbody className="divide-y divide-stone-100/80 dark:divide-zinc-800/60">
{rows.length === 0 ? (
<tr>
<td colSpan={fields.length + 1 + actionColumns.length} className="py-20 text-center">
<div className="flex flex-col items-center gap-3">
<div className="w-14 h-14 rounded-2xl flex items-center justify-center"
style={{ background: "linear-gradient(135deg,#fff5f5,#ffe4e6)" }}>
<Inbox size={22} className="text-rose-400" />
</div>
<div>
<div className="text-[14px] font-semibold text-stone-600 dark:text-zinc-300">No records found</div>
<div className="text-[12px] text-stone-400 dark:text-zinc-600 mt-1">
{query.search ? "Try a different search term" : "No data available"}
</div>
</div>
{query.search && (
<button onClick={clearSearch} className="text-[12px] font-semibold text-rose-600 dark:text-rose-400 hover:text-rose-800 transition-colors">
Clear search
</button>
)}
</div>
</td>
</tr>
) : rows.map((row, i) => (
<tr
key={String(row.instance_id ?? i)}
onClick={() => handleRowClick(row)}
className="group/row cursor-pointer hover:bg-stone-50/80 dark:hover:bg-zinc-800/40 transition-colors"
>
{fields.map((f, fi) => {
const isPrimary = f.field_key === primaryFieldKey;
const isFirst = fi === 0;
return (
<td
key={f.field_key}
className={`py-3.5 text-[13px]
${isFirst
? "pl-5 pr-4 border-l-2 border-l-transparent group-hover/row:border-l-rose-500 transition-colors"
: "px-4"
}
${isPrimary
? "font-semibold text-stone-800 dark:text-zinc-100"
: "text-stone-500 dark:text-zinc-400"
}
`}
>
<div
className="truncate"
style={{ maxWidth: colMaxWidth(f.data_type, f.field_key) }}
title={typeof row[f.field_key] === "string" ? String(row[f.field_key]) : undefined}
>
{renderCell(row[f.field_key], f.data_type, f.field_key)}
</div>
</td>
);
})}
{actionColumns.map((ac) => {
const visible = ac.buttons.filter((b) => evalShowConditions(b.show_conditions, row));
return (
<td key={ac.id} className="px-4 py-3 whitespace-nowrap" onClick={(e) => e.stopPropagation()}>
{visible.length === 0 ? (
<span className="text-stone-300 dark:text-zinc-700"></span>
) : (
<div className="flex items-center gap-1.5">
{visible.map((b, bi) => {
const aid = b.params?.activity_uid;
if (!aid) return null;
return (
<button
key={bi}
onClick={() => setFormModal({
activityId: aid,
instanceId: String(row.instance_id),
title: b.name,
})}
className={buttonClass(b.classes)}
style={!b.classes?.includes("z-btn-secondary") ? { background: "linear-gradient(135deg, #C8102E, #E84258)" } : {}}
>
{iconFor(b.icon)}{b.name}
</button>
);
})}
</div>
)}
</td>
);
})}
<td className="pr-4 py-3 w-16 text-right" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => handleRowClick(row)}
className="w-7 h-7 rounded-lg flex items-center justify-center text-stone-400 dark:text-zinc-500 hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-100 dark:hover:bg-rose-950/50 transition-colors opacity-0 group-hover/row:opacity-100 transition-opacity"
>
<ArrowRight size={14} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* ── Pagination ────────────────────────────────────────────────────── */}
{total_count > 0 && (
<div className="px-5 py-3 border-t border-stone-100 dark:border-zinc-800 flex items-center justify-between gap-4">
<div className="flex items-center gap-3 text-[12px] text-stone-400 dark:text-zinc-500">
<div className="flex items-center gap-1.5">
Show
<select
value={query.limit}
onChange={(e) => setQuery((q) => ({ ...q, page: 1, limit: Number(e.target.value) }))}
className="h-7 px-2 text-[12px] border border-stone-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-stone-600 dark:text-zinc-300 font-medium focus:outline-none cursor-pointer"
>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
</select>
per page
</div>
<span className="pl-3 border-l border-stone-200 dark:border-zinc-700 tabular-nums">
{from}{to} of {total_count}
</span>
</div>
{total_pages > 1 && (
<div className="flex items-center gap-0.5">
<button
disabled={page <= 1}
onClick={() => handlePage(page - 1)}
className="w-7 h-7 rounded-lg flex items-center justify-center text-stone-400 dark:text-zinc-500 hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-950/30 disabled:opacity-30 disabled:pointer-events-none transition-colors"
>
<ChevronLeft size={15} />
</button>
{pages.map((p, idx) =>
p === "…" ? (
<span key={`e${idx}`} className="w-7 h-7 flex items-center justify-center text-[12px] text-stone-300 dark:text-zinc-700 select-none"></span>
) : (
<button
key={p}
onClick={() => handlePage(p as number)}
className={`w-7 h-7 rounded-lg text-[12px] font-medium transition-colors ${
p === page
? "bg-rose-600 text-white"
: "text-stone-500 dark:text-zinc-400 hover:bg-rose-50 dark:hover:bg-rose-950/30 hover:text-rose-600 dark:hover:text-rose-400"
}`}
>
{p}
</button>
)
)}
<button
disabled={page >= total_pages}
onClick={() => handlePage(page + 1)}
className="w-7 h-7 rounded-lg flex items-center justify-center text-stone-400 dark:text-zinc-500 hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-950/30 disabled:opacity-30 disabled:pointer-events-none transition-colors"
>
<ChevronRight size={15} />
</button>
</div>
)}
</div>
)}
{formModal && (
<FormModal
activityId={formModal.activityId}
instanceId={formModal.instanceId}
title={formModal.title}
onClose={() => setFormModal(null)}
onSuccess={() => { setFormModal(null); fetchData(); }}
/>
)}
</div>
</>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function buildPageRange(current: number, total: number): Array<number | ""> {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
if (current <= 4) return [1, 2, 3, 4, 5, "…", total];
if (current >= total - 3) return [1, "…", total - 4, total - 3, total - 2, total - 1, total];
return [1, "…", current - 1, current, current + 1, "…", total];
}
function renderCell(value: unknown, dataType: string, fieldKey: string): React.ReactNode {
if (value == null || value === "") return <span className="text-stone-300 dark:text-zinc-700"></span>;
if (fieldKey === "current_state_name") return <StatusBadge value={String(value)} />;
if (dataType === "phone" && typeof value === "object") {
const p = value as { phone_with_dial_code?: string; phone?: string; dial_code?: string };
const text = p.phone_with_dial_code || (p.dial_code && p.phone ? `${p.dial_code}${p.phone}` : p.phone || "");
return text ? <span className="tabular-nums">{text}</span> : <span className="text-stone-300 dark:text-zinc-700"></span>;
}
if (dataType === "number") {
const n = Number(value);
if (fieldKey.includes("amount") || fieldKey.includes("Amount") || fieldKey.includes("tax") || fieldKey.includes("price"))
return <span className="tabular-nums font-medium text-stone-700 dark:text-zinc-200">{n.toLocaleString("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 0 })}</span>;
return <span className="tabular-nums">{n.toLocaleString()}</span>;
}
if (dataType === "datetime" || dataType === "date") {
try { return <span>{new Date(String(value)).toLocaleDateString("en-IN", { day: "2-digit", month: "short", year: "numeric" })}</span>; }
catch { return String(value); }
}
if (fieldKey === "instance_id") {
return <span className="font-mono text-[11px] text-stone-400 dark:text-zinc-500 bg-stone-100 dark:bg-zinc-800 px-1.5 py-0.5 rounded-md">{String(value).slice(0, 8)}</span>;
}
return String(value);
}
// ---------------------------------------------------------------------------
// Status badge — Mahindra workflow states
// ---------------------------------------------------------------------------
const STATUS: Record<string, { bg: string; text: string; dot: string; darkBg: string; darkText: string; darkDot: string }> = {
"Awaiting Scheduler Call": {
bg: "bg-rose-50 border-rose-200", text: "text-rose-700", dot: "bg-rose-500 animate-pulse",
darkBg: "dark:bg-rose-950/30 dark:border-rose-900", darkText: "dark:text-rose-300", darkDot: "dark:bg-rose-400",
},
"Scheduled": {
bg: "bg-amber-50 border-amber-200", text: "text-amber-700", dot: "bg-amber-500",
darkBg: "dark:bg-amber-950/30 dark:border-amber-900", darkText: "dark:text-amber-300", darkDot: "dark:bg-amber-400",
},
"Awaiting Feedback Call": {
bg: "bg-rose-50 border-rose-200", text: "text-rose-700", dot: "bg-rose-500 animate-pulse",
darkBg: "dark:bg-rose-950/30 dark:border-rose-900", darkText: "dark:text-rose-300", darkDot: "dark:bg-rose-400",
},
"Feedback Collected": {
bg: "bg-emerald-50 border-emerald-200", text: "text-emerald-700", dot: "bg-emerald-500",
darkBg: "dark:bg-emerald-950/30 dark:border-emerald-900", darkText: "dark:text-emerald-300", darkDot: "dark:bg-emerald-400",
},
"Scheduling Call Failed": {
bg: "bg-red-50 border-red-200", text: "text-red-700", dot: "bg-red-500",
darkBg: "dark:bg-red-950/30 dark:border-red-900", darkText: "dark:text-red-300", darkDot: "dark:bg-red-400",
},
"Feedback Call Failed": {
bg: "bg-red-50 border-red-200", text: "text-red-700", dot: "bg-red-500",
darkBg: "dark:bg-red-950/30 dark:border-red-900", darkText: "dark:text-red-300", darkDot: "dark:bg-red-400",
},
"End State": {
bg: "bg-stone-100 border-stone-200", text: "text-stone-600", dot: "bg-stone-400",
darkBg: "dark:bg-zinc-800 dark:border-zinc-700", darkText: "dark:text-zinc-400", darkDot: "dark:bg-zinc-500",
},
};
function StatusBadge({ value }: { value: string }) {
const s = STATUS[value] ?? { bg: "bg-stone-100 border-stone-200", text: "text-stone-600", dot: "bg-stone-400", darkBg: "dark:bg-zinc-800 dark:border-zinc-700", darkText: "dark:text-zinc-400", darkDot: "dark:bg-zinc-500" };
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold border ${s.bg} ${s.text} ${s.darkBg} ${s.darkText}`}>
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${s.dot} ${s.darkDot}`} />
{value}
</span>
);
}

View File

@ -0,0 +1,102 @@
import { useState, ReactNode } from "react";
import { Plus } from "lucide-react";
import type { LayoutElement } from "../api/viewService";
import RecordViewTable from "./RecordViewTable";
import DetailViewPanel from "./DetailViewPanel";
import FormModal from "./FormModal";
interface Props {
layout: LayoutElement[];
instanceId?: string;
onRefresh?: () => void;
onRowClick?: (instanceId: string) => void;
}
interface AnyNode {
type?: string;
style?: Record<string, string>;
config?: Record<string, any>;
children?: AnyNode[];
}
function flatten(nodes: AnyNode[] | undefined): AnyNode[] {
if (!nodes) return [];
const out: AnyNode[] = [];
for (const n of nodes) {
if (n.config && (n.config.type || n.config.job_template)) out.push(n);
if (n.children?.length) out.push(...flatten(n.children));
}
return out;
}
export default function ScreenRenderer({ layout, instanceId, onRefresh, onRowClick }: Props) {
const [formModal, setFormModal] = useState<{ activityId: string; instanceId?: string; title?: string } | null>(null);
const buttons: ReactNode[] = [];
const elements: { type: "rv" | "dv"; viewId: number | string; style?: Record<string, string> }[] = [];
for (const node of flatten(layout as AnyNode[])) {
const cfg = node.config!;
if (cfg.type === "button" || cfg.job_template) {
const aid =
cfg.params?.activity_uid_init ||
cfg.params?.activity_id_init ||
cfg.params?.activity_uid ||
cfg.params?.activity_id;
if (!aid) continue;
const label = (cfg.name || "").replace(/^\+\s*/, "");
buttons.push(
<button
key={aid}
onClick={() => setFormModal({ activityId: aid, instanceId: cfg.job_template === "perform_activity" ? instanceId : undefined, title: label })}
className="h-8 px-3.5 text-[12px] font-semibold text-white rounded-xl inline-flex items-center gap-1.5 transition-all hover:scale-[1.02] active:scale-[0.98] mh-glow"
style={{ background: "linear-gradient(135deg, #C8102E, #E84258)" }}
>
<Plus size={13} strokeWidth={2.5} />{label}
</button>
);
} else if (cfg.type === "recordsview") {
const id = cfg.view_uuid || cfg.view_id;
elements.push({ type: "rv", viewId: id, style: node.style });
} else if (cfg.type === "detailsview") {
const id = cfg.view_uuid || cfg.view_id;
elements.push({ type: "dv", viewId: id, style: node.style });
}
}
return (
<>
<div className="space-y-4">
{elements.map((el, i) => {
if (el.type === "rv") {
return (
<div key={i} style={el.style}>
<RecordViewTable
viewId={el.viewId}
onRowClick={onRowClick}
toolbarAction={buttons.length > 0 ? <div className="flex items-center gap-2">{buttons}</div> : undefined}
/>
</div>
);
}
if (el.type === "dv") {
return <div key={i} style={el.style}><DetailViewPanel viewId={el.viewId} instanceId={instanceId || ""} /></div>;
}
return null;
})}
{elements.length === 0 && buttons.length > 0 && (
<div className="flex items-center gap-2">{buttons}</div>
)}
</div>
{formModal && (
<FormModal
activityId={formModal.activityId}
instanceId={formModal.instanceId}
title={formModal.title}
onClose={() => setFormModal(null)}
onSuccess={() => { setFormModal(null); onRefresh?.(); }}
/>
)}
</>
);
}

View File

@ -0,0 +1,50 @@
export function Skeleton({ className = "" }: { className?: string }) {
return <div className={`animate-pulse bg-stone-200/70 dark:bg-zinc-800 rounded ${className}`} />;
}
export function TableSkeleton({ rows = 5, cols = 6 }: { rows?: number; cols?: number }) {
return (
<div className="rounded-2xl border border-stone-200 dark:border-zinc-700/60 bg-white dark:bg-zinc-900 overflow-hidden shadow-sm">
<div className="px-5 py-3.5 border-b border-stone-100 dark:border-zinc-800 flex items-center justify-between">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-8 w-8 rounded-lg" />
</div>
<div className="px-4 py-2.5 border-b border-stone-100 dark:border-zinc-800 bg-stone-50/50 dark:bg-zinc-800/30 flex gap-4">
{Array.from({ length: cols }).map((_, i) => (
<Skeleton key={i} className="h-3 flex-1" />
))}
</div>
{Array.from({ length: rows }).map((_, ri) => (
<div key={ri} className="px-4 py-4 border-b border-stone-50 dark:border-zinc-800/60 flex gap-4">
{Array.from({ length: cols }).map((_, ci) => (
<Skeleton key={ci} className={`h-4 flex-1 ${ci === 0 ? "max-w-[80px]" : ""}`} />
))}
</div>
))}
</div>
);
}
export function DetailSkeleton() {
return (
<div className="space-y-4">
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-stone-200 dark:border-zinc-700/60 px-5 py-3 flex gap-4">
<Skeleton className="h-6 w-32 rounded-full" />
<Skeleton className="h-4 w-20 mt-1" />
</div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-stone-200 dark:border-zinc-700/60 overflow-hidden">
<div className="px-5 py-3 border-b border-stone-100 dark:border-zinc-800">
<Skeleton className="h-4 w-32" />
</div>
<div className="grid grid-cols-3">
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} className="px-5 py-4 border-b border-stone-50 dark:border-zinc-800/60">
<Skeleton className="h-3 w-20 mb-2" />
<Skeleton className="h-4 w-32" />
</div>
))}
</div>
</div>
</div>
);
}

21
src/components/Tile.tsx Normal file
View File

@ -0,0 +1,21 @@
interface Props {
title?: string;
value: number | string | null | undefined;
loading?: boolean;
}
export default function Tile({ title, value, loading }: Props) {
const display = value == null ? "—" : typeof value === "number" ? value.toLocaleString("en-IN") : String(value);
return (
<div className="flex-1 min-w-[140px] bg-white dark:bg-zinc-900 rounded-2xl border border-stone-200/80 dark:border-zinc-700/60 px-4 py-3 shadow-sm">
{title && (
<div className="text-[10px] font-semibold uppercase tracking-widest text-stone-400 dark:text-zinc-500 mb-1">
{title}
</div>
)}
<div className="text-[22px] font-bold text-stone-900 dark:text-zinc-100 tabular-nums leading-tight">
{loading ? <span className="inline-block h-6 w-12 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse" /> : display}
</div>
</div>
);
}

View File

@ -0,0 +1,571 @@
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";
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;
td_mode?: "home" | "showroom" | null;
is_expert_td?: boolean | null;
home_address?: 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<TestDrive[]>([]);
const [cars, setCars] = useState<Car[]>([]);
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>("");
useEffect(() => {
let cancelled = false;
setLoading(true);
// The /rdbms-templates/.../records handler clamps limit to 200 server-side,
// so loop with offset until we've drained `total`. Without this, ~80 of 280
// test_drives are silently dropped and entire cars (heap-order-last) vanish.
const PAGE = 200;
async function fetchAllDrives(): Promise<TestDrive[]> {
const out: TestDrive[] = [];
let offset = 0;
for (;;) {
const resp = await fetchRdbmsLookupRecords(CALENDAR_LOOKUPS.TEST_DRIVES.rdbmsTemplateUuid, {
workflowId: WORKFLOW_NUMERIC_ID,
activityId: ACTIVITY_IDS.INIT_ACTIVITY,
fieldId: CALENDAR_LOOKUPS.TEST_DRIVES.fieldId,
limit: PAGE,
offset,
});
const batch = (resp.records as unknown as TestDrive[]) ?? [];
out.push(...batch);
if (batch.length < PAGE || out.length >= resp.total) break;
offset += PAGE;
}
return out;
}
Promise.all([
fetchAllDrives(),
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);
setCars((c.records as unknown as Car[]) ?? []);
const days = td.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<number, Car>();
cars.forEach((c) => m.set(Number(c.id), c));
return m;
}, [cars]);
const models = useMemo(() => {
const set = new Set<string>();
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<string>();
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<string, TestDrive>();
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<string, number>();
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 <div className="text-stone-400 text-sm">Loading calendar</div>;
}
if (error) {
return (
<div className="px-4 py-3 text-sm" style={{ background: ACCENT_SOFT, color: "#5f0229", border: `1px solid ${ACCENT}` }}>
Couldn't load calendar: {error}
</div>
);
}
if (!weekStart || timeSlots.length === 0) {
return <div className="text-stone-400 text-sm">No slots configured.</div>;
}
return (
<>
{/* Header — page title + week nav */}
<div className="mb-6 flex items-end justify-between gap-4 flex-wrap">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.15em]" style={{ color: ACCENT }}>
Test-drive lifecycle
</div>
<h1 className="text-[24px] font-semibold tracking-tight leading-tight mt-1.5" style={{ color: INK }}>
Slots calendar
</h1>
</div>
<div className="flex items-center gap-3 flex-wrap">
<div className="relative">
<select
value={modelFilter}
onChange={(e) => setModelFilter(e.target.value)}
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 }}
>
<option value="">All models</option>
{models.map((m) => <option key={m} value={m}>{m}</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>
<button
onClick={() => setWeekStart((k) => k && addDaysKey(k, -7))}
className="h-9 w-9 inline-flex items-center justify-center bg-white hover:bg-stone-50 transition-colors"
style={{ border: `1px solid ${TM_BORDER}`, color: INK }}
aria-label="Previous week"
>
<ChevronLeft size={16} />
</button>
<div className="text-[13px] font-semibold tabular-nums px-3" style={{ color: INK }}>
{fmtDayLabel(weekDays[0]).day} {fmtDayLabel(weekDays[0]).month} {fmtDayLabel(weekDays[6]).day} {fmtDayLabel(weekDays[6]).month}
</div>
<button
onClick={() => setWeekStart((k) => k && addDaysKey(k, 7))}
className="h-9 w-9 inline-flex items-center justify-center bg-white hover:bg-stone-50 transition-colors"
style={{ border: `1px solid ${TM_BORDER}`, color: INK }}
aria-label="Next week"
>
<ChevronRight size={16} />
</button>
</div>
</div>
{/* Legend */}
<div className="mb-4 flex items-center gap-5 text-[11.5px] text-stone-500 flex-wrap">
<div className="flex items-center gap-2">
<span className="w-3 h-3" style={{ background: ACCENT }} />
Booked
</div>
<div className="flex items-center gap-2">
<span className="w-3 h-3 bg-white" style={{ border: `1px solid ${TM_BORDER}` }} />
Available
</div>
<div className="flex items-center gap-1.5">
<Building2 size={12} className="text-stone-500" /> Showroom
</div>
<div className="flex items-center gap-1.5">
<Home size={12} className="text-stone-500" /> Home
</div>
<div className="flex items-center gap-2">
<span className="w-3 h-3" style={{ background: ACCENT, boxShadow: "inset 0 0 0 2px #d4a017" }} />
Expert TD
</div>
<div className="ml-auto text-stone-400">
{totalBookings} bookings across {weekDays.length} days
</div>
</div>
{/* Grid */}
<div className="bg-white overflow-x-auto" style={{ border: `1px solid ${TM_BORDER}` }}>
<table className="w-full" style={{ tableLayout: "fixed", borderCollapse: "collapse" }}>
<colgroup>
<col style={{ width: 80 }} />
{weekDays.map((d) => <col key={d} />)}
</colgroup>
<thead>
<tr>
<th className="text-left px-3 py-3 text-[10px] font-semibold uppercase tracking-[0.12em] text-stone-400" style={{ borderBottom: `1px solid ${TM_BORDER}` }}>
IST
</th>
{weekDays.map((dk) => {
const lbl = fmtDayLabel(dk);
const count = bookingsByDay.get(dk) ?? 0;
return (
<th key={dk} className="text-left px-3 py-3" style={{ borderBottom: `1px solid ${TM_BORDER}`, borderLeft: `1px solid ${TM_BORDER}` }}>
<div className="flex items-baseline justify-between gap-2">
<div>
<div className="text-[10px] font-semibold uppercase tracking-[0.12em] text-stone-400">{lbl.weekday}</div>
<div className="text-[15px] font-bold leading-none mt-1 tabular-nums" style={{ color: INK }}>
{lbl.day} <span className="text-[11px] font-medium text-stone-400 ml-0.5">{lbl.month}</span>
</div>
</div>
{count > 0 && (
<span className="text-[10px] font-bold tabular-nums text-white px-1.5 py-0.5" style={{ background: ACCENT }}>
{count}
</span>
)}
</div>
</th>
);
})}
</tr>
</thead>
<tbody>
{timeSlots.map((tk) => (
<tr key={tk}>
<td className="px-3 py-3 align-top text-[12px] font-semibold tabular-nums text-stone-500" style={{ borderTop: `1px solid ${TM_BORDER}` }}>
{tk}
</td>
{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) => {
const cell = grid.get(`${dk}|${tk}|${Number(c.id)}`);
if (!cell) {
return (
<div key={c.id} className="h-7 px-2 flex items-center text-[10.5px] text-stone-400" style={{ border: `1px solid ${TM_BORDER}` }}>
</div>
);
}
const booked = cell.status === "booked";
const isHome = cell.td_mode === "home";
const isExpert = !!cell.is_expert_td;
const ModeIcon = isHome ? Home : Building2;
return (
<button
key={c.id}
onClick={() => booked && setSelected(cell)}
className={`h-7 px-2 flex items-center justify-between gap-1.5 text-[10.5px] font-medium transition-colors ${booked ? "cursor-pointer" : "cursor-default"}`}
style={
booked
? {
background: ACCENT,
color: "white",
boxShadow: isExpert ? "inset 0 0 0 2px #d4a017" : undefined,
}
: { background: "white", color: "#78716c", border: `1px solid ${TM_BORDER}` }
}
title={
booked
? `${cell.customer_name ?? "Booked"} · ${c.model} · ${isHome ? "Home" : "Showroom"}${isExpert ? " · Expert TD" : ""}`
: `Available · ${c.model}`
}
>
<span className="flex items-center gap-1 min-w-0">
{booked && <ModeIcon size={10} className="shrink-0 opacity-90" />}
<span className="truncate">{c.model}</span>
</span>
{booked && cell.customer_name && (
<span className="truncate font-semibold text-white/90">
{cell.customer_name.split(" ")[0]}
</span>
)}
</button>
);
})}
</div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<BookingPanel
drive={selected}
car={selected ? carById.get(Number(selected.car_id)) : undefined}
onClose={() => setSelected(null)}
/>
</>
);
}
function BookingPanel({ drive, car, onClose }: { drive: TestDrive | null; car: Car | undefined; onClose: () => void }) {
const open = !!drive;
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
return (
<>
<div
className={`fixed inset-0 z-40 transition-opacity duration-200 ${open ? "opacity-100" : "opacity-0 pointer-events-none"}`}
style={{ background: "rgba(6,31,92,0.35)" }}
onClick={onClose}
/>
<aside
className={`fixed top-0 right-0 bottom-0 z-50 w-full max-w-md bg-white shadow-2xl flex flex-col transition-transform duration-200 ease-out ${open ? "translate-x-0" : "translate-x-full"}`}
style={{ borderLeft: `1px solid ${TM_BORDER}` }}
role="dialog"
aria-hidden={!open}
>
{drive && <BookingPanelBody drive={drive} car={car} onClose={onClose} />}
</aside>
</>
);
}
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`;
return (
<>
<div className="relative px-6 py-5 text-white shrink-0" style={{ background: TM_NAVY }}>
<span className="absolute top-0 left-0 bottom-0 w-1.5" style={{ background: ACCENT }} />
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.15em] inline-flex items-center gap-1.5" style={{ color: ACCENT_SOFT }}>
<CalendarIcon size={11} /> Booking
{drive.is_expert_td && (
<span
className="ml-1 inline-flex items-center gap-1 px-1.5 py-0.5 text-[9.5px] font-bold uppercase tracking-wide"
style={{ background: "#d4a017", color: "#1a1a1a", borderRadius: 3 }}
>
Expert TD
</span>
)}
</div>
<div className="text-[22px] font-bold leading-tight mt-1 tabular-nums">
{day.weekday}, {day.day} {day.month} · {time}
</div>
<div className="mt-2 text-[13px] text-white/85">
{drive.customer_name ?? "—"}
</div>
</div>
<button
onClick={onClose}
className="text-white/70 hover:text-white transition-colors shrink-0"
aria-label="Close"
>
<X size={18} />
</button>
</div>
</div>
<div className="overflow-y-auto flex-1">
{/* Car image hero */}
{car && (
<div className="relative w-full bg-stone-100" style={{ aspectRatio: "16 / 9" }}>
<img
src={carImageUrl(car.model)}
alt={car.model}
className="absolute inset-0 w-full h-full object-cover"
onError={(e) => {
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;
}}
/>
<div className="absolute inset-x-0 bottom-0 px-5 py-3" style={{ background: "linear-gradient(to top, rgba(6,31,92,0.85), transparent)" }}>
<div className="text-white text-[18px] font-bold leading-none">{car.model}</div>
{car.variant && <div className="text-white/80 text-[12px] mt-1">{car.variant}</div>}
</div>
</div>
)}
<PanelSection label="Vehicle">
<Row label="Color">
{car?.color ? (
<span className="inline-flex items-center gap-2">
<span className="w-3 h-3 rounded-full" style={{ background: colorSwatch(car.color), border: "1px solid rgba(0,0,0,0.1)" }} />
{car.color}
</span>
) : "—"}
</Row>
<Row label="Ex-showroom">
<span className="font-semibold tabular-nums" style={{ color: INK }}>{fmtPriceInr(car?.price_inr)}</span>
</Row>
</PanelSection>
<PanelSection label="Customer">
<Row label="Name">
<span className="font-semibold" style={{ color: INK }}>{drive.customer_name ?? "—"}</span>
</Row>
<Row label="Phone">
{drive.customer_phone ? (
<a href={`tel:${drive.customer_phone}`} className="inline-flex items-center gap-1.5 font-semibold text-stone-700 hover:text-[#e31837] transition-colors tabular-nums">
<Phone size={12} className="text-stone-400" />
{drive.customer_phone}
</a>
) : "—"}
</Row>
</PanelSection>
<PanelSection label="Dealer calendar">
<Row label="Slot">
<span className="font-semibold tabular-nums" style={{ color: INK }}>{day.weekday} {day.day} {day.month}, {time}</span>
</Row>
<Row label="Mode">
<span className="inline-flex items-center gap-1.5 font-semibold" style={{ color: INK }}>
{drive.td_mode === "home" ? <Home size={12} /> : <Building2 size={12} />}
{drive.td_mode === "home" ? "Home visit" : "Showroom"}
</span>
</Row>
{drive.td_mode === "home" && drive.home_address && (
<Row label="Address">{drive.home_address}</Row>
)}
{drive.booked_at && (
<Row label="Reserved at">
{new Date(drive.booked_at).toLocaleString("en-IN", { dateStyle: "medium", timeStyle: "short" })}
</Row>
)}
{drive.notes && <Row label="Notes">{drive.notes}</Row>}
</PanelSection>
</div>
</>
);
}
function PanelSection({ label, children }: { label: string; children: React.ReactNode }) {
return (
<section className="px-6 py-5" style={{ borderBottom: `1px solid ${TM_BORDER}` }}>
<div className="text-[10px] font-bold uppercase tracking-[0.15em] text-stone-400 mb-3">{label}</div>
<div className="space-y-3">{children}</div>
</section>
);
}
function StatusPill({ status, tone }: { status: string; tone: "booked" | "completed" | "failed" }) {
const palette: Record<string, { bg: string; fg: string; dot: string }> = {
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 (
<span className="inline-flex items-center gap-1.5 px-2 py-1 text-[11px] font-bold uppercase tracking-[0.04em]" style={{ background: p.bg, color: p.fg, borderRadius: 4 }}>
<span className="w-1.5 h-1.5 rounded-full" style={{ background: p.dot }} />
{status}
</span>
);
}
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 (
<div className="flex items-baseline gap-4 text-[13px]">
<div className="w-24 shrink-0 text-[11px] font-semibold uppercase tracking-[0.05em] text-stone-500">{label}</div>
<div className="flex-1 min-w-0 break-words" style={{ color: INK }}>{children}</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

90
src/config.ts Normal file
View File

@ -0,0 +1,90 @@
// Tech Mahindra Demo v2 — App 164, Org 43
export const ORG_ID = "43";
export const APP_ID = "164";
// Core-service expects the workflow UUID (clone-portable) under `workflow_uuid`.
export const WORKFLOW_ID = "17670bed-d52c-455c-87cd-0c1a34417b4a";
export const ACTIVITY_IDS = {
INIT_ACTIVITY: "d951de1f-282f-4156-88c3-5bb3bb60d499", // INIT — Sales Agent captures lead
MARK_TEST_DRIVE_DONE: "5e419afc-3ddf-4351-91f4-6bf59035817c", // Manual — after the drive
AI_CONFIRM_SCHEDULING_SUCCESS: "a2222222-2222-4abc-8000-000000000002", // AI submits on call success
AI_CONFIRM_SCHEDULING_FAILED: "a3333333-3333-4abc-8000-000000000003", // AI submits on call failure
RETRY_SCHEDULING_CALL: "a4444444-4444-4abc-8000-000000000004", // Re-fire AI from failed state
AI_CONFIRM_FEEDBACK_COLLECTED: "a6666666-6666-4abc-8000-000000000006", // AI submits on feedback success
AI_CONFIRM_FEEDBACK_FAILED: "a7777777-7777-4abc-8000-000000000007", // AI submits on feedback failure
RETRY_FEEDBACK_CALL: "a8888888-8888-4abc-8000-000000000008", // Re-fire AI from failed feedback
CONCERN_EXPERT_TD_BOOKED: "e1111111-1111-4abc-8000-000000000009", // AI submits when feedback call surfaces concern + expert TD slot booked
MARK_EXPERT_TD_DONE: "e2222222-2222-4abc-8000-000000000010", // Dealer marks the expert/specialist drive complete
} as const;
export const RECORD_VIEW_IDS = {
LEADS: "e4499533-1269-490e-be24-a744da8411af",
ACTIVE_SCHEDULING: "rv-mahindra-active-scheduling",
PENDING_FEEDBACK: "rv-mahindra-pending-feedback",
COMPLETED: "rv-mahindra-completed",
SCHEDULED_TEST_DRIVES: "rv-mahindra-scheduled-td",
CONCERN_EXPERT_TD: "ab66664a-19e1-45a2-be51-dca0211a8be7",
} as const;
// rv_uid -> field_keys that should NOT render as table columns even though
// they're declared on the RV. current_state_id lives on these RVs only so the
// server-side prefilter validator accepts it; the rendered table doesn't need
// the raw UUID column.
export const RV_HIDDEN_COLUMNS: Record<string, string[]> = {
"rv-mahindra-active-scheduling": ["current_state_id"],
"rv-mahindra-scheduled-td": ["current_state_id"],
"rv-mahindra-pending-feedback": ["current_state_id"],
"rv-mahindra-completed": ["current_state_id"],
"ab66664a-19e1-45a2-be51-dca0211a8be7": ["current_state_id"],
};
export const DETAIL_VIEW_IDS = {
INSTANCE_DETAIL: "7865e1d9-9f7c-489e-a12b-98463ebbb5c1",
} as const;
// Parent screen source_uuid (nav target) → dv-screen source_uuid.
// Each parent screen embeds an rv-screen via a `recordsview` element; this map
// short-circuits the lookup until row-click jobs land on the rv_tables in studio.
export const SCREEN_TO_DV: Record<string, string> = {
"bde69570-1b55-48ae-b670-bcfcaa093505": "05169c21-b5a5-4aa1-8faa-b0ddc8989c7d", // Active Scheduling
"98af93e0-0f66-444b-a007-129b322be86b": "ca9d2c41-f998-44ae-95d7-299b4c8aa895", // Scheduled Test Drives
"dfa82b37-ecc0-4b21-99aa-ae50d62c4dfb": "1eb72636-f6fc-41b6-9107-775f93de1fa1", // Pending Feedback
"1fe0a811-19bf-4dbd-a643-32ae1a067162": "f9828eb2-2c32-4a87-b7f1-fc0c8c16ac86", // Completed
"66c003dc-0162-46f2-9b87-a7b73051a4ca": "f07f3a7f-300e-4040-b5ff-dba75493738a", // Leads
"857596bf-2d50-489d-9316-c5dd10b1b42d": "9cb88bfa-13b5-42e3-88dd-c7eb1f419439", // Concern - Expert TD
};
// Workflow states
export const STATE_IDS = {
AWAITING_SCHEDULER_CALL: "9df9c9fa-7222-446c-a9f5-a40c8dbe91c2",
SCHEDULED: "7ecbf865-a1ce-48ce-a409-7fb5cf5213b8",
AWAITING_FEEDBACK_CALL: "cd8e7333-8a4f-41c0-afe2-fbb1f5b9f52e",
FEEDBACK_COLLECTED: "aab92dd6-46e6-4448-b61b-fe37f8727c00",
SCHEDULING_CALL_FAILED: "b1f1ce11-fa11-4abc-9001-0000000000a1",
FEEDBACK_CALL_FAILED: "b1f1ce11-fa11-4abc-9001-0000000000a2",
EXPERT_TD_SCHEDULED: "e0000000-0000-4abc-9003-000000000001",
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",
DEALER: "Dealer",
} as const;

17
src/hooks/AuthContext.tsx Normal file
View File

@ -0,0 +1,17 @@
import React, { createContext, useContext } from "react";
import { useAuth } from "./useAuth";
type AuthContextValue = ReturnType<typeof useAuth>;
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const auth = useAuth();
return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
}
export function useAuthContext(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuthContext must be inside <AuthProvider>");
return ctx;
}

View File

@ -0,0 +1,36 @@
import { createContext, useContext, useEffect, useState } from "react";
type Theme = "light" | "dark";
interface ThemeContextValue {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue>({
theme: "light",
toggleTheme: () => {},
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
const stored = localStorage.getItem("mahindra_theme");
if (stored === "dark" || stored === "light") return stored;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
});
useEffect(() => {
const root = document.documentElement;
if (theme === "dark") root.classList.add("dark");
else root.classList.remove("dark");
localStorage.setItem("mahindra_theme", theme);
}, [theme]);
const toggleTheme = () => setTheme((t) => (t === "light" ? "dark" : "light"));
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>{children}</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);

93
src/hooks/useAuth.ts Normal file
View File

@ -0,0 +1,93 @@
import { useState, useCallback } from "react";
import type { User } from "../zino-sdk/types";
const TOKEN_KEY = "mahindra_token";
const USER_KEY = "mahindra_user";
interface LoginParams {
email: string;
password: string;
orgId?: string;
}
interface AuthState {
user: User | null;
token: string | null;
loading: boolean;
error: string | null;
}
export function useAuth() {
const [state, setState] = useState<AuthState>(() => {
const token = localStorage.getItem(TOKEN_KEY);
const userJson = localStorage.getItem(USER_KEY);
let user: User | null = null;
if (userJson) {
try { user = JSON.parse(userJson); } catch {}
}
return { user, token, loading: false, error: null };
});
const apiUrl = (import.meta.env.VITE_ZINO_API_URL || "").replace(/\/+$/, "");
const login = useCallback(
async ({ email, password, orgId }: LoginParams) => {
setState((s) => ({ ...s, loading: true, error: null }));
try {
const res = await fetch(`${apiUrl}/usr/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
password,
...(orgId ? { org_id: orgId } : {}),
}),
});
if (!res.ok) {
let msg = "Login failed";
try {
const body = await res.json();
if (body.error) msg = body.error;
} catch {}
throw new Error(msg);
}
const data = await res.json();
const { token, user } = data as { token: string; user: User };
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(USER_KEY, JSON.stringify(user));
localStorage.setItem("zino_token", token);
setState({ user, token, loading: false, error: null });
return { token, user };
} catch (err: any) {
const msg = err?.message || "Login failed";
setState((s) => ({ ...s, loading: false, error: msg }));
throw err;
}
},
[apiUrl],
);
const logout = useCallback(() => {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
localStorage.removeItem("zino_token");
setState({ user: null, token: null, loading: false, error: null });
}, []);
const clearError = useCallback(() => {
setState((s) => ({ ...s, error: null }));
}, []);
return {
user: state.user,
token: state.token,
isAuthenticated: !!state.token,
loading: state.loading,
error: state.error,
login,
logout,
clearError,
};
}

View File

@ -0,0 +1,414 @@
// PROTOTYPE — shared data hooks for /variants/*. Sharing DATA, not UI.
// When a variant wins, the winner stays; this hook is deleted.
import { useEffect, useState, useCallback } from "react";
import {
getHeaderConfig, getScreen, getRVScreen, getRecordView,
getDVScreen, getDetailView, getInstance,
type NavItem, type LayoutElement, type RecordViewField,
type SearchQuery, type TileValue, type ChartDataResponse,
type AuditEntry,
} from "../api/viewService";
import { WORKFLOW_ID, RV_HIDDEN_COLUMNS, ACTIVITY_IDS } from "../config";
import { activityContext } from "../lib/activity";
// ---------------------------------------------------------------------------
// rv-screen layout walkers (same logic the production RecordViewTable uses;
// duplicated here so the prototype is self-contained and the existing red
// table is untouched until a variant wins).
// ---------------------------------------------------------------------------
export interface ActionCondition {
field_key: string; operator: string; value: unknown; data_type?: string;
}
export interface ActionConditions { logic?: "AND" | "OR"; conditions: ActionCondition[]; }
export interface ActionButton {
name: string; icon?: string; classes?: string[];
job_template?: string;
params?: { activity_uid?: string; workflow_uuid?: string; [k: string]: unknown };
show_conditions?: ActionConditions | null;
}
export interface ActionColumn { id: string; display: string; buttons: ActionButton[]; }
export type AnalyticsSpec =
| { kind: "tile"; uid: string; title?: string }
| { kind: "chart"; uid: string; title?: string; chartType?: string };
function walkLayout(layout: any, visit: (node: any) => void): void {
const walk = (nodes: any[]): void => {
for (const n of nodes ?? []) {
visit(n);
if (Array.isArray(n?.children)) walk(n.children);
}
};
walk(Array.isArray(layout) ? layout : []);
}
export function extractColumnLabels(layout: any): Record<string, string> {
const out: Record<string, string> = {};
walkLayout(layout, (n) => {
const cfg = n?.config;
if (cfg?.type === "rv_table" && Array.isArray(cfg.columns)) {
for (const c of cfg.columns) {
if (c?.key_name) {
const label = c.custom_label || c.display;
if (label) out[c.key_name] = label;
}
}
}
});
return out;
}
export function extractColumnKeys(layout: any): string[] {
const out: string[] = [];
walkLayout(layout, (n) => {
const cfg = n?.config;
if (cfg?.type === "rv_table" && Array.isArray(cfg.columns)) {
for (const c of cfg.columns) {
if (c?.key_name && c?.type !== "custom") out.push(c.key_name);
}
}
});
return out;
}
export function extractActionColumns(layout: any): ActionColumn[] {
const out: ActionColumn[] = [];
walkLayout(layout, (n) => {
const cfg = n?.config;
if (cfg?.type === "rv_table" && Array.isArray(cfg.columns)) {
for (const col of cfg.columns) {
if (col?.type === "custom" && Array.isArray(col.config)) {
const buttons = col.config.filter((b: any) => b?.type === "button");
if (buttons.length > 0) out.push({ id: col.id, display: col.display || "Actions", buttons });
}
}
}
});
return out;
}
export function extractAnalytics(layout: any): AnalyticsSpec[] {
const out: AnalyticsSpec[] = [];
walkLayout(layout, (n) => {
const cfg = n?.config;
if (cfg?.type === "rv_tile" && cfg.tile_uid) out.push({ kind: "tile", uid: cfg.tile_uid, title: cfg.title });
if (cfg?.type === "rv_chart" && cfg.chart_uid) out.push({ kind: "chart", uid: cfg.chart_uid, title: cfg.title, chartType: cfg.chart_type });
});
return out;
}
export function evalShowConditions(c: ActionConditions | null | undefined, row: Record<string, unknown>): boolean {
if (!c || !Array.isArray(c.conditions) || c.conditions.length === 0) return true;
const results = c.conditions.map((cond) => {
const v = row[cond.field_key];
switch (cond.operator) {
case "equals": return v === cond.value;
case "not_equals": return v !== cond.value;
case "is_empty": return v == null || v === "";
case "is_not_empty": return v != null && v !== "";
case "in": return Array.isArray(cond.value) && cond.value.includes(v as never);
case "not_in": return Array.isArray(cond.value) && !cond.value.includes(v as never);
default: return false;
}
});
return (c.logic === "OR") ? results.some(Boolean) : results.every(Boolean);
}
// ---------------------------------------------------------------------------
// Pull the recordview viewId out of a screen layout (the parent screen
// embeds an rv-screen via a `recordsview` element).
// ---------------------------------------------------------------------------
export function findRecordsViewId(layout: LayoutElement[]): number | string | null {
let found: number | string | null = null;
walkLayout(layout as any, (n) => {
if (found) return;
const cfg = n?.config;
if (cfg?.type === "recordsview") found = cfg.view_uuid || cfg.view_id || null;
});
return found;
}
// ---------------------------------------------------------------------------
// Header config — nav items
// ---------------------------------------------------------------------------
export function useHeaderConfig() {
const [navItems, setNavItems] = useState<NavItem[]>([]);
const [logoText, setLogoText] = useState("Mahindra · AI Sales Ops");
const [ready, setReady] = useState(false);
useEffect(() => {
getHeaderConfig("desktop").then((cfg) => {
setNavItems(cfg.components?.nav?.items ?? []);
const lt = (cfg.components as any)?.logo?.text;
if (lt) setLogoText(lt);
}).catch(console.error).finally(() => setReady(true));
}, []);
return { navItems, logoText, ready };
}
// ---------------------------------------------------------------------------
// Active screen → recordview viewId
// ---------------------------------------------------------------------------
export interface ScreenButton { activityId: string; label: string; jobTemplate: string }
// Pull screen-level CTA buttons (e.g. "Add Lead") from a layout. Mirrors the
// parsing in ScreenRenderer.tsx — buttons declared at the screen level kick off
// an activity via activity_uid_init / activity_id_init.
function findScreenButtons(layout: LayoutElement[]): ScreenButton[] {
const out: ScreenButton[] = [];
walkLayout(layout as any, (n) => {
const cfg = n?.config;
if (!cfg) return;
if (cfg.type !== "button" && !cfg.job_template) return;
const aid =
cfg.params?.activity_uid_init ||
cfg.params?.activity_id_init ||
cfg.params?.activity_uid ||
cfg.params?.activity_id;
if (!aid) return;
out.push({
activityId: aid,
label: (cfg.name || "").replace(/^\+\s*/, "") || "New",
jobTemplate: cfg.job_template || "start_state_machine",
});
});
return out;
}
export function useActiveScreenRecordView(activeScreenId: string | null) {
const [viewId, setViewId] = useState<number | string | null>(null);
const [screenButtons, setScreenButtons] = useState<ScreenButton[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!activeScreenId) { setViewId(null); setScreenButtons([]); return; }
setLoading(true);
getScreen(activeScreenId)
.then((s) => {
const layout = s.layout ?? [];
setViewId(findRecordsViewId(layout));
setScreenButtons(findScreenButtons(layout));
})
.catch(() => { setViewId(null); setScreenButtons([]); })
.finally(() => setLoading(false));
}, [activeScreenId]);
return { viewId, screenButtons, loading };
}
// ---------------------------------------------------------------------------
// Full record view (rv-screen + recordview data)
// ---------------------------------------------------------------------------
export interface UseRecordViewState {
loading: boolean;
dataLoading: boolean;
error: string | null;
fields: RecordViewField[];
rows: Record<string, unknown>[];
columnLabels: Record<string, string>;
actionColumns: ActionColumn[];
analytics: AnalyticsSpec[];
tileValues: TileValue[];
chartData: ChartDataResponse[];
pagination: { page: number; limit: number; total_count: number; total_pages: number };
query: SearchQuery;
setQuery: (q: SearchQuery | ((q: SearchQuery) => SearchQuery)) => void;
refetch: () => void;
}
export function useRecordView(viewId: number | string | null): UseRecordViewState {
const [rvTemplateUid, setRvTemplateUid] = useState<string | null>(null);
const [layoutColumnKeys, setLayoutColumnKeys] = useState<string[] | null>(null);
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
const [actionColumns, setActionColumns] = useState<ActionColumn[]>([]);
const [analytics, setAnalytics] = useState<AnalyticsSpec[]>([]);
const [tileValues, setTileValues] = useState<TileValue[]>([]);
const [chartData, setChartData] = useState<ChartDataResponse[]>([]);
const [fields, setFields] = useState<RecordViewField[]>([]);
const [rows, setRows] = useState<Record<string, unknown>[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 25, total_count: 0, total_pages: 0 });
const [query, setQuery] = useState<SearchQuery>({ page: 1, limit: 25, sort_dir: "desc" });
const [loading, setLoading] = useState(true);
const [dataLoading, setDataLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [refreshTick, setRefreshTick] = useState(0);
useEffect(() => {
if (!viewId) return;
setLoading(true); setError(null);
setRvTemplateUid(null); setLayoutColumnKeys(null); setColumnLabels({}); setActionColumns([]);
setAnalytics([]); setTileValues([]); setChartData([]);
getRVScreen(viewId)
.then((rv) => {
setColumnLabels(extractColumnLabels(rv.layout));
setLayoutColumnKeys(extractColumnKeys(rv.layout));
setActionColumns(extractActionColumns(rv.layout));
setAnalytics(extractAnalytics(rv.layout));
setRvTemplateUid(rv.rv_template_uid);
})
.catch((err) => { setError(err.message); setLoading(false); });
}, [viewId]);
const fetchData = useCallback(async () => {
if (!rvTemplateUid || !viewId) return;
if (!loading) setDataLoading(true);
setError(null);
try {
const res = await getRecordView(rvTemplateUid, viewId, query);
setTileValues(res.tile_values ?? []);
setChartData(res.chart_data ?? []);
const hidden = new Set(RV_HIDDEN_COLUMNS[rvTemplateUid] ?? []);
const picked = res.config.fields.filter((f) => f.field_key !== "" && !hidden.has(f.field_key));
// If the screen layout picks an explicit column set, use it as the authoritative list + order.
if (layoutColumnKeys && layoutColumnKeys.length > 0) {
const byKey = new Map(picked.map((f) => [f.field_key, f]));
setFields(layoutColumnKeys.map((k) => byKey.get(k)).filter((f): f is RecordViewField => !!f));
} else {
setFields(picked);
}
const dataRows = Array.isArray(res.data) ? res.data : [];
setRows(dataRows);
if (res.pagination) setPagination(res.pagination);
else setPagination((p) => ({ ...p, total_count: dataRows.length, total_pages: Math.ceil(dataRows.length / p.limit) || 1 }));
} catch (err: any) { setError(err.message); }
finally { setLoading(false); setDataLoading(false); }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rvTemplateUid, viewId, query, refreshTick, layoutColumnKeys]);
useEffect(() => { fetchData(); }, [fetchData]);
return {
loading, dataLoading, error,
fields, rows, columnLabels, actionColumns, analytics, tileValues, chartData,
pagination, query, setQuery,
refetch: () => setRefreshTick((t) => t + 1),
};
}
// ---------------------------------------------------------------------------
// Detail view
// ---------------------------------------------------------------------------
export interface DVField { field_key: string; output_label: string; data_type: string }
export function useDetailView(viewId: number | string, instanceId: string) {
const [data, setData] = useState<Record<string, unknown> | null>(null);
const [fields, setFields] = useState<DVField[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!instanceId) return;
setLoading(true); setError(null);
getDVScreen(viewId)
.then((dv) => getDetailView(dv.dv_template_uid, instanceId))
.catch(() => getDetailView(viewId, 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]);
return { data, fields, loading, error };
}
// ---------------------------------------------------------------------------
// Instance + audit (for state badge / action menu / timeline)
// ---------------------------------------------------------------------------
// Synthesise an AuditEntry-compatible array from instance.data._activities so
// the existing timeline UI works without the broken /view/audit endpoint.
// INIT is synthesised from instance.created_at since the platform doesn't
// write INIT into _activities.
interface ActivityRecord {
_system?: { created_at?: string; user_id?: string; user_roles?: string[] | null };
data?: Record<string, unknown>;
}
function deriveAuditFromActivities(
createdAt: string,
instanceData: Record<string, unknown>,
activities: Record<string, ActivityRecord> | undefined,
): AuditEntry[] {
const out: AuditEntry[] = [];
let id = 1;
out.push({
id: id++,
user_id: "",
user_roles: [],
activity_id: ACTIVITY_IDS.INIT_ACTIVITY,
data: {},
execution_state: "completed",
created_at: createdAt,
});
if (activities) {
for (const [activityId, record] of Object.entries(activities)) {
out.push({
id: id++,
user_id: record._system?.user_id ?? "",
user_roles: record._system?.user_roles ?? [],
activity_id: activityId,
data: record.data ?? {},
execution_state: "completed",
created_at: record._system?.created_at ?? createdAt,
});
}
}
out.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
// Renumber ids chronologically and attach a context line drawn from the
// instance's top-level data (named keys, latest values).
return out.map((e, i) => ({
...e,
id: i + 1,
context: activityContext(e.activity_id, instanceData),
}));
}
export function useInstanceMeta(instanceId: string | null, refreshKey: number = 0) {
const [instanceState, setInstanceState] = useState<string | null>(null);
const [audit, setAudit] = useState<AuditEntry[]>([]);
useEffect(() => {
if (!instanceId) { setInstanceState(null); setAudit([]); return; }
getInstance(WORKFLOW_ID, instanceId)
.then((i) => {
setInstanceState(i.current_state_id);
const instanceData = (i.data as Record<string, unknown>) ?? {};
const acts = (instanceData as { _activities?: Record<string, ActivityRecord> })._activities;
setAudit(deriveAuditFromActivities(i.created_at, instanceData, acts));
})
.catch(() => { setAudit([]); });
}, [instanceId, refreshKey]);
return { instanceState, audit };
}
// ---------------------------------------------------------------------------
// Cell formatters — shared utilities (not UI)
// ---------------------------------------------------------------------------
export function fmtCellText(value: unknown, dataType: string, fieldKey: string): string {
if (value == null || value === "") return "—";
if (dataType === "phone" && typeof value === "object") {
const p = value as { phone_with_dial_code?: string; phone?: string; dial_code?: string };
return p.phone_with_dial_code || (p.dial_code && p.phone ? `${p.dial_code}${p.phone}` : p.phone || "—");
}
if (dataType === "number") {
const n = Number(value);
if (["amount", "tax", "price", "cost"].some((x) => fieldKey.toLowerCase().includes(x)))
return n.toLocaleString("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 0 });
return n.toLocaleString("en-IN");
}
if (dataType === "datetime" || dataType === "date") {
try { return new Date(String(value)).toLocaleDateString("en-IN", { day: "2-digit", month: "short", year: "numeric" }); }
catch { return String(value); }
}
return String(value);
}
export function fmtLabel(label: string): string {
return label.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}

37
src/index.css Normal file
View File

@ -0,0 +1,37 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html, body {
@apply font-sans antialiased text-stone-800 bg-white;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
}
* {
transition-property: background-color, border-color, color;
transition-duration: 150ms;
transition-timing-function: ease;
}
.animate-spin, .animate-pulse {
transition: none;
}
}
@layer utilities {
.tabular-nums {
font-variant-numeric: tabular-nums;
}
/* Mahindra signature red ramp */
.mh-red { background-color: #C8102E; }
.mh-red-dark { background-color: #9D0D24; }
.mh-red-soft { background-color: #FEE7EB; }
.gradient-mh { background: linear-gradient(135deg, #C8102E 0%, #E84258 100%); }
.gradient-mh-deep { background: linear-gradient(135deg, #9D0D24 0%, #C8102E 60%, #E84258 100%); }
.mh-glow { box-shadow: 0 10px 30px -10px rgba(200, 16, 46, 0.45); }
.mh-text-grad {
background: linear-gradient(135deg, #C8102E, #E84258);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
}

89
src/lib/activity.ts Normal file
View File

@ -0,0 +1,89 @@
import { ACTIVITY_IDS, STATE_IDS } from "../config";
export 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";
case ACTIVITY_IDS.CONCERN_EXPERT_TD_BOOKED: return "AI · Concern → Expert TD Booked";
case ACTIVITY_IDS.MARK_EXPERT_TD_DONE: return "Expert TD Completed";
default: return id.slice(0, 8) + "…";
}
}
const FAILURE_STATE_IDS: ReadonlySet<string> = new Set([
STATE_IDS.SCHEDULING_CALL_FAILED,
STATE_IDS.FEEDBACK_CALL_FAILED,
]);
export function isFailedExecution(execution_state: string): boolean {
return execution_state === "TRIGGER_RESPONSE_TIMEOUT" || FAILURE_STATE_IDS.has(execution_state);
}
export function isInflightExecution(execution_state: string): boolean {
return execution_state === "TRIGGER_DISPATCHED";
}
// ---------------------------------------------------------------------------
// activityContext — short human-readable summary per activity, drawn from
// the workflow instance's top-level data fields (clean named keys, latest
// values). Returns "" when nothing useful is available.
// ---------------------------------------------------------------------------
function truncate(s: string, n: number): string {
return s.length > n ? s.slice(0, n - 1) + "…" : s;
}
function fmtSlot(iso: unknown): string | null {
if (typeof iso !== "string" || !iso) return null;
const d = new Date(iso);
if (isNaN(d.getTime())) return null;
return d.toLocaleString("en-IN", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
}
export function activityContext(activityId: string, data: Record<string, unknown>): string {
const d = data as Record<string, any>;
switch (activityId) {
case ACTIVITY_IDS.INIT_ACTIVITY: {
const name = d.customer_name;
const model = d.model_interest;
return [name, model].filter(Boolean).join(" · ");
}
case ACTIVITY_IDS.AI_CONFIRM_SCHEDULING_SUCCESS: {
const parts: string[] = [];
if (d.booking_id) parts.push(`Booked #${d.booking_id}`);
if (d.dealer_name) parts.push(`at ${d.dealer_name}`);
const slot = fmtSlot(d.booking_slot);
if (slot) parts.push(slot);
return parts.join(" · ");
}
case ACTIVITY_IDS.AI_CONFIRM_SCHEDULING_FAILED:
case ACTIVITY_IDS.AI_CONFIRM_FEEDBACK_FAILED:
return d.call_summary ? truncate(String(d.call_summary), 120) : "Call did not complete";
case ACTIVITY_IDS.AI_CONFIRM_FEEDBACK_COLLECTED: {
const parts: string[] = [];
if (d.feedback_rating != null) parts.push(`Rated ${d.feedback_rating}/10`);
if (d.feedback_concern) parts.push(`Concern: ${truncate(String(d.feedback_concern), 80)}`);
else if (d.feedback_liked_most) parts.push(`Liked: ${truncate(String(d.feedback_liked_most), 80)}`);
return parts.join(" · ");
}
case ACTIVITY_IDS.MARK_TEST_DRIVE_DONE:
return "Dealer marked test drive complete";
case ACTIVITY_IDS.MARK_EXPERT_TD_DONE:
return "Dealer marked expert test drive complete";
case ACTIVITY_IDS.CONCERN_EXPERT_TD_BOOKED: {
const slot = fmtSlot(d.booking_slot);
return slot ? `Expert TD scheduled · ${slot}` : "Expert TD scheduled";
}
case ACTIVITY_IDS.RETRY_SCHEDULING_CALL:
case ACTIVITY_IDS.RETRY_FEEDBACK_CALL:
return "Re-dispatched call";
default:
return "";
}
}

24
src/main.tsx Normal file
View File

@ -0,0 +1,24 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { ZinoProvider } from "./zino-sdk";
import { AuthProvider } from "./hooks/AuthContext";
import { ThemeProvider } from "./hooks/ThemeContext";
import "./index.css";
import App from "./App";
const apiUrl = import.meta.env.VITE_ZINO_API_URL || "";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ThemeProvider>
<ZinoProvider baseUrl={apiUrl}>
<BrowserRouter basename={import.meta.env.BASE_URL}>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</ZinoProvider>
</ThemeProvider>
</StrictMode>,
);

184
src/pages/Login.tsx Normal file
View File

@ -0,0 +1,184 @@
import { useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { useAuthContext } from "../hooks/AuthContext";
import { ORG_ID } from "../config";
import { PhoneCall, CalendarCheck2, Sparkles, ArrowRight } from "lucide-react";
// TM brand tokens — mirror VariantB.tsx / FormModal.tsx.
const ACCENT = "#e31837";
const ACCENT_SOFT = "#fde2e8";
const TM_NAVY = "#061f5c";
const TM_BORDER = "#e4e4ed";
const INK = "#17181a";
export default function Login() {
const { login, loading, error, clearError } = useAuthContext();
const navigate = useNavigate();
const location = useLocation();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [orgId] = useState(ORG_ID);
const from = (location.state as any)?.from?.pathname || "/";
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearError();
try {
await login({ email, password, orgId: orgId || undefined });
navigate(from, { replace: true });
} catch {}
};
const inputStyle: React.CSSProperties = { border: `1px solid ${TM_BORDER}`, borderRadius: 4 };
const onFocus = (e: React.FocusEvent<HTMLInputElement>) => {
e.currentTarget.style.borderColor = ACCENT;
e.currentTarget.style.boxShadow = `0 0 0 3px ${ACCENT_SOFT}`;
};
const onBlur = (e: React.FocusEvent<HTMLInputElement>) => {
e.currentTarget.style.borderColor = TM_BORDER;
e.currentTarget.style.boxShadow = "none";
};
return (
<div className="min-h-screen flex bg-white">
{/* ── Left — TM brand panel ───────────────────────────────────── */}
<div
className="hidden lg:flex lg:w-[45%] flex-col justify-between p-14 relative overflow-hidden text-white"
style={{ background: TM_NAVY }}
>
{/* Red vertical rail — TM editorial signature (mirrors the hero on DV) */}
<span className="absolute top-0 left-0 bottom-0 w-1.5" style={{ background: ACCENT }} />
{/* Diagonal red slice on the right — fills dead space, brand accent */}
<span
className="absolute top-0 right-0 bottom-0 w-56 pointer-events-none opacity-50"
style={{
background: `linear-gradient(115deg, transparent 0%, transparent 55%, ${ACCENT} 55%, ${ACCENT} 58%, transparent 58%, transparent 72%, ${ACCENT} 72%, ${ACCENT} 74%, transparent 74%)`,
}}
/>
{/* Brand lockup — zino mark + Sales Ops subtitle */}
<div className="relative z-10 leading-tight">
<img src={`${import.meta.env.BASE_URL}zino.svg`} alt="Zino" className="h-7 w-auto" />
<div className="text-[10.5px] uppercase tracking-[0.18em] text-white/60 mt-2">AI Sales Ops</div>
</div>
{/* Headline + feature bullets */}
<div className="relative z-10 max-w-md">
<div className="text-[12px] font-semibold uppercase tracking-[0.15em] mb-5" style={{ color: ACCENT }}>
AI · Test-drive scheduler
</div>
<h1 className="text-[2.6rem] font-bold leading-[1.05] tracking-tight mb-10 text-white">
Drive every lead<br />to the showroom floor.
</h1>
<div className="space-y-5">
{[
{ icon: <PhoneCall size={14} />, title: "Auto-dial leads", desc: "Maya, your AI scheduler, calls customers within seconds of capture." },
{ icon: <CalendarCheck2 size={14} />, title: "Live slot booking", desc: "Real-time availability against the dealer's calendar." },
{ icon: <Sparkles size={14} />, title: "Feedback loop", desc: "Post-drive AI follow-up captures NPS and concerns." },
].map((item) => (
<div key={item.title} className="flex items-start gap-3.5">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{ background: ACCENT, color: "white" }}
>
{item.icon}
</div>
<div className="min-w-0">
<div className="text-[13.5px] font-semibold text-white leading-tight">{item.title}</div>
<div className="text-[12px] mt-1 text-white/65 leading-relaxed">{item.desc}</div>
</div>
</div>
))}
</div>
</div>
{/* Footer rule */}
<div className="relative z-10 text-[11px] uppercase tracking-[0.18em] text-white/40">
© Zino
</div>
</div>
{/* ── Right — login form ──────────────────────────────────────── */}
<div className="flex-1 flex items-center justify-center px-8" style={{ background: "white" }}>
<div className="w-full max-w-[360px]">
{/* Compact lockup for mobile (left panel hidden) */}
<div className="flex items-center mb-10 lg:hidden">
<img src={`${import.meta.env.BASE_URL}zino.svg`} alt="Zino" className="h-6 w-auto" />
</div>
<div className="mb-8">
<div className="text-[11px] font-semibold uppercase tracking-[0.15em] mb-2" style={{ color: ACCENT }}>
Sign in
</div>
<h2 className="text-[26px] font-bold tracking-tight leading-none mb-2" style={{ color: INK }}>
Welcome back
</h2>
<p className="text-[13px] text-stone-500">
Sign in to the test-drive operations console.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-[11px] font-semibold uppercase tracking-[0.03em] text-stone-500 mb-2">
Email
</label>
<input
type="email" required autoComplete="email" autoFocus
value={email} onChange={(e) => { setEmail(e.target.value); clearError(); }}
onFocus={onFocus} onBlur={onBlur}
className="w-full h-11 px-3.5 text-[13.5px] bg-white text-stone-900 focus:outline-none transition placeholder:text-stone-400"
style={inputStyle}
placeholder="you@techmahindra.com"
/>
</div>
<div>
<label className="block text-[11px] font-semibold uppercase tracking-[0.03em] text-stone-500 mb-2">
Password
</label>
<input
type="password" required autoComplete="current-password"
value={password} onChange={(e) => { setPassword(e.target.value); clearError(); }}
onFocus={onFocus} onBlur={onBlur}
className="w-full h-11 px-3.5 text-[13.5px] bg-white text-stone-900 focus:outline-none transition placeholder:text-stone-400"
style={inputStyle}
placeholder="••••••••"
/>
</div>
{error && (
<div
className="text-[12.5px] px-3.5 py-2.5 font-medium"
style={{ background: ACCENT_SOFT, border: `1px solid #fbb4be`, color: "#5f0229", borderRadius: 4 }}
>
{error}
</div>
)}
<button
type="submit" disabled={loading}
className="w-full h-11 mt-2 disabled:opacity-60 text-white text-[13.5px] font-semibold inline-flex items-center justify-center gap-2 transition-colors hover:brightness-110"
style={{ background: ACCENT, borderRadius: 4 }}
>
{loading ? (
<><div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Signing in</>
) : (
<>Sign in <ArrowRight size={14} /></>
)}
</button>
</form>
<div className="flex items-center justify-center gap-1.5 mt-12">
<span className="text-[11px] uppercase tracking-[0.12em] text-stone-400">Powered by</span>
<span className="text-[11px] font-bold uppercase tracking-[0.12em]" style={{ color: INK }}>Zino</span>
</div>
</div>
</div>
</div>
);
}

11
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_ZINO_API_URL?: string;
readonly VITE_ZINO_MOCK?: string;
readonly VITE_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@ -0,0 +1,175 @@
import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useZino } from "./provider";
import type { FormField } from "./types";
export interface DynamicFormProps {
/** Workflow definition ID. */
workflowId: string;
/** Activity UID — the form schema is fetched for this activity. */
activityId: string;
/** Instance ID — omit for INIT activities (start workflow). */
instanceId?: string;
/** Called after successful submission with the result. */
onSuccess?: (result: unknown) => void;
/** Called on submission error. */
onError?: (error: unknown) => void;
/** Optional CSS class for the form wrapper. */
className?: string;
}
/**
* Renders a dynamic form based on the activity's form schema.
*
* Fetches the form config from the core-service, renders fields by type,
* and submits via performActivity or startWorkflow.
*
* ```tsx
* <DynamicForm
* workflowId={WORKFLOW_ID}
* activityId={ACTIVITY_IDS.ASSIGN_TICKET}
* instanceId={instanceId}
* onSuccess={() => { closeModal(); }}
* />
* ```
*/
export function DynamicForm({
workflowId,
activityId,
instanceId,
onSuccess,
onError,
className,
}: DynamicFormProps) {
const { workflows } = useZino();
const queryClient = useQueryClient();
const [formData, setFormData] = useState<Record<string, string>>({});
// Fetch form schema
const { data: form, isLoading: formLoading, error: formError } = useQuery({
queryKey: ["form", workflowId, activityId, instanceId],
queryFn: () => workflows.getForm(workflowId, activityId, "desktop", instanceId),
});
// Submit mutation
const submit = useMutation({
mutationFn: async (data: Record<string, string>) => {
if (instanceId) {
return workflows.performActivity(workflowId, instanceId, activityId, data);
} else {
return workflows.startWorkflow(workflowId, activityId, data);
}
},
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ["recordview"] });
queryClient.invalidateQueries({ queryKey: ["instance"] });
queryClient.invalidateQueries({ queryKey: ["detailview"] });
queryClient.invalidateQueries({ queryKey: ["audit"] });
onSuccess?.(result);
},
onError: (err) => {
onError?.(err);
},
});
const handleChange = (fieldId: string, value: string) => {
setFormData((prev) => ({ ...prev, [fieldId]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Map form field IDs to their mapped_activity_field_id
const mapped: Record<string, string> = {};
for (const field of form?.form_json?.fields ?? []) {
const key = field.mapped_activity_field_id || field.id;
mapped[key] = formData[field.id] ?? "";
}
submit.mutate(mapped);
};
if (formLoading) {
return <div className="flex justify-center py-8"><div className="animate-spin h-6 w-6 border-2 border-blue-500 border-t-transparent rounded-full" /></div>;
}
if (formError) {
return <div className="text-red-600 text-sm py-4">Failed to load form: {(formError as any)?.message ?? "Unknown error"}</div>;
}
const fields: FormField[] = form?.form_json?.fields ?? [];
return (
<form onSubmit={handleSubmit} className={className}>
{form?.form_json?.title && (
<h3 className="text-lg font-semibold mb-4">{form.form_json.title}</h3>
)}
<div className="space-y-4">
{fields.map((field) => (
<div key={field.id}>
<label className="block text-sm font-medium text-gray-700 mb-1">
{field.label}
</label>
{renderField(field, formData[field.id] ?? "", (v) => handleChange(field.id, v))}
</div>
))}
</div>
{submit.error && (
<div className="mt-3 text-sm text-red-600">
{(submit.error as any)?.message ?? "Submission failed"}
</div>
)}
<button
type="submit"
disabled={submit.isPending}
className="mt-6 w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium py-2.5 px-4 rounded-lg transition-colors"
>
{submit.isPending ? "Submitting..." : "Submit"}
</button>
</form>
);
}
function renderField(
field: FormField,
value: string,
onChange: (value: string) => void,
) {
const baseClass =
"w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent";
switch (field.type) {
case "paragraph":
return (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
rows={4}
className={baseClass}
placeholder={field.label}
/>
);
case "number":
return (
<input
type="number"
value={value}
onChange={(e) => onChange(e.target.value)}
className={baseClass}
placeholder={field.label}
/>
);
case "text":
default:
return (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
className={baseClass}
placeholder={field.label}
/>
);
}
}

64
src/zino-sdk/auth.ts Normal file
View File

@ -0,0 +1,64 @@
import type { ZinoClient } from "./client";
import type { LoginResponse, User } from "./types";
import { mockDelay, MOCK_USER } from "./mock";
/**
* Authentication methods login, logout, and JWT decoding.
* Maps to user-service endpoints.
*/
export class AuthService {
private client: ZinoClient;
constructor(client: ZinoClient) {
this.client = client;
}
async login(
email: string,
password: string,
orgId?: string,
): Promise<LoginResponse> {
if (this.client.isMock) {
await mockDelay();
const res: LoginResponse = {
token: "mock-jwt-token-" + Date.now(),
user: { ...MOCK_USER },
};
this.client.setToken(res.token);
return res;
}
const res = await this.client.request<LoginResponse>("POST", "/login", {
email,
password,
...(orgId ? { org_id: orgId } : {}),
});
this.client.setToken(res.token);
return res;
}
logout(): void {
this.client.setToken(null);
}
getCurrentUser(): User | null {
if (this.client.isMock) return { ...MOCK_USER };
const token = this.client.getToken();
if (!token) return null;
try {
const payload = JSON.parse(atob(token.split(".")[1]));
return {
id: payload.user_id ?? payload.sub,
org_id: payload.org_id ?? "",
name: payload.name ?? "",
email: payload.email ?? "",
roles: payload.roles ?? [],
groups: payload.groups ?? [],
role_assignments: payload.role_assignments ?? [],
};
} catch {
return null;
}
}
}

86
src/zino-sdk/client.ts Normal file
View File

@ -0,0 +1,86 @@
import type { ApiError, ZinoClientConfig } from "./types";
const TOKEN_KEY = "zino_token";
/**
* Core HTTP client for the Zino API.
*
* Persists JWT in localStorage so sessions survive page refresh.
*/
export class ZinoClient {
readonly baseUrl: string;
private token: string | null = null;
private onAuthError?: () => void;
constructor(config: ZinoClientConfig) {
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
this.onAuthError = config.onAuthError;
// Restore token from localStorage on init
if (typeof window !== "undefined") {
this.token = localStorage.getItem(TOKEN_KEY);
}
}
get isMock(): boolean {
return this.baseUrl === "mock";
}
setToken(token: string | null): void {
this.token = token;
if (typeof window !== "undefined") {
if (token) {
localStorage.setItem(TOKEN_KEY, token);
} else {
localStorage.removeItem(TOKEN_KEY);
}
}
}
getToken(): string | null {
return this.token;
}
async request<T>(
method: "GET" | "POST" | "PUT" | "DELETE",
path: string,
body?: unknown,
): Promise<T> {
const url = `${this.baseUrl}${path}`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (this.token) {
headers["Authorization"] = `Bearer ${this.token}`;
}
const res = await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (res.status === 401) {
this.token = null;
this.onAuthError?.();
const err: ApiError = { status: 401, message: "Unauthorized" };
throw err;
}
if (!res.ok) {
let message = res.statusText;
try {
const errBody = (await res.json()) as { error?: string };
if (errBody.error) message = errBody.error;
} catch {
// body wasn't JSON
}
const err: ApiError = { status: res.status, message };
throw err;
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
}

61
src/zino-sdk/index.ts Normal file
View File

@ -0,0 +1,61 @@
// ---------------------------------------------------------------------------
// Zino Service SDK — Public API
// ---------------------------------------------------------------------------
// Core client
export { ZinoClient } from "./client";
// Service modules
export { AuthService } from "./auth";
export { WorkflowService } from "./workflow";
export { ViewService } from "./views";
// React bindings
export { ZinoProvider, useZino, queryClient } from "./provider";
// Components
export { DynamicForm } from "./DynamicForm";
// Re-export TanStack Query hooks for convenience
export { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
// Types
export type {
User,
RoleAssignment,
LoginRequest,
LoginResponse,
App,
WorkflowDefinition,
State,
Transition,
Activity,
ActivityDataField,
AllowedRole,
WorkflowDataField,
GridColumn,
DataType,
StageGate,
StageGateRuleSet,
SGRuleOutcome,
WorkflowInstance,
ActivityLogEntry,
FormFieldType,
FormField,
FormConfig,
ActivityForm,
ViewField,
ViewFieldType,
ActionConfig,
Column,
SortDir,
TabularViewParams,
TabularViewResponse,
ActivityEntry,
InstanceReport,
DetailViewResponse,
ReportSection,
StateTransition,
ZinoClientConfig,
ApiError,
} from "./types";

243
src/zino-sdk/mock.ts Normal file
View File

@ -0,0 +1,243 @@
import type {
ActivityForm,
ActivityLogEntry,
Column,
InstanceReport,
ReportSection,
StateTransition,
TabularViewParams,
TabularViewResponse,
User,
WorkflowDefinition,
WorkflowInstance,
} from "./types";
export function mockDelay(): Promise<void> {
const ms = 200 + Math.random() * 200;
return new Promise((r) => setTimeout(r, ms));
}
function randomId(): string {
return Math.random().toString(36).slice(2, 10);
}
function isoNow(): string {
return new Date().toISOString();
}
function daysAgo(n: number): string {
const d = new Date();
d.setDate(d.getDate() - n);
return d.toISOString();
}
export const MOCK_USER: User = {
id: "usr-001",
org_id: "org-acme",
name: "Jane Doe",
email: "jane@acme.corp",
roles: ["admin", "reviewer"],
groups: ["engineering"],
role_assignments: [
{ role_id: "admin", positions: ["manager"] },
{ role_id: "reviewer" },
],
};
export const MOCK_WORKFLOW_DEF: WorkflowDefinition = {
workflow_id: "wf-support-ticket",
version: 1,
name: "Support Ticket",
states: [
{ uid: "s-new", name: "New", type: "initial", allowed_activities: ["a-init"] },
{ uid: "s-open", name: "Open", allowed_activities: ["a-assign", "a-close"] },
{ uid: "s-in-progress", name: "In Progress", allowed_activities: ["a-resolve", "a-escalate"] },
{ uid: "s-resolved", name: "Resolved", allowed_activities: ["a-reopen", "a-close"] },
{ uid: "s-closed", name: "Closed", type: "terminal", allowed_activities: [] },
],
activities: [
{ uid: "a-init", name: "Create Ticket", type: "INIT", data_fields: [
{ id: "f-title", name: "title", type: "local", data_type: "text", mandatory: true },
{ id: "f-desc", name: "description", type: "local", data_type: "longtext", mandatory: false },
{ id: "f-priority", name: "priority", type: "local", data_type: "text", mandatory: true },
] },
{ uid: "a-assign", name: "Assign Agent", type: "USER", data_fields: [
{ id: "f-agent", name: "assigned_to", type: "local", data_type: "text", mandatory: true },
] },
{ uid: "a-resolve", name: "Resolve", type: "USER", data_fields: [
{ id: "f-resolution", name: "resolution_notes", type: "local", data_type: "longtext", mandatory: true },
] },
{ uid: "a-escalate", name: "Escalate", type: "USER" },
{ uid: "a-reopen", name: "Reopen", type: "USER" },
{ uid: "a-close", name: "Close", type: "USER" },
],
transitions: [
{ from_state_id: "s-new", by_activity_id: "a-init", transition_type: "direct", to_state_id: "s-open" },
{ from_state_id: "s-open", by_activity_id: "a-assign", transition_type: "direct", to_state_id: "s-in-progress" },
{ from_state_id: "s-in-progress", by_activity_id: "a-resolve", transition_type: "direct", to_state_id: "s-resolved" },
{ from_state_id: "s-in-progress", by_activity_id: "a-escalate", transition_type: "direct", to_state_id: "s-open" },
{ from_state_id: "s-resolved", by_activity_id: "a-reopen", transition_type: "direct", to_state_id: "s-open" },
{ from_state_id: "s-resolved", by_activity_id: "a-close", transition_type: "direct", to_state_id: "s-closed" },
{ from_state_id: "s-open", by_activity_id: "a-close", transition_type: "direct", to_state_id: "s-closed" },
],
workflow_data_fields: [
{ id: "wdf-title", uid: "wdf-title", name: "title", data_type: "text" },
{ id: "wdf-desc", uid: "wdf-desc", name: "description", data_type: "longtext" },
{ id: "wdf-priority", uid: "wdf-priority", name: "priority", data_type: "text" },
],
};
const NAMES = ["Alice Johnson", "Bob Ramirez", "Chandra Patel", "Dina Sokolov", "Emeka Obi"];
const PRIORITIES = ["High", "Medium", "Low"];
const STATUSES = ["Open", "In Progress", "Resolved", "Closed"];
const TITLES = [
"Login page not loading",
"Payment gateway timeout",
"Dashboard chart misaligned",
"Export CSV returns empty file",
"Password reset email delayed",
"Mobile sidebar overlaps content",
"Search returns stale results",
"Role permissions not syncing",
];
export function mockWorkflowInstance(workflowId: string, instanceId?: string): WorkflowInstance {
return {
instance_id: instanceId ?? `inst-${randomId()}`,
workflow_id: workflowId,
workflow_version: 1,
current_state_id: "s-open",
data: {
title: TITLES[Math.floor(Math.random() * TITLES.length)],
priority: PRIORITIES[Math.floor(Math.random() * PRIORITIES.length)],
description: "Detailed description of the reported issue.",
},
created_at: daysAgo(3),
updated_at: isoNow(),
};
}
export function mockActivityForm(workflowId: string, activityId: string): ActivityForm {
return {
id: `form-${randomId()}`,
workflow_id: workflowId,
activity_id: activityId,
form_id: `form-${activityId}`,
device_type: "desktop",
form_json: {
title: activityId === "a-init" ? "Create Ticket" : "Update Ticket",
fields: [
{ id: "f1", label: "Title", type: "text", mapped_activity_field_id: "f-title" },
{ id: "f2", label: "Description", type: "paragraph", mapped_activity_field_id: "f-desc" },
{ id: "f3", label: "Priority", type: "text", mapped_activity_field_id: "f-priority" },
],
},
created_at: daysAgo(30),
};
}
export function mockTabularResponse(params: TabularViewParams): TabularViewResponse {
const pageSize = params.pageSize ?? 10;
const page = params.page ?? 1;
const total = 47;
const columns: Column[] = [
{ key: "instance_id", label: "ID", data_type: "text", sortable: true, filterable: false, searchable: true },
{ key: "title", label: "Title", data_type: "text", sortable: true, filterable: false, searchable: true },
{ key: "priority", label: "Priority", data_type: "text", sortable: true, filterable: true, searchable: false },
{ key: "status", label: "Status", data_type: "text", sortable: true, filterable: true, searchable: false },
{ key: "assigned_to", label: "Assigned To", data_type: "text", sortable: true, filterable: true, searchable: true },
{ key: "created_at", label: "Created", data_type: "text", sortable: true, filterable: false, searchable: false },
];
const rows: Record<string, unknown>[] = [];
const count = Math.min(pageSize, total - (page - 1) * pageSize);
for (let i = 0; i < count; i++) {
const idx = (page - 1) * pageSize + i;
rows.push({
instance_id: `TICKET-${1000 + idx}`,
title: TITLES[idx % TITLES.length],
priority: PRIORITIES[idx % PRIORITIES.length],
status: STATUSES[idx % STATUSES.length],
assigned_to: NAMES[idx % NAMES.length],
created_at: daysAgo(total - idx),
});
}
return { rows, total, page, columns };
}
export function mockInstanceReport(instanceId: string): {
report: InstanceReport;
sections: ReportSection[];
history: StateTransition[];
} {
const report: InstanceReport = {
workflow_id: "wf-support-ticket",
instance_id: instanceId,
workflow_version: 1,
current_state_id: "s-in-progress",
current_state_name: "In Progress",
global_data: {
title: "Payment gateway timeout",
description: "Users report intermittent 504 errors during checkout.",
priority: "High",
assigned_to: "Bob Ramirez",
},
activities: [
{ activity_id: "a-init", data: { title: "Payment gateway timeout" }, activity_performed_at: daysAgo(5), performed_by_id: "usr-001" },
{ activity_id: "a-assign", data: { assigned_to: "Bob Ramirez" }, activity_performed_at: daysAgo(4), performed_by_id: "usr-001" },
],
created_at: daysAgo(5),
updated_at: daysAgo(4),
};
const sections: ReportSection[] = [
{
title: "Instance Details",
fields: [
{ label: "Instance ID", value: instanceId },
{ label: "Workflow", value: "Support Ticket" },
{ label: "Current State", value: "In Progress" },
{ label: "Created", value: report.created_at },
{ label: "Updated", value: report.updated_at },
],
},
{
title: "Data",
fields: Object.entries(report.global_data).map(([k, v]) => ({ label: k, value: v })),
},
];
const history: StateTransition[] = [
{ from_state: "New", to_state: "Open", activity_name: "Create Ticket", performed_by: "Jane Doe", timestamp: daysAgo(5), data: {} },
{ from_state: "Open", to_state: "In Progress", activity_name: "Assign Agent", performed_by: "Jane Doe", timestamp: daysAgo(4), data: { assigned_to: "Bob Ramirez" } },
];
return { report, sections, history };
}
export function mockAuditLog(): ActivityLogEntry[] {
return [
{
id: 1,
user_id: "usr-001",
user_roles: ["admin"],
user_groups: ["engineering"],
activity_id: "a-init",
data: { title: "Payment gateway timeout", priority: "High" },
execution_state: "s-open",
created_at: daysAgo(5),
},
{
id: 2,
user_id: "usr-001",
user_roles: ["admin"],
user_groups: ["engineering"],
activity_id: "a-assign",
data: { assigned_to: "Bob Ramirez" },
execution_state: "s-in-progress",
created_at: daysAgo(4),
},
];
}

143
src/zino-sdk/provider.tsx Normal file
View File

@ -0,0 +1,143 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ZinoClient } from "./client";
import { AuthService } from "./auth";
import { WorkflowService } from "./workflow";
import { ViewService } from "./views";
import type { User } from "./types";
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
export interface ZinoContextValue {
client: ZinoClient;
auth: AuthService;
workflows: WorkflowService;
views: ViewService;
user: User | null;
setUser: (user: User | null) => void;
}
const ZinoContext = createContext<ZinoContextValue | null>(null);
const MOCK_SESSION_KEY = "zino_mock_session";
// Shared QueryClient — sensible defaults for generated apps.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // 30s before refetch
retry: 1,
refetchOnWindowFocus: false,
},
},
});
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export interface ZinoProviderProps {
baseUrl: string;
children: React.ReactNode;
}
/**
* Wrap your app with `<ZinoProvider>` to get access to Zino services
* and TanStack Query throughout the component tree.
*
* ```tsx
* <ZinoProvider baseUrl="http://localhost:8091">
* <App />
* </ZinoProvider>
* ```
*/
export function ZinoProvider({ baseUrl, children }: ZinoProviderProps) {
const [user, setUserState] = useState<User | null>(null);
const viteMock = import.meta.env.VITE_ZINO_MOCK === "true";
const resolvedUrl = baseUrl === "mock" || viteMock ? "mock" : baseUrl;
const isMock = resolvedUrl === "mock";
const setUser = useMemo(() => (u: User | null) => {
setUserState(u);
if (isMock) {
if (u) {
sessionStorage.setItem(MOCK_SESSION_KEY, "1");
} else {
sessionStorage.removeItem(MOCK_SESSION_KEY);
}
}
}, [isMock]);
// Restore session on mount — from localStorage token or mock sessionStorage.
useEffect(() => {
if (isMock && sessionStorage.getItem(MOCK_SESSION_KEY)) {
const authSvc = new AuthService(new ZinoClient({ baseUrl: "mock" }));
const mockUser = authSvc.getCurrentUser();
if (mockUser) setUserState(mockUser);
} else {
// Real mode — check if a token exists in localStorage
const client = new ZinoClient({ baseUrl: resolvedUrl });
if (client.getToken()) {
const authSvc = new AuthService(client);
const restored = authSvc.getCurrentUser();
if (restored) setUserState(restored);
}
}
}, []);
const value = useMemo<ZinoContextValue>(() => {
const client = new ZinoClient({
baseUrl: resolvedUrl,
onAuthError: () => setUser(null),
});
return {
client,
auth: new AuthService(client),
workflows: new WorkflowService(client),
views: new ViewService(client),
user,
setUser,
};
}, [resolvedUrl, setUser]);
const ctx = useMemo(
() => ({ ...value, user, setUser }),
[value, user, setUser],
);
return (
<QueryClientProvider client={queryClient}>
<ZinoContext.Provider value={ctx}>{children}</ZinoContext.Provider>
</QueryClientProvider>
);
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
/**
* Access Zino services. Use with TanStack Query:
*
* ```tsx
* const { views } = useZino();
* const { data } = useQuery({
* queryKey: ['recordview', rvId],
* queryFn: () => views.getTabularView(rvId, { page: 1 }),
* });
* ```
*/
export function useZino(): ZinoContextValue {
const ctx = useContext(ZinoContext);
if (!ctx) {
throw new Error("useZino must be used within a <ZinoProvider>");
}
return ctx;
}
/** Re-export queryClient for direct invalidation in mutations. */
export { queryClient };

285
src/zino-sdk/types.ts Normal file
View File

@ -0,0 +1,285 @@
// ---------------------------------------------------------------------------
// Zino Service SDK — TypeScript Interfaces
// ---------------------------------------------------------------------------
// ---- Auth & User ----
export interface RoleAssignment {
role_id: string;
positions?: string[];
}
export interface User {
id: string;
org_id: string;
name: string;
email: string;
roles: string[];
groups: string[];
role_assignments: RoleAssignment[];
}
export interface LoginRequest {
email: string;
password: string;
org_id?: string;
}
export interface LoginResponse {
token: string;
user: User;
}
export interface App {
id: string;
name: string;
description?: string;
org_id: string;
}
// ---- Workflow Definition ----
export interface AllowedRole {
role: string;
positions?: string;
}
export interface State {
uid: string;
name: string;
type?: string;
allowed_activities: string[];
}
export interface Transition {
from_state_id: string;
by_activity_id?: string;
by_outcome?: string;
transition_type: string;
to_state_id?: string;
stage_gate_id?: string;
}
export type DataType = "text" | "longtext" | "number" | "grid";
export interface GridColumn {
id: string;
name: string;
data_type: DataType;
}
export interface WorkflowDataField {
id: string;
uid: string;
name: string;
data_type: DataType;
columns?: GridColumn[];
}
export interface ActivityDataField {
id: string;
name: string;
type: "local" | "mapped";
data_type?: DataType;
mandatory: boolean;
mapped_workflow_field?: string;
columns?: GridColumn[];
}
export interface Activity {
uid: string;
name: string;
type: string;
allowed_roles?: AllowedRole[];
allowed_groups?: string[];
dynamic_rbac_allowed?: boolean;
only_roles_positions?: boolean;
data_fields?: ActivityDataField[];
trigger_uid?: string;
}
export interface StageGate {
id: string;
rules_id: string;
}
export interface SGRuleOutcome {
state_id: string;
sg_rule_config?: {
condition_field?: string;
condition_operator?: string;
condition_value?: unknown;
};
}
export interface StageGateRuleSet {
id: string;
default_state: string;
other_states: SGRuleOutcome[];
}
export interface WorkflowDefinition {
workflow_id: string;
version: number;
name: string;
states: State[];
activities: Activity[];
transitions: Transition[];
workflow_data_fields: WorkflowDataField[];
stage_gates?: StageGate[];
sg_rules?: StageGateRuleSet[];
}
// ---- Workflow Instances ----
export interface WorkflowInstance {
instance_id: string;
workflow_id: string;
workflow_version?: number;
current_state_id: string;
current_state_name?: string;
data: Record<string, unknown>;
created_at: string;
updated_at: string;
}
export interface ActivityLogEntry {
id: number;
user_id: string;
user_roles: string[];
user_groups: string[];
activity_id: string;
data: Record<string, unknown>;
execution_state: string;
created_at: string;
}
// ---- Forms ----
export type FormFieldType = "text" | "paragraph" | "number";
export interface FormField {
id: string;
label: string;
type: FormFieldType;
mapped_activity_field_id: string;
}
export interface FormConfig {
title: string;
fields: FormField[];
}
export interface ActivityForm {
id: string;
workflow_id: string;
activity_id: string;
form_id: string;
device_type: string;
form_json: FormConfig;
created_at: string;
}
// ---- Views ----
export interface ActionConfig {
label: string;
activity_uid: string;
workflow_id?: string;
}
export type ViewFieldType =
| "global"
| "local"
| "system-global"
| "system-local"
| "action";
export interface ViewField {
type: ViewFieldType;
field_key: string;
output_label: string;
activity_id?: string;
action?: ActionConfig;
data_type: string;
is_filter: boolean;
is_search: boolean;
}
export interface Column {
key: string;
label: string;
data_type: string;
sortable: boolean;
filterable: boolean;
searchable: boolean;
}
export type SortDir = "asc" | "desc";
export interface TabularViewParams {
page?: number;
pageSize?: number;
sortBy?: string;
sortDir?: SortDir;
filters?: Record<string, string>;
search?: string;
}
export interface TabularViewResponse {
rows: Record<string, unknown>[];
total: number;
totalPages?: number;
page: number;
columns: Column[];
}
export interface ActivityEntry {
activity_id: string;
data: Record<string, unknown>;
activity_performed_at: string;
performed_by_id: string;
}
export interface InstanceReport {
workflow_id: string;
instance_id: string;
workflow_version: number;
current_state_id: string;
current_state_name: string;
global_data: Record<string, unknown>;
activities: ActivityEntry[];
created_at: string;
updated_at: string;
}
export interface StateTransition {
from_state: string;
to_state: string;
activity_name: string;
performed_by: string;
timestamp: string;
data: Record<string, unknown>;
}
export interface ReportSection {
title: string;
fields: { label: string; value: unknown }[];
}
export interface DetailViewResponse {
data: Record<string, unknown>;
sections: ReportSection[];
}
// ---- SDK-internal ----
export interface ZinoClientConfig {
baseUrl: string;
onAuthError?: () => void;
}
export interface ApiError {
status: number;
message: string;
}

197
src/zino-sdk/views.ts Normal file
View File

@ -0,0 +1,197 @@
import type { ZinoClient } from "./client";
import type {
ActivityLogEntry,
Column,
DetailViewResponse,
InstanceReport,
ReportSection,
StateTransition,
TabularViewParams,
TabularViewResponse,
} from "./types";
import {
mockDelay,
mockTabularResponse,
mockInstanceReport,
mockAuditLog,
} from "./mock";
/**
* View and reporting methods tabular record views, detail views,
* and audit trails. Maps to view-service endpoints.
*/
export class ViewService {
private client: ZinoClient;
constructor(client: ZinoClient) {
this.client = client;
}
/**
* Fetch a paginated, sortable, filterable record view.
* Maps to `GET /recordview?rv_id=…`.
*/
async getTabularView(
viewId: string,
params: TabularViewParams = {},
): Promise<TabularViewResponse> {
if (this.client.isMock) {
await mockDelay();
return mockTabularResponse(params);
}
const qs = new URLSearchParams();
qs.set("rv_id", viewId);
if (params.page) qs.set("page", String(params.page));
if (params.pageSize) qs.set("limit", String(params.pageSize));
if (params.sortBy) qs.set("sort_by", params.sortBy);
if (params.sortDir) qs.set("sort_dir", params.sortDir);
if (params.search) qs.set("search", params.search);
if (params.filters) {
for (const [k, v] of Object.entries(params.filters)) {
qs.set(`filter.${k}`, v);
}
}
const raw = await this.client.request<{
config: { fields: Array<{ field_key: string; output_label: string; data_type: string; is_filter: boolean; is_search: boolean }> };
data: Record<string, unknown>[];
pagination?: { page: number; limit: number; total_count: number; total_pages: number };
}>("GET", `/recordview?${qs.toString()}`);
const columns: Column[] = raw.config.fields
.filter((f) => f.field_key !== "")
.map((f) => ({
key: f.field_key,
label: f.output_label,
data_type: f.data_type,
sortable: true,
filterable: f.is_filter,
searchable: f.is_search,
}));
const rows = Array.isArray(raw.data) ? raw.data : [];
return {
rows,
total: raw.pagination?.total_count ?? rows.length,
totalPages: raw.pagination?.total_pages,
page: raw.pagination?.page ?? params.page ?? 1,
columns,
};
}
/**
* Fetch a detail view for a specific workflow instance.
* Maps to `GET /detailview?dv_id=…&instance_id=…`.
*/
async getDetailView(viewId: string, instanceId: string): Promise<DetailViewResponse> {
if (this.client.isMock) {
await mockDelay();
const data: Record<string, unknown> = {
instance_id: instanceId,
view_id: viewId,
status: "Open",
created_at: new Date().toISOString(),
};
return {
data,
sections: [
{
title: "Details",
fields: Object.entries(data).map(([k, v]) => ({ label: k, value: v })),
},
],
};
}
const raw = await this.client.request<{
config: { fields: Array<{ field_key: string; output_label: string; data_type: string }> };
data: Record<string, unknown>;
}>("GET", `/detailview?dv_id=${encodeURIComponent(viewId)}&instance_id=${encodeURIComponent(instanceId)}`);
const data = raw.data ?? {};
const fields = (raw.config?.fields ?? []).filter((f) => f.field_key !== "");
const sections: ReportSection[] = [
{
title: "Details",
fields: fields.length > 0
? fields.map((f) => ({ label: f.output_label, value: data[f.field_key] }))
: Object.entries(data).map(([k, v]) => ({ label: k, value: v })),
},
];
return { data, sections };
}
/**
* Fetch a full instance report global data, activity history,
* and state transitions.
*/
async getInstanceReport(
workflowId: string,
instanceId: string,
): Promise<{ report: InstanceReport; sections: ReportSection[]; history: StateTransition[] }> {
if (this.client.isMock) {
await mockDelay();
return mockInstanceReport(instanceId);
}
const [instance, audit] = await Promise.all([
this.client.request<InstanceReport>("POST", "/instance", {
workflow_id: workflowId,
instance_id: instanceId,
}),
this.client.request<ActivityLogEntry[]>(
"GET",
`/audit?instance_id=${encodeURIComponent(instanceId)}`,
),
]);
const sections: ReportSection[] = [
{
title: "Instance Details",
fields: [
{ label: "Instance ID", value: instance.instance_id },
{ label: "Workflow", value: instance.workflow_id },
{ label: "Current State", value: instance.current_state_name },
{ label: "Created", value: instance.created_at },
{ label: "Updated", value: instance.updated_at },
],
},
{
title: "Data",
fields: Object.entries((instance as any).data ?? instance.global_data ?? {}).map(([k, v]) => ({
label: k,
value: v,
})),
},
];
const history: StateTransition[] = audit.map((entry) => ({
from_state: "",
to_state: entry.execution_state,
activity_name: entry.activity_id,
performed_by: entry.user_id,
timestamp: entry.created_at,
data: entry.data,
}));
return { report: instance, sections, history };
}
/**
* Fetch the raw audit log for an instance.
*/
async getAuditLog(instanceId: string): Promise<ActivityLogEntry[]> {
if (this.client.isMock) {
await mockDelay();
return mockAuditLog();
}
return this.client.request<ActivityLogEntry[]>(
"GET",
`/audit?instance_id=${encodeURIComponent(instanceId)}`,
);
}
}

223
src/zino-sdk/workflow.ts Normal file
View File

@ -0,0 +1,223 @@
import type { ZinoClient } from "./client";
import type {
Activity,
ActivityForm,
WorkflowDefinition,
WorkflowInstance,
} from "./types";
import {
mockDelay,
MOCK_WORKFLOW_DEF,
mockWorkflowInstance,
mockActivityForm,
} from "./mock";
/**
* Workflow execution methods start instances, perform activities,
* fetch definitions, forms, and instance state.
* Maps to core-service and view-service endpoints.
*/
export class WorkflowService {
private client: ZinoClient;
constructor(client: ZinoClient) {
this.client = client;
}
/**
* Start a new workflow instance.
* Core-service returns a wrapped response; we extract instance_id.
*/
async startWorkflow(
workflowId: string,
activityId: string,
data?: Record<string, unknown>,
): Promise<WorkflowInstance> {
if (this.client.isMock) {
await mockDelay();
return mockWorkflowInstance(workflowId);
}
const raw = await this.client.request<{
success: boolean;
instance_id?: string;
data?: Record<string, unknown>;
}>("POST", "/start", {
workflow_id: workflowId,
activity_id: activityId,
data,
});
return {
instance_id: raw.instance_id ?? "",
workflow_id: workflowId,
current_state_id: "",
current_state_name: "",
data: raw.data ?? {},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
} as WorkflowInstance;
}
/**
* Execute an activity on a running workflow instance.
* Core-service returns a wrapped response; we extract instance_id.
*/
async performActivity(
workflowId: string,
instanceId: string,
activityId: string,
data?: Record<string, unknown>,
): Promise<WorkflowInstance> {
if (this.client.isMock) {
await mockDelay();
return mockWorkflowInstance(workflowId, instanceId);
}
const raw = await this.client.request<{
success: boolean;
instance_id?: string;
data?: Record<string, unknown>;
}>("POST", "/activity", {
workflow_id: workflowId,
instance_id: instanceId,
activity_id: activityId,
data,
});
return {
instance_id: raw.instance_id ?? instanceId,
workflow_id: workflowId,
current_state_id: "",
current_state_name: "",
data: raw.data ?? {},
created_at: "",
updated_at: new Date().toISOString(),
} as WorkflowInstance;
}
/**
* Fetch instance state and compute available activities.
* Uses /api-docs to get the activity list (no states endpoint exists),
* so all non-INIT activities are returned the server enforces real constraints.
*/
async getInstanceState(
workflowId: string,
instanceId: string,
): Promise<{
instance: WorkflowInstance;
availableActivities: Activity[];
}> {
if (this.client.isMock) {
await mockDelay();
return {
instance: mockWorkflowInstance(workflowId, instanceId),
availableActivities: MOCK_WORKFLOW_DEF.activities.filter(
(a) => a.type !== "INIT",
),
};
}
const [apiDocs, instance] = await Promise.all([
this.client.request<Array<{
activity_uid: string;
activity_name: string;
type: string;
}>>("GET", `/api-docs?workflow_id=${encodeURIComponent(workflowId)}`),
this.client.request<WorkflowInstance>("POST", "/instance", {
workflow_id: workflowId,
instance_id: instanceId,
}),
]);
// api-docs returns activity documentation; filter out INIT activities
const availableActivities: Activity[] = (apiDocs ?? [])
.filter((a) => a.type !== "INIT")
.map((a) => ({
uid: a.activity_uid,
name: a.activity_name,
type: a.type,
}));
return { instance, availableActivities };
}
/**
* Fetch the workflow definition.
* Note: /api-docs returns activity documentation (not full definition with states).
* We build a partial WorkflowDefinition from it states/transitions will be empty.
*/
async getWorkflowDefinition(
workflowId: string,
): Promise<WorkflowDefinition> {
if (this.client.isMock) {
await mockDelay();
return { ...MOCK_WORKFLOW_DEF, workflow_id: workflowId };
}
const apiDocs = await this.client.request<Array<{
activity_uid: string;
activity_name: string;
type: string;
}>>("GET", `/api-docs?workflow_id=${encodeURIComponent(workflowId)}`);
return {
workflow_id: workflowId,
version: 1,
name: workflowId,
states: [],
activities: (apiDocs ?? []).map((a) => ({
uid: a.activity_uid,
name: a.activity_name,
type: a.type,
})),
transitions: [],
workflow_data_fields: [],
};
}
/**
* Fetch the form schema for a specific activity.
*/
async getForm(
workflowId: string,
activityId: string,
deviceType: string = "desktop",
instanceId?: string,
): Promise<ActivityForm> {
if (this.client.isMock) {
await mockDelay();
return mockActivityForm(workflowId, activityId);
}
return this.client.request<ActivityForm>("POST", "/form", {
workflow_id: workflowId,
activity_id: activityId,
device_type: deviceType,
...(instanceId ? { instance_id: instanceId } : {}),
});
}
/**
* Submit form data for an activity.
*/
async submitForm(
workflowId: string,
activityId: string,
formData: Record<string, unknown>,
deviceType: string = "desktop",
instanceId?: string,
): Promise<WorkflowInstance> {
if (this.client.isMock) {
await mockDelay();
return mockWorkflowInstance(workflowId, instanceId);
}
return this.client.request<WorkflowInstance>("POST", "/form/submit", {
workflow_id: workflowId,
activity_id: activityId,
device_type: deviceType,
form_data: formData,
...(instanceId ? { instance_id: instanceId } : {}),
});
}
}

14
tailwind.config.js Normal file
View File

@ -0,0 +1,14 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Poppins', 'Inter', 'system-ui', '-apple-system', 'sans-serif'],
brand: ['Poppins', 'arial', 'sans-serif'],
},
},
},
plugins: [],
}

19
tsconfig.app.json Normal file
View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

15
tsconfig.node.json Normal file
View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts"]
}

28
vite.config.ts Normal file
View File

@ -0,0 +1,28 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const devTarget = "https://sandbox.getzino.in";
const proxy = (target: string) => ({ target, changeOrigin: true, secure: false });
export default defineConfig({
plugins: [react()],
base: process.env.VITE_BASE_URL ?? "/tech_mahindra/",
server: {
port: 3005,
proxy: {
"/login": proxy(devTarget),
"/apps": proxy(devTarget),
"/agents": proxy(devTarget),
"/start": proxy(devTarget),
"/activity": proxy(devTarget),
"/instance": proxy(devTarget),
"/form": proxy(devTarget),
"/pa": proxy(devTarget),
"/recordview": proxy(devTarget),
"/detailview": proxy(devTarget),
"/audit": proxy(devTarget),
"/api-docs": proxy(devTarget),
"/app/": proxy(devTarget),
},
},
});