Initial scaffold for Mahindra test-drive lead manager (app 164)
This commit is contained in:
commit
348e0c5cb4
2
.env
Normal file
2
.env
Normal file
@ -0,0 +1,2 @@
|
||||
VITE_ZINO_API_URL=https://sandbox.getzino.in
|
||||
VITE_ZINO_MOCK=false
|
||||
37
src/App.tsx
Normal file
37
src/App.tsx
Normal 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
351
src/api/viewService.ts
Normal 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
366
src/components/AppShell.tsx
Normal 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) + "…";
|
||||
}
|
||||
}
|
||||
372
src/components/DetailViewPanel.tsx
Normal file
372
src/components/DetailViewPanel.tsx
Normal 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; }
|
||||
}
|
||||
211
src/components/DynamicHeader.tsx
Normal file
211
src/components/DynamicHeader.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
250
src/components/FormModal.tsx
Normal file
250
src/components/FormModal.tsx
Normal 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()}`} />;
|
||||
}
|
||||
}
|
||||
9
src/components/ProtectedRoute.tsx
Normal file
9
src/components/ProtectedRoute.tsx
Normal 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}</>;
|
||||
}
|
||||
557
src/components/RecordViewTable.tsx
Normal file
557
src/components/RecordViewTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
src/components/ScreenRenderer.tsx
Normal file
110
src/components/ScreenRenderer.tsx
Normal 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?.(); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
50
src/components/Skeleton.tsx
Normal file
50
src/components/Skeleton.tsx
Normal 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
46
src/config.ts
Normal 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
17
src/hooks/AuthContext.tsx
Normal 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;
|
||||
}
|
||||
36
src/hooks/ThemeContext.tsx
Normal file
36
src/hooks/ThemeContext.tsx
Normal 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
93
src/hooks/useAuth.ts
Normal 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
38
src/index.css
Normal 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
24
src/main.tsx
Normal 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
176
src/pages/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
175
src/zino-sdk/DynamicForm.tsx
Normal file
175
src/zino-sdk/DynamicForm.tsx
Normal 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
64
src/zino-sdk/auth.ts
Normal 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
86
src/zino-sdk/client.ts
Normal 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
61
src/zino-sdk/index.ts
Normal 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
243
src/zino-sdk/mock.ts
Normal 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
143
src/zino-sdk/provider.tsx
Normal 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
285
src/zino-sdk/types.ts
Normal 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
197
src/zino-sdk/views.ts
Normal 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
223
src/zino-sdk/workflow.ts
Normal 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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user