Initial scaffold for Mahindra test-drive lead manager (app 164)

This commit is contained in:
Bhanu Prakash Sai Potteri 2026-05-22 19:34:42 +05:30
commit 348e0c5cb4
27 changed files with 4222 additions and 0 deletions

2
.env Normal file
View File

@ -0,0 +1,2 @@
VITE_ZINO_API_URL=https://sandbox.getzino.in
VITE_ZINO_MOCK=false

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>
);
}

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

@ -0,0 +1,351 @@
import { APP_ID } from "../config";
const TOKEN_KEY = "zino_token";
function baseUrl(): string {
return (import.meta.env.VITE_ZINO_API_URL || "").replace(/\/+$/, "");
}
function headers(): Record<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 = "/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_id: number;
}
export async function getHeaderConfig(deviceType = "desktop"): Promise<HeaderConfig> {
const raw = await request<any>("GET", `/app/${APP_ID}/header/${deviceType}`);
return raw.config ?? raw;
}
// ---------------------------------------------------------------------------
// Screens
// ---------------------------------------------------------------------------
export interface ScreenListItem {
id: number;
source_screen_id: number;
screen_name: string;
device: string;
description: string;
}
export interface ScreenLayout {
id: number;
source_screen_id: number;
screen_name: string;
layout: LayoutElement[];
}
export interface LayoutElement {
type: string;
style?: Record<string, string>;
config: Record<string, any>;
classes?: string[];
}
export function getScreens(deviceType = "desktop"): Promise<ScreenListItem[]> {
return request("GET", `/app/${APP_ID}/screens?device_type=${deviceType}`);
}
export function getScreen(sourceScreenId: number): Promise<ScreenLayout> {
return request("GET", `/app/${APP_ID}/screens/${sourceScreenId}`);
}
// ---------------------------------------------------------------------------
// RV Screens
// ---------------------------------------------------------------------------
export interface RVScreen {
id: number;
source_screen_id: number;
screen_name: string;
rv_template_id: number;
layout?: any;
}
// Accepts either the numeric source_screen_id OR the source_uuid — both
// are accepted by the view-service's rv-screens endpoint.
export function getRVScreen(idOrUuid: number | string): Promise<RVScreen> {
return request("GET", `/app/${APP_ID}/rv-screens/${idOrUuid}`);
}
// ---------------------------------------------------------------------------
// Record View (POST-based)
// ---------------------------------------------------------------------------
export interface SearchQuery {
page?: number;
limit?: number;
sort_by?: string;
sort_dir?: "asc" | "desc";
search?: string;
filters?: Array<{ field_key: string; value: string; data_type?: string }>;
}
export interface RecordViewField {
field_key: string;
output_label: string;
data_type: string;
is_filter: boolean;
is_search: boolean;
type?: string;
activity_id?: string;
}
export interface RecordViewResponse {
config: { fields: RecordViewField[] };
data: Record<string, unknown>[];
pagination?: {
page: number;
limit: number;
total_count: number;
total_pages: number;
};
}
export function getRecordView(
rvTemplateId: number,
rvScreenId: string | number,
query: SearchQuery = {},
): Promise<RecordViewResponse> {
return request("POST", `/app/${APP_ID}/recordview`, {
rv_template_id: rvTemplateId,
rv_screen_id: String(rvScreenId),
search_query: {
page: query.page || 1,
limit: query.limit || 50,
sort_by: query.sort_by || "",
sort_dir: query.sort_dir || "desc",
search: query.search || "",
filters: query.filters || [],
},
});
}
export function getRecordViewLegacy(
rvUid: string,
query: SearchQuery = {},
): Promise<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_id: number;
layout?: any;
}
export function getDVScreen(idOrUuid: number | string): Promise<DVScreen> {
return request("GET", `/app/${APP_ID}/dv-screens/${idOrUuid}`);
}
// ---------------------------------------------------------------------------
// Detail View
// ---------------------------------------------------------------------------
export interface DetailViewResponse {
config: { fields: Array<{ field_key: string; output_label: string; data_type: string }> };
data: Record<string, unknown>;
}
export function getDetailView(dvSourceId: number, instanceId: string): Promise<DetailViewResponse> {
return request(
"GET",
`/app/${APP_ID}/detailview/${dvSourceId}?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;
options?: Array<{ label: string; value: string }>;
}
export interface FormScreenResponse {
id: number;
activity_uid: string;
activity_name: string;
device_type: string;
fields: FormScreenField[];
grid_config: Array<{ i: string; x: number; y: number; w: number; h: number }>;
layout: any[];
}
export function getFormScreen(
activityId: string,
instanceId?: string,
deviceType = "desktop",
): Promise<FormScreenResponse> {
return request("POST", `/app/${APP_ID}/form-screens`, {
activity_id: activityId,
instance_id: instanceId || "",
device_type: deviceType,
});
}
// ---------------------------------------------------------------------------
// Workflow Actions
// ---------------------------------------------------------------------------
export interface WorkflowResponse {
success: boolean;
status_code?: number;
message?: string;
data?: Record<string, unknown>;
instance_id?: string;
}
export function startWorkflow(
workflowId: string,
activityId: string,
data?: Record<string, unknown>,
): Promise<WorkflowResponse> {
return request("POST", `/app/${APP_ID}/start`, { workflow_id: workflowId, activity_id: activityId, data });
}
export function performActivity(
workflowId: string,
instanceId: string,
activityId: string,
data?: Record<string, unknown>,
): Promise<WorkflowResponse> {
return request("POST", `/app/${APP_ID}/activity`, {
workflow_id: workflowId,
instance_id: instanceId,
activity_id: activityId,
data,
});
}
export function submitForm(
workflowId: string,
activityId: string,
formData: Record<string, unknown>,
instanceId?: string,
deviceType = "desktop",
): Promise<WorkflowResponse> {
return request("POST", `/app/${APP_ID}/form/submit`, {
workflow_id: workflowId,
activity_id: activityId,
device_type: deviceType,
form_data: formData,
...(instanceId ? { instance_id: instanceId } : {}),
});
}
// ---------------------------------------------------------------------------
// Instance
// ---------------------------------------------------------------------------
export interface InstanceResponse {
instance_id: string;
workflow_id: string;
current_state_id: string;
current_state_name: string;
data: Record<string, unknown>;
created_at: string;
updated_at: string;
}
export function getInstance(workflowId: string, instanceId: string): Promise<InstanceResponse> {
return request("POST", `/app/${APP_ID}/instance`, {
workflow_id: workflowId,
instance_id: instanceId,
});
}
// ---------------------------------------------------------------------------
// Audit
// ---------------------------------------------------------------------------
export interface AuditEntry {
id: number;
user_id: string;
user_roles: string[];
activity_id: string;
data: Record<string, unknown>;
execution_state: string;
created_at: string;
}
export function getAuditLog(instanceId: string): Promise<AuditEntry[]> {
return request("GET", `/app/${APP_ID}/audit?instance_id=${encodeURIComponent(instanceId)}`);
}

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

@ -0,0 +1,366 @@
import { useEffect, useState, useCallback } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Loader2, CheckCircle2, RefreshCw, Clock, Sparkles } from "lucide-react";
import DynamicHeader from "./DynamicHeader";
import ScreenRenderer from "./ScreenRenderer";
import { TableSkeleton } from "./Skeleton";
import DetailViewPanel from "./DetailViewPanel";
import FormModal from "./FormModal";
import {
getHeaderConfig, getScreen, getAuditLog, getInstance,
type LayoutElement, type NavItem, type AuditEntry,
} from "../api/viewService";
import { WORKFLOW_ID, ACTIVITY_IDS, STATE_IDS, DETAIL_VIEW_IDS } from "../config";
// State → contextual action buttons shown above the detail view.
const ACTIVITY_META: Record<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" },
};
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.END_STATE]: "Closed",
};
// Color hint for state pill.
function stateTone(stateId: string | null) {
if (!stateId) return { bg: "rgba(168,162,158,0.15)", fg: "#57534E", ring: "rgba(168,162,158,0.30)" };
if (stateId === STATE_IDS.FEEDBACK_COLLECTED || stateId === STATE_IDS.END_STATE)
return { bg: "rgba(16,185,129,0.12)", fg: "#047857", ring: "rgba(16,185,129,0.30)" };
if (stateId === STATE_IDS.SCHEDULING_CALL_FAILED || stateId === STATE_IDS.FEEDBACK_CALL_FAILED)
return { bg: "rgba(239,68,68,0.12)", fg: "#B91C1C", ring: "rgba(239,68,68,0.30)" };
if (stateId === STATE_IDS.AWAITING_SCHEDULER_CALL || stateId === STATE_IDS.AWAITING_FEEDBACK_CALL)
return { bg: "rgba(200,16,46,0.10)", fg: "#C8102E", ring: "rgba(200,16,46,0.28)" };
if (stateId === STATE_IDS.SCHEDULED)
return { bg: "rgba(245,158,11,0.14)", fg: "#B45309", ring: "rgba(245,158,11,0.30)" };
return { bg: "rgba(168,162,158,0.15)", fg: "#57534E", ring: "rgba(168,162,158,0.30)" };
}
export default function AppShell() {
const navigate = useNavigate();
const params = useParams();
const screenIdParam = params.screenId ? Number(params.screenId) : null;
const instanceIdParam = params.instanceId;
const [activeScreenId, setActiveScreenId] = useState<number | 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_id);
})
.catch(console.error)
.finally(() => setReady(true));
} else {
// Still need nav items for the breadcrumb / sidebar.
getHeaderConfig("desktop")
.then((cfg) => setNavItems(cfg.components?.nav?.items ?? []))
.catch(console.error);
setReady(true);
if (instanceIdParam) setDetailInstanceId(instanceIdParam);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Sync detail state with browser history.
useEffect(() => {
if (!params.instanceId && detailInstanceId) {
setDetailInstanceId(null);
setInstanceState(null);
setAudit([]);
} else if (params.instanceId && params.instanceId !== detailInstanceId) {
setDetailInstanceId(params.instanceId);
}
}, [params.instanceId]); // eslint-disable-line react-hooks/exhaustive-deps
// Load active screen layout.
useEffect(() => {
if (!activeScreenId || detailInstanceId) return;
setLoading(true);
getScreen(activeScreenId)
.then((s) => setLayout(s.layout ?? []))
.catch(console.error)
.finally(() => setLoading(false));
}, [activeScreenId, detailInstanceId]);
// Load instance state + audit.
useEffect(() => {
if (!detailInstanceId) return;
getInstance(WORKFLOW_ID, detailInstanceId)
.then((inst) => setInstanceState(inst.current_state_id))
.catch(() => {});
getAuditLog(detailInstanceId).then(setAudit).catch(() => setAudit([]));
}, [detailInstanceId, refreshKey]);
const handleNavigate = useCallback((id: number) => {
setActiveScreenId(id);
setDetailInstanceId(null);
setInstanceState(null);
setAudit([]);
navigate(`/screen/${id}`);
}, [navigate]);
const handleRowClick = useCallback((instanceId: string) => {
setDetailInstanceId(instanceId);
navigate(`/screen/${activeScreenId}/detail/${instanceId}`);
}, [activeScreenId, navigate]);
const handleBack = () => {
setDetailInstanceId(null);
setInstanceState(null);
setAudit([]);
navigate(`/screen/${activeScreenId}`);
};
const handleFormSuccess = () => {
setFormModal(null);
setRefreshKey((k) => k + 1);
};
const actions = (() => {
if (!instanceState) return [];
if (instanceState === STATE_IDS.SCHEDULED) return [ACTIVITY_IDS.MARK_TEST_DRIVE_DONE];
if (instanceState === STATE_IDS.SCHEDULING_CALL_FAILED) return [ACTIVITY_IDS.RETRY_SCHEDULING_CALL];
if (instanceState === STATE_IDS.FEEDBACK_CALL_FAILED) return [ACTIVITY_IDS.RETRY_FEEDBACK_CALL];
return [];
})();
const aiInFlight =
instanceState === STATE_IDS.AWAITING_SCHEDULER_CALL ||
instanceState === STATE_IDS.AWAITING_FEEDBACK_CALL;
if (!ready) {
return (
<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_id === 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 — uses the deployed instance detail view */}
<DetailViewPanel
key={`dv-${detailInstanceId}-${refreshKey}`}
viewId={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>
);
}
// Friendlier label for our UUID-style activity IDs.
function fmtAct(id: string): string {
switch (id) {
case ACTIVITY_IDS.INIT_ACTIVITY: return "Lead Captured";
case ACTIVITY_IDS.MARK_TEST_DRIVE_DONE: return "Test Drive Completed";
case ACTIVITY_IDS.AI_CONFIRM_SCHEDULING_SUCCESS: return "AI · Scheduling Confirmed";
case ACTIVITY_IDS.AI_CONFIRM_SCHEDULING_FAILED: return "AI · Scheduling Failed";
case ACTIVITY_IDS.RETRY_SCHEDULING_CALL: return "Retry Scheduling Call";
case ACTIVITY_IDS.AI_CONFIRM_FEEDBACK_COLLECTED: return "AI · Feedback Collected";
case ACTIVITY_IDS.AI_CONFIRM_FEEDBACK_FAILED: return "AI · Feedback Failed";
case ACTIVITY_IDS.RETRY_FEEDBACK_CALL: return "Retry Feedback Call";
default: return id.slice(0, 8) + "…";
}
}

View File

@ -0,0 +1,372 @@
import { useEffect, useState, useRef } from "react";
import { Hash, Calendar, Sparkles, TrendingUp, MessageSquare, ChevronDown, ChevronUp } from "lucide-react";
import { getDVScreen, getDetailView } from "../api/viewService";
interface Field { field_key: string; output_label: string; data_type: string }
interface Props { viewId: number | string; instanceId: string }
type GroupKey = "hero" | "customer" | "vehicle" | "booking" | "feedback" | "ai" | "general";
// ---------------------------------------------------------------------------
// Classification — Mahindra test-drive workflow
// ---------------------------------------------------------------------------
function classify(key: string): GroupKey {
const k = key.toLowerCase();
if (k === "customer_name") return "hero";
if (k.startsWith("customer_") || k === "lead_source" || k === "phone" || k === "email") return "customer";
if (k === "model_interest" || k.startsWith("vehicle_") || k === "variant" || k === "color" || k === "fuel_type") return "vehicle";
if (k.startsWith("booking_") || k === "dealer_name" || k === "dealer_id" || k === "slot_start" || k === "slot_end") return "booking";
if (k.startsWith("feedback_") || k === "test_drive_outcome" || k === "would_recommend" || k === "rating") return "feedback";
if (k.startsWith("ai_") || k.startsWith("call_") || k.includes("summary") || k.includes("failure_reason") ||
k.includes("confidence") || k.includes("reasoning") || k.includes("escalation")) return "ai";
return "general";
}
// ---------------------------------------------------------------------------
// Status
// ---------------------------------------------------------------------------
const STATUS: Record<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_id, instanceId))
.catch(() => getDetailView(Number(viewId) || 0, instanceId))
.then(res => {
setFields(res.config?.fields?.filter((f: any) => f.field_key !== "") ?? []);
setData(res.data ?? {});
})
.catch((e: any) => setError(e.message))
.finally(() => setLoading(false));
}, [viewId, instanceId]);
if (loading) return <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 v = data[f.field_key];
const g = classify(f.field_key);
if (g !== "hero" && (v == null || v === "")) continue;
groups[g].push(f);
}
const heroField = groups.hero[0];
const heroValue = heroField ? String(data[heroField.field_key] ?? "") : "";
const stateName = String(data.current_state_name ?? "");
const sc = STATUS[stateName] ?? DS;
const phone = String(data.customer_phone ?? "");
const model = String(data.model_interest ?? "");
const leftGroups = [
{ label: "Customer", fields: groups.customer },
{ label: "Vehicle", fields: groups.vehicle },
{ label: "Other", fields: groups.general },
].filter(g => g.fields.length > 0);
const rightGroups = [
{ label: "Booking", fields: groups.booking },
{ label: "Feedback", fields: groups.feedback },
].filter(g => g.fields.length > 0);
const isScore = (f: Field) => f.field_key.toLowerCase().includes("confidence") || f.field_key.toLowerCase().includes("rating") || f.field_key.toLowerCase().includes("score");
const isLongText = (f: Field) => { const v = data[f.field_key]; return typeof v === "string" && v.length > 55; };
const aiShort = groups.ai.filter(f => isScore(f) || !isLongText(f));
const aiLong = groups.ai.filter(f => !isScore(f) && isLongText(f));
return (
<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>
)}
</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 === "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; }
}

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: number | null;
onNavigate: (screenId: number) => void;
onSidebarChange?: (open: boolean) => void;
}
export default function DynamicHeader({ activeScreenId, onNavigate, onSidebarChange }: Props) {
const { user, logout } = useAuthContext();
const { theme, toggleTheme } = useTheme();
const navigate = useNavigate();
const [config, setConfig] = useState<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 · 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_id;
return (
<button
key={item.id}
onClick={() => onNavigate(item.screen_id)}
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,250 @@
import { useEffect, useState, useRef } from "react";
import { X, ArrowRight, AlertCircle, CheckCircle2, ChevronDown } from "lucide-react";
import { getFormScreen, startWorkflow, performActivity } from "../api/viewService";
import { WORKFLOW_ID } from "../config";
interface FormField { id: string; uid: string; name: string; type: string; data_type: string; mandatory: boolean; value?: any; options?: Array<{ label: string; value: string }>; }
interface GridItem { i: string; x: number; y: number; w: number; h: number; }
interface Props {
activityId: string;
instanceId?: string;
title?: string;
onClose: () => void;
onSuccess: () => void;
}
export default function FormModal({ activityId, instanceId, title, onClose, onSuccess }: Props) {
const [fields, setFields] = useState<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 f: FormField[] = res.fields ?? res.form_json?.fields ?? [];
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] ?? ""; data[f.id] = f.data_type === "number" ? (v ? Number(v) : 0) : v; }
instanceId ? await performActivity(WORKFLOW_ID, instanceId, activityId, data) : await startWorkflow(WORKFLOW_ID, activityId, data);
setSuccess(true);
setTimeout(() => animateClose(onSuccess), 800);
} catch (err: any) { setError(err.message || "Failed"); }
finally { setSubmitting(false); }
};
const sorted = [...fields].sort((a, b) => {
const ga = gridConfig.find((g) => g.i === a.uid), gb = gridConfig.find((g) => g.i === b.uid);
if (!ga || !gb) return 0;
return ga.y !== gb.y ? ga.y - gb.y : ga.x - gb.x;
});
const rows: FormField[][] = []; let cy = -1;
for (const f of sorted) { const g = gridConfig.find((gi) => gi.i === f.uid); const y = g?.y ?? rows.length; if (y !== cy) { rows.push([]); cy = y; } rows[rows.length - 1].push(f); }
const isMissing = (f: FormField) => f.mandatory && touched.has(f.id) && !formData[f.id]?.trim();
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={() => animateClose(onClose)}>
<div className={`absolute inset-0 bg-stone-900/50 dark:bg-black/60 backdrop-blur-[2px] transition-opacity duration-200 ${visible ? "opacity-100" : "opacity-0"}`} />
<div
ref={modalRef}
className={`relative bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl shadow-stone-900/10 dark:shadow-black/50 border border-stone-200/0 dark:border-zinc-700/60 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"}`}
onClick={(e) => e.stopPropagation()}
>
{success && (
<div className="absolute inset-0 z-10 bg-white/95 dark:bg-zinc-900/95 rounded-2xl flex flex-col items-center justify-center gap-3">
<div className="w-12 h-12 rounded-full bg-emerald-50 dark:bg-emerald-950/40 flex items-center justify-center">
<CheckCircle2 size={24} className="text-emerald-500" />
</div>
<div className="text-[14px] font-semibold text-stone-800 dark:text-zinc-100">Submitted successfully</div>
</div>
)}
{/* ── Header ── */}
<div className="flex items-center justify-between px-6 py-4 border-b border-stone-100 dark:border-zinc-800">
<div>
<h2 className="text-[15px] font-semibold text-stone-900 dark:text-zinc-100">{formTitle}</h2>
<p className="text-[12px] text-stone-400 dark:text-zinc-500 mt-0.5">
{instanceId ? "Update the details below" : "Fill in the details to submit"}
</p>
</div>
<button
onClick={() => animateClose(onClose)}
className="w-8 h-8 flex items-center justify-center text-stone-400 dark:text-zinc-500 hover:text-stone-600 dark:hover:text-zinc-200 hover:bg-stone-100 dark:hover:bg-zinc-800 rounded-lg transition-colors"
>
<X size={16} />
</button>
</div>
{/* ── Body ── */}
<div className="flex-1 overflow-y-auto px-6 py-5">
{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 dark:bg-zinc-800 rounded animate-pulse mb-2" /><div className="h-10 w-full bg-stone-100 dark:bg-zinc-800 rounded-lg animate-pulse" /></div>
<div><div className="h-3 w-24 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse mb-2" /><div className="h-10 w-full bg-stone-100 dark:bg-zinc-800 rounded-lg 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-[12px] font-medium text-stone-500 dark:text-zinc-400 mb-1.5">
{f.name}
{f.mandatory && <span className="text-red-400 ml-0.5">*</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] text-red-500 mt-1 flex items-center gap-1">
<AlertCircle size={11} /> Required
</p>
)}
</div>
))}
</div>
))}
</form>
)}
{error && (
<div className="mt-4 flex items-start gap-2.5 text-[13px] text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 rounded-xl px-4 py-3 border border-red-100 dark:border-red-900">
<AlertCircle size={15} className="shrink-0 mt-0.5" />
<span>{error}</span>
</div>
)}
</div>
{/* ── Footer ── */}
{!loading && (
<div className="px-6 py-4 border-t border-stone-100 dark:border-zinc-800 flex items-center justify-between">
<div className="text-[11px] text-stone-400 dark:text-zinc-500">
<span className="text-red-400">*</span> Required
</div>
<div className="flex gap-2.5">
<button
type="button"
onClick={() => animateClose(onClose)}
className="h-9 px-4 text-[13px] font-medium text-stone-500 dark:text-zinc-400 hover:text-stone-700 dark:hover:text-zinc-200 hover:bg-stone-50 dark:hover:bg-zinc-800 rounded-xl transition-colors"
>
Cancel
</button>
<button
type="submit"
form="act-form"
disabled={submitting}
className="h-9 px-5 text-[13px] font-semibold text-white disabled:opacity-50 rounded-xl transition-all flex items-center gap-1.5 mh-glow hover:scale-[1.02] active:scale-[0.98]"
style={{ background: "linear-gradient(135deg, #C8102E, #E84258)" }}
>
{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 border rounded-xl px-3.5 text-[13px] bg-stone-50/50 dark:bg-zinc-800/70 focus:bg-white dark:focus:bg-zinc-800 text-stone-800 dark:text-zinc-100 focus:outline-none focus:ring-2 transition placeholder:text-stone-400 dark:placeholder:text-zinc-600 ${
hasError
? "border-red-300 dark:border-red-700 focus:ring-red-500/20 focus:border-red-400"
: "border-stone-200 dark:border-zinc-700 focus:ring-[#C8102E]/20 focus:border-[#C8102E] dark:focus:border-rose-500"
}`;
if (f.options && f.options.length > 0) {
return (
<div className="relative">
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={`${base} appearance-none pr-9 cursor-pointer`}
>
<option value="">Select {f.name.toLowerCase()}</option>
{f.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)}
rows={3}
className={`${base} h-auto py-2.5 rounded-xl`}
placeholder={`Enter ${f.name.toLowerCase()}`}
/>
);
case "number":
return <input type="number" step="any" value={value} onChange={(e) => onChange(e.target.value)} className={base} placeholder="0" />;
case "date":
return <input type="date" value={value} onChange={(e) => onChange(e.target.value)} className={base} />;
case "email":
return <input type="email" value={value} onChange={(e) => onChange(e.target.value)} className={base} placeholder={`Enter ${f.name.toLowerCase()}`} />;
case "phone":
return <input type="tel" value={value} onChange={(e) => onChange(e.target.value)} className={base} placeholder={`Enter ${f.name.toLowerCase()}`} />;
default:
return <input type="text" value={value} onChange={(e) => onChange(e.target.value)} className={base} placeholder={`Enter ${f.name.toLowerCase()}`} />;
}
}

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,557 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { useNavigate } from "react-router-dom";
import {
ChevronLeft, ChevronRight, Search, ArrowUp, ArrowDown,
X, Inbox, MoreHorizontal, ArrowRight, RefreshCw,
} from "lucide-react";
import { getRVScreen, getRecordView, type RecordViewField, type SearchQuery } from "../api/viewService";
import FormModal from "./FormModal";
import { STATE_IDS, ACTIVITY_IDS } from "../config";
import { CheckCircle2, RefreshCw as RefreshIcon } from "lucide-react";
// ---------------------------------------------------------------------------
// Row actions by state
// ---------------------------------------------------------------------------
const ROW_ACTIONS: Record<string, Array<{ activityId: string; label: string; icon: React.ReactNode }>> = {
[STATE_IDS.SCHEDULED]: [
{ activityId: ACTIVITY_IDS.MARK_TEST_DRIVE_DONE, label: "Mark Test Drive Done", icon: <CheckCircle2 size={13} /> },
],
[STATE_IDS.SCHEDULING_CALL_FAILED]: [
{ activityId: ACTIVITY_IDS.RETRY_SCHEDULING_CALL, label: "Retry Scheduling Call", icon: <RefreshIcon size={13} /> },
],
[STATE_IDS.FEEDBACK_CALL_FAILED]: [
{ activityId: ACTIVITY_IDS.RETRY_FEEDBACK_CALL, label: "Retry Feedback Call", icon: <RefreshIcon size={13} /> },
],
};
// State name (as returned by record view) → state UID. Used to resolve row actions.
const STATE_BY_NAME: Record<string, string> = {
"Awaiting Scheduler Call": STATE_IDS.AWAITING_SCHEDULER_CALL,
"Scheduled": STATE_IDS.SCHEDULED,
"Awaiting Feedback Call": STATE_IDS.AWAITING_FEEDBACK_CALL,
"Feedback Collected": STATE_IDS.FEEDBACK_COLLECTED,
"Scheduling Call Failed": STATE_IDS.SCHEDULING_CALL_FAILED,
"Feedback Call Failed": STATE_IDS.FEEDBACK_CALL_FAILED,
"End State": STATE_IDS.END_STATE,
};
// ---------------------------------------------------------------------------
// 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;
}
export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: Props) {
const navigate = useNavigate();
const [rvTemplateId, setRvTemplateId] = useState<number | null>(null);
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);
getRVScreen(viewId)
.then((rv) => setRvTemplateId(rv.rv_template_id))
.catch((err) => { setError(err.message); setLoading(false); });
}, [viewId]);
const fetchData = useCallback(async () => {
if (!rvTemplateId) return;
if (!loading) setDataLoading(true);
setError(null);
try {
const res = await getRecordView(rvTemplateId, viewId, query);
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); }
}, [rvTemplateId, 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}`);
};
if (loading) return <TableSkeleton rows={6} cols={fields.length || 6} />;
if (error) {
return (
<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 (
<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">
<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)}
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">
{f.output_label}
{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>
);
})}
<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} 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) => {
const stateName = String(row.current_state_name ?? "");
const stateId = STATE_BY_NAME[stateName];
const actions = stateId ? ROW_ACTIONS[stateId] : undefined;
return (
<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 whitespace-nowrap 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"
}
`}
>
{renderCell(row[f.field_key], f.data_type, f.field_key)}
</td>
);
})}
<td className="pr-4 py-3 w-16 text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1 opacity-0 group-hover/row:opacity-100 transition-opacity">
{actions && actions.length > 0 && (
<RowMenu
actions={actions}
onAction={(activityId, label) =>
setFormModal({ activityId, instanceId: String(row.instance_id), title: label })
}
/>
)}
<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"
>
<ArrowRight size={14} />
</button>
</div>
</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>
);
}
// ---------------------------------------------------------------------------
// Row menu
// ---------------------------------------------------------------------------
interface RowMenuProps {
actions: Array<{ activityId: string; label: string; icon: React.ReactNode }>;
onAction: (activityId: string, label: string) => void;
}
function RowMenu({ actions, onAction }: RowMenuProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open]);
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="w-7 h-7 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"
>
<MoreHorizontal size={15} />
</button>
{open && (
<div className="absolute right-0 top-full mt-1 z-50 w-40 bg-white dark:bg-zinc-900 rounded-xl shadow-xl shadow-stone-900/10 dark:shadow-black/40 border border-stone-200 dark:border-zinc-700/70 py-1 overflow-hidden">
{actions.map((a) => (
<button
key={a.activityId}
onClick={() => { setOpen(false); onAction(a.activityId, a.label); }}
className="w-full text-left px-3.5 py-2 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"
>
<span className="text-stone-400 dark:text-zinc-500">{a.icon}</span>
{a.label}
</button>
))}
</div>
)}
</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 === "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,110 @@
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[];
}
// Flatten the screen layout depth-first. Container nodes (section/column/block)
// carry no config we care about — only their leaf "element" descendants do.
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);
// Collect button elements; they get injected into the toolbar of the
// *next* record-view we find. Everything else renders inline in order.
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) {
// V1 uses activity_id_init / activity_id; V2 uses activity_uid_init / activity_uid.
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") {
// Prefer view_uuid — the v2 envelope renumbers numeric IDs across envs,
// so the UUID is the stable cross-env handle.
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;
})}
{/* If there's no record view to host the buttons, show them standalone. */}
{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>
);
}

46
src/config.ts Normal file
View File

@ -0,0 +1,46 @@
// Tech Mahindra Demo v2 — App 164, Org 43
export const ORG_ID = "43";
export const APP_ID = "164";
export const WORKFLOW_ID = "29347";
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
} 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",
} as const;
export const DETAIL_VIEW_IDS = {
INSTANCE_DETAIL: "7865e1d9-9f7c-489e-a12b-98463ebbb5c1",
} as const;
// 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",
END_STATE: "33cce5a0-f493-4fac-a1b7-7b68fbcbffce",
} 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}/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,
};
}

38
src/index.css Normal file
View File

@ -0,0 +1,38 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply antialiased text-stone-800 dark:text-zinc-100 bg-white dark:bg-zinc-950;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
transition: background-color 0.2s ease, color 0.2s ease;
}
* {
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;
}
}

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>,
);

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

@ -0,0 +1,176 @@
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, Car } from "lucide-react";
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 inputCls =
"w-full h-11 border border-stone-200 dark:border-zinc-700 rounded-lg px-3.5 text-[13px] " +
"bg-stone-50 dark:bg-zinc-800/60 text-stone-900 dark:text-zinc-100 " +
"focus:outline-none focus:ring-2 focus:ring-[#C8102E]/20 focus:border-[#C8102E] " +
"focus:bg-white dark:focus:bg-zinc-800 transition placeholder:text-stone-400 dark:placeholder:text-zinc-600";
return (
<div className="min-h-screen flex dark:bg-zinc-950">
{/* ── Left — branding ───────────────────────────────────────── */}
<div
className="hidden lg:flex lg:w-[45%] items-center justify-center p-16 relative overflow-hidden"
style={{ background: "linear-gradient(155deg, #1a0708 0%, #3a0e16 45%, #6d0f22 80%, #2a0810 100%)" }}
>
{/* Atmospheric orbs */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div
className="absolute -top-40 -left-32 w-[520px] h-[520px] rounded-full opacity-40"
style={{ background: "radial-gradient(circle, #C8102E 0%, transparent 70%)" }}
/>
<div
className="absolute -bottom-24 -right-20 w-96 h-96 rounded-full opacity-25"
style={{ background: "radial-gradient(circle, #E84258 0%, transparent 70%)" }}
/>
<div
className="absolute inset-0 opacity-[0.05]"
style={{
backgroundImage:
"linear-gradient(rgba(255,255,255,0.6) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.6) 1px, transparent 1px)",
backgroundSize: "44px 44px",
}}
/>
</div>
<div className="absolute top-10 left-16 z-10 flex items-center gap-2.5">
<div className="w-7 h-7 rounded-lg flex items-center justify-center gradient-mh shadow-md">
<Car size={15} className="text-white" />
</div>
<span className="text-[12px] font-bold tracking-widest text-white/85 uppercase">Mahindra · Sales Ops</span>
</div>
<div className="relative z-10 max-w-lg">
<div
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium mb-8 border"
style={{ background: "rgba(200,16,46,0.18)", borderColor: "rgba(232,66,88,0.35)", color: "#fcb6c0" }}
>
<Sparkles size={13} />
AI-powered test drive scheduler
</div>
<h1 className="text-[2.6rem] font-bold text-white leading-[1.05] tracking-tight mb-8">
Drive every lead
<br />
<span
className="text-transparent bg-clip-text"
style={{ backgroundImage: "linear-gradient(135deg, #fda4af, #f9a8d4, #fbbf24)" }}
>
to the showroom floor.
</span>
</h1>
<div className="space-y-4">
{[
{ icon: <PhoneCall size={15} />, title: "Auto-dial leads", desc: "Maya, your AI scheduler, calls customers within seconds of capture" },
{ icon: <CalendarCheck2 size={15} />, title: "Live slot booking", desc: "Real-time availability against the dealer's calendar" },
{ icon: <Sparkles size={15} />, 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">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{ background: "rgba(200,16,46,0.28)", border: "1px solid rgba(232,66,88,0.30)", color: "#fda4af" }}
>
{item.icon}
</div>
<div>
<div className="text-[13px] font-semibold text-white">{item.title}</div>
<div className="text-[12px] mt-0.5 text-rose-200/65">{item.desc}</div>
</div>
</div>
))}
</div>
</div>
</div>
{/* ── Right — login form ──────────────────────────────────── */}
<div className="flex-1 flex items-center justify-center bg-white dark:bg-zinc-950 px-8">
<div className="w-full max-w-[340px]">
<div className="mb-8">
<div className="flex items-center gap-2 mb-6 lg:hidden">
<div className="w-7 h-7 rounded-lg flex items-center justify-center gradient-mh shadow-md">
<Car size={15} className="text-white" />
</div>
<span className="text-[12px] font-bold tracking-widest text-stone-700 dark:text-zinc-200 uppercase">
Mahindra · Sales Ops
</span>
</div>
<h2 className="text-[1.6rem] font-bold text-stone-900 dark:text-zinc-50 tracking-tight leading-none mb-2">
Welcome back
</h2>
<p className="text-[13px] text-stone-400 dark:text-zinc-500">
Sign in to the test-drive operations console
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-[12px] font-medium text-stone-600 dark:text-zinc-400 mb-1.5 uppercase tracking-wide">
Email
</label>
<input
type="email" required autoComplete="email" autoFocus
value={email} onChange={(e) => { setEmail(e.target.value); clearError(); }}
className={inputCls} placeholder="you@mahindra.in"
/>
</div>
<div>
<label className="block text-[12px] font-medium text-stone-600 dark:text-zinc-400 mb-1.5 uppercase tracking-wide">
Password
</label>
<input
type="password" required autoComplete="current-password"
value={password} onChange={(e) => { setPassword(e.target.value); clearError(); }}
className={inputCls} placeholder="••••••••"
/>
</div>
{error && (
<p className="text-[12px] text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 rounded-lg px-3 py-2.5 border border-red-100 dark:border-red-900/60">
{error}
</p>
)}
<button
type="submit" disabled={loading}
className="w-full h-11 mt-1 disabled:opacity-50 text-white text-[13px] font-semibold rounded-lg transition-all flex items-center justify-center gap-2 hover:opacity-95 active:scale-[0.99] mh-glow"
style={{ background: "linear-gradient(135deg, #C8102E, #E84258)" }}
>
{loading ? "Signing in…" : <>Sign in <ArrowRight size={14} /></>}
</button>
</form>
<div className="flex items-center justify-center gap-1.5 mt-10">
<span className="text-[11px] text-stone-400 dark:text-zinc-500">Powered by</span>
<span className="text-[11px] font-bold text-stone-600 dark:text-zinc-400">Zino</span>
</div>
</div>
</div>
</div>
);
}

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 } : {}),
});
}
}