inital commit
This commit is contained in:
commit
d16cd8d86e
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.local
|
||||||
1635
2026-05-23-123334-local-command-caveatcaveat-the-messages-below.txt
Normal file
1635
2026-05-23-123334-local-command-caveatcaveat-the-messages-below.txt
Normal file
File diff suppressed because it is too large
Load Diff
16
index.html
Normal file
16
index.html
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/zino.svg" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Poppins:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
|
<title>Test Drive Lead Manager</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3120
package-lock.json
generated
Normal file
3120
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "tech-mahindra",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"typecheck": "tsc -b",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.60.5",
|
||||||
|
"lucide-react": "^0.441.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.26.0",
|
||||||
|
"recharts": "^2.13.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.5",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.41",
|
||||||
|
"tailwindcss": "^3.4.10",
|
||||||
|
"typescript": "~5.5.4",
|
||||||
|
"vite": "^5.4.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
BIN
public/cars/bolero-neo.jpg
Normal file
BIN
public/cars/bolero-neo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
BIN
public/cars/scorpio-n.jpg
Normal file
BIN
public/cars/scorpio-n.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
public/cars/thar-roxx.jpg
Normal file
BIN
public/cars/thar-roxx.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
BIN
public/cars/xuv3xo.jpg
Normal file
BIN
public/cars/xuv3xo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
public/cars/xuv700.jpg
Normal file
BIN
public/cars/xuv700.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
9
public/zino.svg
Normal file
9
public/zino.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 27 KiB |
BIN
src/.DS_Store
vendored
Normal file
BIN
src/.DS_Store
vendored
Normal file
Binary file not shown.
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
422
src/api/viewService.ts
Normal file
422
src/api/viewService.ts
Normal file
@ -0,0 +1,422 @@
|
|||||||
|
import { APP_ID } from "../config";
|
||||||
|
|
||||||
|
const TOKEN_KEY = "zino_token";
|
||||||
|
|
||||||
|
function baseUrl(): string {
|
||||||
|
return (import.meta.env.VITE_ZINO_API_URL || "").replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function headers(): Record<string, string> {
|
||||||
|
const h: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
|
if (token) h["Authorization"] = `Bearer ${token}`;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${baseUrl()}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: headers(),
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem("zino_user");
|
||||||
|
window.location.href = `${import.meta.env.BASE_URL}login`;
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = res.statusText;
|
||||||
|
try { const e = await res.json(); if (e.error) msg = e.error; } catch {}
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Header
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface HeaderConfig {
|
||||||
|
slots: { left: string[]; right: string[]; center: string[] };
|
||||||
|
layout: { type: string; height: string; position: string; full_width: boolean };
|
||||||
|
components: {
|
||||||
|
nav?: { items: NavItem[] };
|
||||||
|
logo?: { text?: string; image_url?: string; display_type: string };
|
||||||
|
profile?: { shape: string; show_name: boolean };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavItem {
|
||||||
|
id: string;
|
||||||
|
icon: string;
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
screen_uuid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHeaderConfig(deviceType = "desktop"): Promise<HeaderConfig> {
|
||||||
|
const raw = await request<any>("GET", `/app/${APP_ID}/view/header/${deviceType}`);
|
||||||
|
return raw.config ?? raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Screens
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ScreenListItem {
|
||||||
|
id: number;
|
||||||
|
source_screen_id: number;
|
||||||
|
screen_name: string;
|
||||||
|
device: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScreenLayout {
|
||||||
|
id: number;
|
||||||
|
source_screen_id: number;
|
||||||
|
screen_name: string;
|
||||||
|
layout: LayoutElement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutElement {
|
||||||
|
type: string;
|
||||||
|
style?: Record<string, string>;
|
||||||
|
config: Record<string, any>;
|
||||||
|
classes?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScreens(deviceType = "desktop"): Promise<ScreenListItem[]> {
|
||||||
|
return request("GET", `/app/${APP_ID}/view/screens?device_type=${deviceType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScreen(idOrUuid: number | string): Promise<ScreenLayout> {
|
||||||
|
return request("GET", `/app/${APP_ID}/view/screens/${idOrUuid}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// RV Screens
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface RVScreen {
|
||||||
|
id: number;
|
||||||
|
source_screen_id: number;
|
||||||
|
screen_name: string;
|
||||||
|
rv_template_uid: string;
|
||||||
|
layout?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accepts either the numeric source_screen_id OR the source_uuid — both
|
||||||
|
// are accepted by the view-service's rv-screens endpoint.
|
||||||
|
export function getRVScreen(idOrUuid: number | string): Promise<RVScreen> {
|
||||||
|
return request("GET", `/app/${APP_ID}/view/rv-screens/${idOrUuid}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Record View (POST-based)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface SearchQuery {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
sort_by?: string;
|
||||||
|
sort_dir?: "asc" | "desc";
|
||||||
|
search?: string;
|
||||||
|
filters?: Array<{ field_key: string; value: string; data_type?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordViewField {
|
||||||
|
field_key: string;
|
||||||
|
output_label: string;
|
||||||
|
data_type: string;
|
||||||
|
is_filter: boolean;
|
||||||
|
is_search: boolean;
|
||||||
|
type?: string;
|
||||||
|
activity_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TileValue {
|
||||||
|
tile_uid: string;
|
||||||
|
key: string;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartDataRow {
|
||||||
|
dimension: unknown;
|
||||||
|
series?: string;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartDataResponse {
|
||||||
|
chart_uid: string;
|
||||||
|
key: string;
|
||||||
|
rows: ChartDataRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordViewResponse {
|
||||||
|
config: { fields: RecordViewField[] };
|
||||||
|
data: Record<string, unknown>[];
|
||||||
|
pagination?: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total_count: number;
|
||||||
|
total_pages: number;
|
||||||
|
};
|
||||||
|
tile_values?: TileValue[];
|
||||||
|
chart_data?: ChartDataResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecordView(
|
||||||
|
rvTemplateUid: string,
|
||||||
|
rvScreenId: string | number,
|
||||||
|
query: SearchQuery = {},
|
||||||
|
): Promise<RecordViewResponse> {
|
||||||
|
return request("POST", `/app/${APP_ID}/view/recordview`, {
|
||||||
|
rv_template_uid: rvTemplateUid,
|
||||||
|
rv_screen_id: String(rvScreenId),
|
||||||
|
search_query: {
|
||||||
|
page: query.page || 1,
|
||||||
|
limit: query.limit || 50,
|
||||||
|
sort_by: query.sort_by || "",
|
||||||
|
sort_dir: query.sort_dir || "desc",
|
||||||
|
search: query.search || "",
|
||||||
|
filters: query.filters || [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecordViewLegacy(
|
||||||
|
rvUid: string,
|
||||||
|
query: SearchQuery = {},
|
||||||
|
): Promise<RecordViewResponse> {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
qs.set("rv_id", rvUid);
|
||||||
|
if (query.page) qs.set("page", String(query.page));
|
||||||
|
if (query.limit) qs.set("limit", String(query.limit));
|
||||||
|
if (query.sort_by) qs.set("sort_by", query.sort_by);
|
||||||
|
if (query.sort_dir) qs.set("sort_dir", query.sort_dir);
|
||||||
|
if (query.search) qs.set("search", query.search);
|
||||||
|
if (query.filters) {
|
||||||
|
for (const f of query.filters) {
|
||||||
|
qs.set(`filter.${f.field_key}`, f.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return request("GET", `/recordview?${qs.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DV Screens
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface DVScreen {
|
||||||
|
id: number;
|
||||||
|
source_screen_id: number;
|
||||||
|
screen_name: string;
|
||||||
|
dv_template_uid: string;
|
||||||
|
layout?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDVScreen(idOrUuid: number | string): Promise<DVScreen> {
|
||||||
|
return request("GET", `/app/${APP_ID}/view/dv-screens/${idOrUuid}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Detail View
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface DetailViewResponse {
|
||||||
|
config: { fields: Array<{ field_key: string; output_label: string; data_type: string }> };
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDetailView(dvSourceIdOrUid: number | string, instanceId: string): Promise<DetailViewResponse> {
|
||||||
|
return request(
|
||||||
|
"GET",
|
||||||
|
`/app/${APP_ID}/view/detailview/${encodeURIComponent(String(dvSourceIdOrUid))}?instance_id=${encodeURIComponent(instanceId)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDetailViewLegacy(dvUid: string, instanceId: string): Promise<DetailViewResponse> {
|
||||||
|
return request(
|
||||||
|
"GET",
|
||||||
|
`/detailview?dv_id=${encodeURIComponent(dvUid)}&instance_id=${encodeURIComponent(instanceId)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Form Screens
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface FormScreenField {
|
||||||
|
id: string;
|
||||||
|
uid: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
data_type: string;
|
||||||
|
mandatory: boolean;
|
||||||
|
value?: any;
|
||||||
|
properties?: {
|
||||||
|
options?: Array<{ label: string; value: string }>;
|
||||||
|
country_code?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormScreenResponse {
|
||||||
|
id: number;
|
||||||
|
activity_uid: string;
|
||||||
|
activity_name: string;
|
||||||
|
device_type: string;
|
||||||
|
fields: FormScreenField[];
|
||||||
|
grid_config: Array<{ i: string; x: number; y: number; w: number; h: number }>;
|
||||||
|
layout: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFormScreen(
|
||||||
|
activityId: string,
|
||||||
|
instanceId?: string,
|
||||||
|
deviceType = "desktop",
|
||||||
|
): Promise<FormScreenResponse> {
|
||||||
|
const numericInstanceId = instanceId ? Number(instanceId) : undefined;
|
||||||
|
return request("POST", `/app/${APP_ID}/view/form-screens`, {
|
||||||
|
activity_id: activityId,
|
||||||
|
device_type: deviceType,
|
||||||
|
...(numericInstanceId && !Number.isNaN(numericInstanceId)
|
||||||
|
? { instance_id: numericInstanceId }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Workflow Actions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface WorkflowResponse {
|
||||||
|
success: boolean;
|
||||||
|
status_code?: number;
|
||||||
|
message?: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
instance_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Core-service expects the workflow UUID as `workflow_uuid` — the numeric
|
||||||
|
// `workflow_id` field was removed when the clone-portable identifier rolled out.
|
||||||
|
export function startWorkflow(
|
||||||
|
workflowUuid: string,
|
||||||
|
activityId: string,
|
||||||
|
data?: Record<string, unknown>,
|
||||||
|
): Promise<WorkflowResponse> {
|
||||||
|
return request("POST", `/app/${APP_ID}/start`, {
|
||||||
|
workflow_uuid: workflowUuid,
|
||||||
|
activity_id: activityId,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function performActivity(
|
||||||
|
workflowUuid: string,
|
||||||
|
instanceId: string,
|
||||||
|
activityId: string,
|
||||||
|
data?: Record<string, unknown>,
|
||||||
|
): Promise<WorkflowResponse> {
|
||||||
|
const numericInstanceId = Number(instanceId);
|
||||||
|
return request("POST", `/app/${APP_ID}/activity`, {
|
||||||
|
workflow_uuid: workflowUuid,
|
||||||
|
instance_id: Number.isNaN(numericInstanceId) ? instanceId : numericInstanceId,
|
||||||
|
activity_id: activityId,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function submitForm(
|
||||||
|
workflowUuid: string,
|
||||||
|
activityId: string,
|
||||||
|
formData: Record<string, unknown>,
|
||||||
|
instanceId?: string,
|
||||||
|
deviceType = "desktop",
|
||||||
|
): Promise<WorkflowResponse> {
|
||||||
|
const numericInstanceId = instanceId ? Number(instanceId) : undefined;
|
||||||
|
return request("POST", `/app/${APP_ID}/form/submit`, {
|
||||||
|
workflow_uuid: workflowUuid,
|
||||||
|
activity_id: activityId,
|
||||||
|
device_type: deviceType,
|
||||||
|
form_data: formData,
|
||||||
|
...(numericInstanceId && !Number.isNaN(numericInstanceId)
|
||||||
|
? { instance_id: numericInstanceId }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Instance
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface InstanceResponse {
|
||||||
|
instance_id: string;
|
||||||
|
workflow_id: string;
|
||||||
|
current_state_id: string;
|
||||||
|
current_state_name: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInstance(workflowUuid: string, instanceId: string): Promise<InstanceResponse> {
|
||||||
|
return request("POST", `/app/${APP_ID}/instance`, {
|
||||||
|
workflow_uuid: workflowUuid,
|
||||||
|
instance_id: Number(instanceId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Audit
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface AuditEntry {
|
||||||
|
id: number;
|
||||||
|
user_id: string;
|
||||||
|
user_roles: string[];
|
||||||
|
activity_id: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
execution_state: string;
|
||||||
|
created_at: string;
|
||||||
|
// Optional human-readable summary line. Populated by useInstanceMeta when
|
||||||
|
// the audit is synthesised from instance.data._activities.
|
||||||
|
context?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuditLog(instanceId: string): Promise<AuditEntry[]> {
|
||||||
|
return request("GET", `/app/${APP_ID}/view/audit?instance_id=${encodeURIComponent(instanceId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// RDBMS lookup records — used by the demo calendar to pull rows from the
|
||||||
|
// `mahindra_demo` schema via the lookup-field config on the INIT activity.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface RdbmsLookupResponse {
|
||||||
|
records: Record<string, unknown>[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RdbmsLookupQuery {
|
||||||
|
workflowId: number;
|
||||||
|
activityId: string;
|
||||||
|
fieldId: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchRdbmsLookupRecords(templateUuid: string, q: RdbmsLookupQuery): Promise<RdbmsLookupResponse> {
|
||||||
|
return request("POST", `/app/${APP_ID}/rdbms-templates/${encodeURIComponent(templateUuid)}/records`, {
|
||||||
|
workflow_id: q.workflowId,
|
||||||
|
activity_id: q.activityId,
|
||||||
|
field_id: q.fieldId,
|
||||||
|
limit: q.limit ?? 1000,
|
||||||
|
offset: q.offset ?? 0,
|
||||||
|
search: q.search ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
368
src/components/AppShell.tsx
Normal file
368
src/components/AppShell.tsx
Normal file
@ -0,0 +1,368 @@
|
|||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
import { Loader2, CheckCircle2, RefreshCw, Clock, Sparkles } from "lucide-react";
|
||||||
|
import DynamicHeader from "./DynamicHeader";
|
||||||
|
import ScreenRenderer from "./ScreenRenderer";
|
||||||
|
import { TableSkeleton } from "./Skeleton";
|
||||||
|
import DetailViewPanel from "./DetailViewPanel";
|
||||||
|
import FormModal from "./FormModal";
|
||||||
|
import VariantB from "./variants/VariantB";
|
||||||
|
import {
|
||||||
|
getHeaderConfig, getScreen, getAuditLog, getInstance,
|
||||||
|
type LayoutElement, type NavItem, type AuditEntry,
|
||||||
|
} from "../api/viewService";
|
||||||
|
import { WORKFLOW_ID, ACTIVITY_IDS, STATE_IDS, DETAIL_VIEW_IDS, SCREEN_TO_DV } from "../config";
|
||||||
|
import { fmtAct } from "../lib/activity";
|
||||||
|
|
||||||
|
// State → contextual action buttons shown above the detail view.
|
||||||
|
const ACTIVITY_META: Record<string, { label: string; icon: React.ReactNode; style: "primary" | "ghost" }> = {
|
||||||
|
[ACTIVITY_IDS.MARK_TEST_DRIVE_DONE]: { label: "Mark Test Drive Done", icon: <CheckCircle2 size={14} />, style: "primary" },
|
||||||
|
[ACTIVITY_IDS.RETRY_SCHEDULING_CALL]: { label: "Retry Scheduling Call", icon: <RefreshCw size={14} />, style: "primary" },
|
||||||
|
[ACTIVITY_IDS.RETRY_FEEDBACK_CALL]: { label: "Retry Feedback Call", icon: <RefreshCw size={14} />, style: "primary" },
|
||||||
|
[ACTIVITY_IDS.MARK_EXPERT_TD_DONE]: { label: "Mark Expert TD Done", icon: <CheckCircle2 size={14} />, style: "primary" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const BTN_STYLES: Record<string, string> = {
|
||||||
|
primary: "text-white mh-glow hover:scale-[1.02]",
|
||||||
|
ghost: "text-stone-600 dark:text-zinc-300 bg-white dark:bg-zinc-900 border border-stone-200 dark:border-zinc-700 hover:bg-stone-50 dark:hover:bg-zinc-800",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Friendly state labels for the breadcrumb / banner.
|
||||||
|
const STATE_LABEL: Record<string, string> = {
|
||||||
|
[STATE_IDS.AWAITING_SCHEDULER_CALL]: "Awaiting Scheduler Call",
|
||||||
|
[STATE_IDS.SCHEDULED]: "Scheduled",
|
||||||
|
[STATE_IDS.AWAITING_FEEDBACK_CALL]: "Awaiting Feedback Call",
|
||||||
|
[STATE_IDS.FEEDBACK_COLLECTED]: "Feedback Collected",
|
||||||
|
[STATE_IDS.SCHEDULING_CALL_FAILED]: "Scheduling Call Failed",
|
||||||
|
[STATE_IDS.FEEDBACK_CALL_FAILED]: "Feedback Call Failed",
|
||||||
|
[STATE_IDS.EXPERT_TD_SCHEDULED]: "Expert TD Scheduled",
|
||||||
|
[STATE_IDS.END_STATE]: "Closed",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Color hint for state pill.
|
||||||
|
function stateTone(stateId: string | null) {
|
||||||
|
if (!stateId) return { bg: "rgba(168,162,158,0.15)", fg: "#57534E", ring: "rgba(168,162,158,0.30)" };
|
||||||
|
if (stateId === STATE_IDS.FEEDBACK_COLLECTED || stateId === STATE_IDS.END_STATE)
|
||||||
|
return { bg: "rgba(16,185,129,0.12)", fg: "#047857", ring: "rgba(16,185,129,0.30)" };
|
||||||
|
if (stateId === STATE_IDS.SCHEDULING_CALL_FAILED || stateId === STATE_IDS.FEEDBACK_CALL_FAILED)
|
||||||
|
return { bg: "rgba(239,68,68,0.12)", fg: "#B91C1C", ring: "rgba(239,68,68,0.30)" };
|
||||||
|
if (stateId === STATE_IDS.AWAITING_SCHEDULER_CALL || stateId === STATE_IDS.AWAITING_FEEDBACK_CALL)
|
||||||
|
return { bg: "rgba(200,16,46,0.10)", fg: "#C8102E", ring: "rgba(200,16,46,0.28)" };
|
||||||
|
if (stateId === STATE_IDS.SCHEDULED || stateId === STATE_IDS.EXPERT_TD_SCHEDULED)
|
||||||
|
return { bg: "rgba(245,158,11,0.14)", fg: "#B45309", ring: "rgba(245,158,11,0.30)" };
|
||||||
|
return { bg: "rgba(168,162,158,0.15)", fg: "#57534E", ring: "rgba(168,162,158,0.30)" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppShell() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
// ?variant=current escape hatch to the old red UI; default is Variant B (Insights Studio).
|
||||||
|
if (searchParams.get("variant") === "current") return <CurrentAppShell />;
|
||||||
|
return <VariantB />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CurrentAppShell() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const params = useParams();
|
||||||
|
const screenIdParam = params.screenId || null;
|
||||||
|
const instanceIdParam = params.instanceId;
|
||||||
|
|
||||||
|
const [activeScreenId, setActiveScreenId] = useState<string | null>(screenIdParam);
|
||||||
|
const [layout, setLayout] = useState<LayoutElement[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
const [detailInstanceId, setDetailInstanceId] = useState<string | null>(instanceIdParam || null);
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
const [formModal, setFormModal] = useState<{ activityId: string; instanceId: string; title: string } | null>(null);
|
||||||
|
const [instanceState, setInstanceState] = useState<string | null>(null);
|
||||||
|
const [audit, setAudit] = useState<AuditEntry[]>([]);
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
|
const [navItems, setNavItems] = useState<NavItem[]>([]);
|
||||||
|
|
||||||
|
// Init — pick the first nav screen if no URL screen is set.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!screenIdParam && !instanceIdParam) {
|
||||||
|
getHeaderConfig("desktop")
|
||||||
|
.then((cfg) => {
|
||||||
|
const items = cfg.components?.nav?.items ?? [];
|
||||||
|
setNavItems(items);
|
||||||
|
if (items.length > 0) handleNavigate(items[0].screen_uuid);
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setReady(true));
|
||||||
|
} else {
|
||||||
|
// Still need nav items for the breadcrumb / sidebar.
|
||||||
|
getHeaderConfig("desktop")
|
||||||
|
.then((cfg) => setNavItems(cfg.components?.nav?.items ?? []))
|
||||||
|
.catch(console.error);
|
||||||
|
setReady(true);
|
||||||
|
if (instanceIdParam) setDetailInstanceId(instanceIdParam);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Sync detail state with browser history.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!params.instanceId && detailInstanceId) {
|
||||||
|
setDetailInstanceId(null);
|
||||||
|
setInstanceState(null);
|
||||||
|
setAudit([]);
|
||||||
|
} else if (params.instanceId && params.instanceId !== detailInstanceId) {
|
||||||
|
setDetailInstanceId(params.instanceId);
|
||||||
|
}
|
||||||
|
}, [params.instanceId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Load active screen layout.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeScreenId || detailInstanceId) return;
|
||||||
|
setLoading(true);
|
||||||
|
getScreen(activeScreenId)
|
||||||
|
.then((s) => setLayout(s.layout ?? []))
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [activeScreenId, detailInstanceId]);
|
||||||
|
|
||||||
|
// Load instance state + audit.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!detailInstanceId) return;
|
||||||
|
getInstance(WORKFLOW_ID, detailInstanceId)
|
||||||
|
.then((inst) => setInstanceState(inst.current_state_id))
|
||||||
|
.catch(() => {});
|
||||||
|
getAuditLog(detailInstanceId).then(setAudit).catch(() => setAudit([]));
|
||||||
|
}, [detailInstanceId, refreshKey]);
|
||||||
|
|
||||||
|
const handleNavigate = useCallback((uuid: string) => {
|
||||||
|
setActiveScreenId(uuid);
|
||||||
|
setDetailInstanceId(null);
|
||||||
|
setInstanceState(null);
|
||||||
|
setAudit([]);
|
||||||
|
navigate(`/screen/${uuid}`);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const handleRowClick = useCallback((instanceId: string) => {
|
||||||
|
setDetailInstanceId(instanceId);
|
||||||
|
navigate(`/screen/${activeScreenId}/detail/${instanceId}`);
|
||||||
|
}, [activeScreenId, navigate]);
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setDetailInstanceId(null);
|
||||||
|
setInstanceState(null);
|
||||||
|
setAudit([]);
|
||||||
|
navigate(`/screen/${activeScreenId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormSuccess = () => {
|
||||||
|
setFormModal(null);
|
||||||
|
setRefreshKey((k) => k + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const actions = (() => {
|
||||||
|
if (!instanceState) return [];
|
||||||
|
if (instanceState === STATE_IDS.SCHEDULED) return [ACTIVITY_IDS.MARK_TEST_DRIVE_DONE];
|
||||||
|
if (instanceState === STATE_IDS.SCHEDULING_CALL_FAILED) return [ACTIVITY_IDS.RETRY_SCHEDULING_CALL];
|
||||||
|
if (instanceState === STATE_IDS.FEEDBACK_CALL_FAILED) return [ACTIVITY_IDS.RETRY_FEEDBACK_CALL];
|
||||||
|
if (instanceState === STATE_IDS.EXPERT_TD_SCHEDULED) return [ACTIVITY_IDS.MARK_EXPERT_TD_DONE];
|
||||||
|
return [];
|
||||||
|
})();
|
||||||
|
|
||||||
|
const aiInFlight =
|
||||||
|
instanceState === STATE_IDS.AWAITING_SCHEDULER_CALL ||
|
||||||
|
instanceState === STATE_IDS.AWAITING_FEEDBACK_CALL;
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-stone-50 dark:bg-zinc-950 flex justify-center items-center">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<Loader2 size={22} className="animate-spin text-[#C8102E]" />
|
||||||
|
<span className="text-[12px] text-stone-400 dark:text-zinc-600">Loading…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tone = stateTone(instanceState);
|
||||||
|
const stateLabel = instanceState ? STATE_LABEL[instanceState] || instanceState : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-stone-50/60 dark:bg-zinc-950">
|
||||||
|
<DynamicHeader
|
||||||
|
activeScreenId={activeScreenId}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
onSidebarChange={setSidebarOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className={`transition-all duration-200 py-6 px-7 ${sidebarOpen ? "ml-60" : "ml-0"}`}>
|
||||||
|
<div className="w-full max-w-7xl mx-auto">
|
||||||
|
{detailInstanceId ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Breadcrumb + actions */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<nav className="flex items-center gap-1.5 text-[13px]">
|
||||||
|
<button
|
||||||
|
onClick={handleBack}
|
||||||
|
className="text-stone-400 dark:text-zinc-500 hover:text-[#C8102E] dark:hover:text-rose-400 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
{navItems.find((n) => n.screen_uuid === activeScreenId)?.label ?? "Pipeline"}
|
||||||
|
</button>
|
||||||
|
<span className="text-stone-300 dark:text-zinc-700">/</span>
|
||||||
|
<span className="text-stone-700 dark:text-zinc-200 font-medium">
|
||||||
|
Lead #{detailInstanceId}
|
||||||
|
</span>
|
||||||
|
{stateLabel && (
|
||||||
|
<span
|
||||||
|
className="ml-2 text-[10px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded-full"
|
||||||
|
style={{ background: tone.bg, color: tone.fg, border: `1px solid ${tone.ring}` }}
|
||||||
|
>
|
||||||
|
{stateLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{actions.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{actions.map((id) => {
|
||||||
|
const m = ACTIVITY_META[id];
|
||||||
|
if (!m) return null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => setFormModal({ activityId: id, instanceId: detailInstanceId, title: m.label })}
|
||||||
|
className={`h-9 px-4 text-[12px] font-semibold rounded-xl transition-all flex items-center gap-1.5 ${BTN_STYLES[m.style]}`}
|
||||||
|
style={m.style === "primary" ? { background: "linear-gradient(135deg, #C8102E, #E84258)" } : {}}
|
||||||
|
>
|
||||||
|
{m.icon}{m.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI banner */}
|
||||||
|
{aiInFlight && (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl px-5 py-3.5 border flex items-center gap-3"
|
||||||
|
style={{ background: "rgba(200,16,46,0.06)", borderColor: "rgba(200,16,46,0.22)" }}
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 gradient-mh">
|
||||||
|
<Sparkles size={14} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[12.5px] font-semibold text-[#9D0D24]">Maya is on the line</div>
|
||||||
|
<div className="text-[11.5px] text-stone-500 dark:text-zinc-400 mt-0.5">
|
||||||
|
The AI scheduler is placing the call right now. This page refreshes on completion.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Detail panel — picks the dv-screen matching the active rv;
|
||||||
|
falls back to the shared dv-template UID when no mapping exists. */}
|
||||||
|
<DetailViewPanel
|
||||||
|
key={`dv-${detailInstanceId}-${refreshKey}`}
|
||||||
|
viewId={
|
||||||
|
(activeScreenId && SCREEN_TO_DV[activeScreenId]) ||
|
||||||
|
DETAIL_VIEW_IDS.INSTANCE_DETAIL
|
||||||
|
}
|
||||||
|
instanceId={detailInstanceId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Activity timeline */}
|
||||||
|
{audit.length > 0 && (
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-stone-200 dark:border-zinc-700/60 overflow-hidden">
|
||||||
|
<div className="px-5 py-3 border-b border-stone-100 dark:border-zinc-800 flex items-center gap-2.5">
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded-lg flex items-center justify-center"
|
||||||
|
style={{ background: "rgba(200,16,46,0.10)" }}
|
||||||
|
>
|
||||||
|
<Clock size={13} className="text-[#C8102E]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-[13px] font-semibold text-stone-700 dark:text-zinc-200">Activity Timeline</h3>
|
||||||
|
<span className="text-[11px] text-stone-300 dark:text-zinc-600 font-medium">
|
||||||
|
{audit.length} events
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<div className="relative pl-6">
|
||||||
|
<div
|
||||||
|
className="absolute left-[7px] top-2 bottom-2 w-px"
|
||||||
|
style={{ background: "linear-gradient(to bottom, rgba(200,16,46,0.35), rgba(232,66,88,0.15), transparent)" }}
|
||||||
|
/>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{audit.map((e, i) => {
|
||||||
|
const isFirst = i === 0;
|
||||||
|
const isLast = i === audit.length - 1;
|
||||||
|
return (
|
||||||
|
<div key={e.id} className="flex items-start gap-3 relative">
|
||||||
|
<div
|
||||||
|
className={`absolute -left-6 w-3.5 h-3.5 rounded-full border-2 border-white dark:border-zinc-900 mt-0.5 shadow-sm ${
|
||||||
|
isLast ? "ring-2 ring-rose-200 dark:ring-rose-900" : ""
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
background: isLast ? "#C8102E" : isFirst ? "#10b981" : "#d1d5db",
|
||||||
|
boxShadow: isLast ? "0 0 8px rgba(200,16,46,0.5)" : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 py-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`text-[13px] font-medium ${
|
||||||
|
isLast ? "text-[#9D0D24] dark:text-rose-300" : "text-stone-700 dark:text-zinc-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{fmtAct(e.activity_id)}
|
||||||
|
</span>
|
||||||
|
{isLast && (
|
||||||
|
<span
|
||||||
|
className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full"
|
||||||
|
style={{ background: "rgba(200,16,46,0.10)", color: "#C8102E" }}
|
||||||
|
>
|
||||||
|
Latest
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-stone-400 dark:text-zinc-500">
|
||||||
|
{new Date(e.created_at).toLocaleString("en-IN", {
|
||||||
|
day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
<TableSkeleton rows={6} cols={6} />
|
||||||
|
) : layout.length > 0 ? (
|
||||||
|
<ScreenRenderer
|
||||||
|
key={`screen-${activeScreenId}-${refreshKey}`}
|
||||||
|
layout={layout}
|
||||||
|
onRowClick={handleRowClick}
|
||||||
|
onRefresh={() => setRefreshKey((k) => k + 1)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-20 text-sm text-stone-400 dark:text-zinc-600">
|
||||||
|
Select a screen from the navigation
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{formModal && (
|
||||||
|
<FormModal
|
||||||
|
activityId={formModal.activityId}
|
||||||
|
instanceId={formModal.instanceId}
|
||||||
|
title={formModal.title}
|
||||||
|
onClose={() => setFormModal(null)}
|
||||||
|
onSuccess={handleFormSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
51
src/components/BarChart.tsx
Normal file
51
src/components/BarChart.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { BarChart as RBarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid, ResponsiveContainer, Cell } from "recharts";
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
dimension: unknown;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title?: string;
|
||||||
|
rows: Row[];
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RED_PALETTE = ["#C8102E", "#E84258", "#F26471", "#F69199", "#FABFBF"];
|
||||||
|
|
||||||
|
export default function BarChart({ title, rows, loading }: Props) {
|
||||||
|
const data = (rows ?? [])
|
||||||
|
.map(r => ({ name: String(r.dimension ?? "—"), value: Number(r.value ?? 0) }))
|
||||||
|
.filter(d => d.name && d.name !== "—" && d.value > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 min-w-[280px] bg-white dark:bg-zinc-900 rounded-2xl border border-stone-200/80 dark:border-zinc-700/60 px-5 py-4 shadow-sm">
|
||||||
|
{title && (
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-widest text-stone-400 dark:text-zinc-500 mb-3">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[0,1,2,3].map(i => <div key={i} className="h-5 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse" />)}
|
||||||
|
</div>
|
||||||
|
) : data.length === 0 ? (
|
||||||
|
<div className="text-[12px] text-stone-400 dark:text-zinc-500 py-4 text-center">No data</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ width: "100%", height: 220 }}>
|
||||||
|
<ResponsiveContainer>
|
||||||
|
<RBarChart data={data} margin={{ top: 5, right: 10, left: -10, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
|
||||||
|
<XAxis dataKey="name" tick={{ fontSize: 11, fill: "#78716c" }} interval={0} />
|
||||||
|
<YAxis tick={{ fontSize: 11, fill: "#78716c" }} allowDecimals={false} />
|
||||||
|
<Tooltip cursor={{ fill: "#fef2f2" }} contentStyle={{ fontSize: 12, borderRadius: 8 }} />
|
||||||
|
<Bar dataKey="value" radius={[6, 6, 0, 0]}>
|
||||||
|
{data.map((_, i) => <Cell key={i} fill={RED_PALETTE[i % RED_PALETTE.length]} />)}
|
||||||
|
</Bar>
|
||||||
|
</RBarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
517
src/components/DetailViewPanel.tsx
Normal file
517
src/components/DetailViewPanel.tsx
Normal file
@ -0,0 +1,517 @@
|
|||||||
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
import { Hash, Calendar, Sparkles, TrendingUp, MessageSquare, ChevronDown, ChevronUp, Activity } from "lucide-react";
|
||||||
|
import { getDVScreen, getDetailView, getInstance, type InstanceResponse } from "../api/viewService";
|
||||||
|
import { WORKFLOW_ID, STATE_IDS, ACTIVITY_IDS } from "../config";
|
||||||
|
import { fmtAct } from "../lib/activity";
|
||||||
|
|
||||||
|
interface Field { field_key: string; output_label: string; data_type: string }
|
||||||
|
interface Props { viewId: number | string; instanceId: string }
|
||||||
|
|
||||||
|
type GroupKey = "hero" | "customer" | "vehicle" | "booking" | "feedback" | "ai" | "general";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Classification — Mahindra test-drive workflow
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function classify(key: string): GroupKey {
|
||||||
|
const k = key.toLowerCase();
|
||||||
|
if (k === "customer_name") return "hero";
|
||||||
|
if (k.startsWith("customer_") || k === "lead_source" || k === "phone" || k === "email") return "customer";
|
||||||
|
if (k === "model_interest" || k.startsWith("vehicle_") || k === "variant" || k === "color" || k === "fuel_type") return "vehicle";
|
||||||
|
if (k.startsWith("booking_") || k === "dealer_name" || k === "dealer_id" || k === "slot_start" || k === "slot_end") return "booking";
|
||||||
|
if (k.startsWith("feedback_") || k === "test_drive_outcome" || k === "would_recommend" || k === "rating") return "feedback";
|
||||||
|
if (k.startsWith("ai_") || k.startsWith("call_") || k.includes("summary") || k.includes("failure_reason") ||
|
||||||
|
k.includes("confidence") || k.includes("reasoning") || k.includes("escalation")) return "ai";
|
||||||
|
return "general";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const STATUS: Record<string, { pill: string; dot: string; bar: string; pulse?: boolean }> = {
|
||||||
|
"Awaiting Scheduler Call": {
|
||||||
|
pill: "text-rose-700 bg-rose-50 ring-rose-200 dark:text-rose-300 dark:bg-rose-950/30 dark:ring-rose-900",
|
||||||
|
dot: "bg-rose-500", bar: "bg-rose-500", pulse: true,
|
||||||
|
},
|
||||||
|
"Scheduled": {
|
||||||
|
pill: "text-amber-700 bg-amber-50 ring-amber-200 dark:text-amber-300 dark:bg-amber-950/30 dark:ring-amber-900",
|
||||||
|
dot: "bg-amber-500", bar: "bg-amber-500",
|
||||||
|
},
|
||||||
|
"Awaiting Feedback Call": {
|
||||||
|
pill: "text-rose-700 bg-rose-50 ring-rose-200 dark:text-rose-300 dark:bg-rose-950/30 dark:ring-rose-900",
|
||||||
|
dot: "bg-rose-500", bar: "bg-rose-500", pulse: true,
|
||||||
|
},
|
||||||
|
"Feedback Collected": {
|
||||||
|
pill: "text-emerald-700 bg-emerald-50 ring-emerald-200 dark:text-emerald-300 dark:bg-emerald-950/30 dark:ring-emerald-900",
|
||||||
|
dot: "bg-emerald-500", bar: "bg-emerald-500",
|
||||||
|
},
|
||||||
|
"Scheduling Call Failed": {
|
||||||
|
pill: "text-red-700 bg-red-50 ring-red-200 dark:text-red-300 dark:bg-red-950/30 dark:ring-red-900",
|
||||||
|
dot: "bg-red-500", bar: "bg-red-500",
|
||||||
|
},
|
||||||
|
"Feedback Call Failed": {
|
||||||
|
pill: "text-red-700 bg-red-50 ring-red-200 dark:text-red-300 dark:bg-red-950/30 dark:ring-red-900",
|
||||||
|
dot: "bg-red-500", bar: "bg-red-500",
|
||||||
|
},
|
||||||
|
"End State": {
|
||||||
|
pill: "text-stone-600 bg-stone-100 ring-stone-200 dark:text-zinc-400 dark:bg-zinc-800 dark:ring-zinc-700",
|
||||||
|
dot: "bg-stone-400", bar: "bg-stone-400",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const DS = { pill: "text-stone-600 bg-stone-100 ring-stone-200 dark:text-zinc-400 dark:bg-zinc-800 dark:ring-zinc-700", dot: "bg-stone-400", bar: "bg-stone-300" };
|
||||||
|
|
||||||
|
const SKIP_KEYS = new Set(["analysis_so_far"]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Skeleton
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function DetailSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-stone-200/80 dark:border-zinc-700/60 overflow-hidden">
|
||||||
|
<div className="h-[3px] bg-stone-100 dark:bg-zinc-800" />
|
||||||
|
<div className="px-7 pt-5 pb-4 border-b border-stone-100 dark:border-zinc-800 space-y-3">
|
||||||
|
<div className="h-6 w-48 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse" />
|
||||||
|
<div className="h-4 w-32 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div className="px-7 py-5 grid grid-cols-2 gap-6">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
<div className="h-3 w-20 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse" />
|
||||||
|
<div className="h-4 w-32 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function DetailViewPanel({ viewId, instanceId }: Props) {
|
||||||
|
const [data, setData] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [fields, setFields] = useState<Field[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
getDVScreen(viewId)
|
||||||
|
.then(dv => getDetailView(dv.dv_template_uid, instanceId))
|
||||||
|
.catch(() => getDetailView(viewId, instanceId))
|
||||||
|
.then(res => {
|
||||||
|
setFields(res.config?.fields?.filter((f: any) => f.field_key !== "") ?? []);
|
||||||
|
setData(res.data ?? {});
|
||||||
|
})
|
||||||
|
.catch((e: any) => setError(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [viewId, instanceId]);
|
||||||
|
|
||||||
|
if (loading) return <DetailSkeleton />;
|
||||||
|
if (error) return <p className="text-sm text-red-500 dark:text-red-400">{error}</p>;
|
||||||
|
if (!data || !fields.length) return null;
|
||||||
|
|
||||||
|
const sys = new Set(["instance_id", "current_state_name", "created_at", "updated_at"]);
|
||||||
|
const groups: Record<GroupKey, Field[]> = { hero: [], customer: [], vehicle: [], booking: [], feedback: [], ai: [], general: [] };
|
||||||
|
|
||||||
|
for (const f of fields) {
|
||||||
|
if (sys.has(f.field_key) || SKIP_KEYS.has(f.field_key)) continue;
|
||||||
|
const g = classify(f.field_key);
|
||||||
|
groups[g].push(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
const heroField = groups.hero[0];
|
||||||
|
const heroValue = heroField ? String(data[heroField.field_key] ?? "") : "";
|
||||||
|
const stateName = String(data.current_state_name ?? "");
|
||||||
|
const sc = STATUS[stateName] ?? DS;
|
||||||
|
|
||||||
|
const phone = fmtPhone(data.customer_phone);
|
||||||
|
const model = String(data.model_interest ?? "");
|
||||||
|
|
||||||
|
const leftGroups = [
|
||||||
|
{ label: "Customer", fields: groups.customer },
|
||||||
|
{ label: "Vehicle", fields: groups.vehicle },
|
||||||
|
{ label: "Other", fields: groups.general },
|
||||||
|
].filter(g => g.fields.length > 0);
|
||||||
|
|
||||||
|
const rightGroups = [
|
||||||
|
{ label: "Booking", fields: groups.booking },
|
||||||
|
{ label: "Feedback", fields: groups.feedback },
|
||||||
|
].filter(g => g.fields.length > 0);
|
||||||
|
|
||||||
|
const isScore = (f: Field) => f.field_key.toLowerCase().includes("confidence") || f.field_key.toLowerCase().includes("rating") || f.field_key.toLowerCase().includes("score");
|
||||||
|
const isLongText = (f: Field) => { const v = data[f.field_key]; return typeof v === "string" && v.length > 55; };
|
||||||
|
|
||||||
|
const aiShort = groups.ai.filter(f => isScore(f) || !isLongText(f));
|
||||||
|
const aiLong = groups.ai.filter(f => !isScore(f) && isLongText(f));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-stone-200/80 dark:border-zinc-700/60 overflow-hidden">
|
||||||
|
|
||||||
|
{/* Status bar */}
|
||||||
|
<div className={`h-[3px] ${sc.bar}`} />
|
||||||
|
|
||||||
|
{/* ── Header ──────────────────────────────────────────────────── */}
|
||||||
|
<div className="px-7 pt-5 pb-4 border-b border-stone-100 dark:border-zinc-800">
|
||||||
|
<div className="flex items-start justify-between gap-6 mb-3">
|
||||||
|
<div className="min-w-0 flex items-center gap-3 flex-wrap">
|
||||||
|
{heroValue && (
|
||||||
|
<h1 className="text-[20px] font-bold tracking-tight text-stone-900 dark:text-white leading-none">
|
||||||
|
{heroValue}
|
||||||
|
</h1>
|
||||||
|
)}
|
||||||
|
{stateName && (
|
||||||
|
<span className={`inline-flex items-center gap-1.5 text-[11px] font-semibold px-2.5 py-1 rounded-full ring-1 shrink-0 ${sc.pill}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${sc.dot} ${sc.pulse ? "animate-pulse" : ""}`} />
|
||||||
|
{stateName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{model && (
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<p className="text-[10px] font-medium text-stone-400 dark:text-zinc-600 mb-0.5 uppercase tracking-wider">Model Interest</p>
|
||||||
|
<p className="text-[18px] font-bold text-stone-900 dark:text-white leading-none">{model}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 flex-wrap text-[12px]">
|
||||||
|
{phone && (
|
||||||
|
<span className="font-medium text-stone-600 dark:text-zinc-300">{phone}</span>
|
||||||
|
)}
|
||||||
|
{phone && (data.created_at || data.instance_id) && (
|
||||||
|
<span className="text-stone-200 dark:text-zinc-700">·</span>
|
||||||
|
)}
|
||||||
|
{data.created_at && (
|
||||||
|
<span className="flex items-center gap-1 text-stone-400 dark:text-zinc-500">
|
||||||
|
<Calendar size={11} />{fmtDate(String(data.created_at))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{data.instance_id && (
|
||||||
|
<span className="flex items-center gap-1 text-stone-400 dark:text-zinc-500 font-mono text-[11px]">
|
||||||
|
<Hash size={10} />Lead #{String(data.instance_id)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Two-column metadata ──────────────────────────────────────── */}
|
||||||
|
{(leftGroups.length > 0 || rightGroups.length > 0) && (
|
||||||
|
<div className="grid lg:grid-cols-2 divide-y lg:divide-y-0 lg:divide-x divide-stone-100 dark:divide-zinc-800 border-b border-stone-100 dark:border-zinc-800">
|
||||||
|
<div className="px-7 py-5 space-y-6">
|
||||||
|
{leftGroups.map(({ label, fields: fs }) => (
|
||||||
|
<section key={label}>
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest text-stone-400 dark:text-zinc-600 mb-3">{label}</p>
|
||||||
|
<dl className="grid grid-cols-2 gap-x-8 gap-y-4">
|
||||||
|
{fs.map(f => (
|
||||||
|
<div key={f.field_key}>
|
||||||
|
<dt className="text-[11px] text-stone-400 dark:text-zinc-500 mb-0.5">{fmtLabel(f.output_label)}</dt>
|
||||||
|
<dd className="text-[14px] font-medium text-stone-900 dark:text-zinc-100 break-words">
|
||||||
|
{fmtVal(data[f.field_key], f.data_type, f.field_key)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-7 py-5 space-y-6">
|
||||||
|
{rightGroups.length > 0 ? rightGroups.map(({ label, fields: fs }) => (
|
||||||
|
<section key={label}>
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest text-stone-400 dark:text-zinc-600 mb-3 flex items-center gap-1.5">
|
||||||
|
{label === "Booking" && <TrendingUp size={11} className="text-amber-500" />}
|
||||||
|
{label === "Feedback" && <MessageSquare size={11} className="text-emerald-500" />}
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<dl className="grid grid-cols-2 gap-x-8 gap-y-4">
|
||||||
|
{fs.map(f => (
|
||||||
|
<div key={f.field_key}>
|
||||||
|
<dt className="text-[11px] text-stone-400 dark:text-zinc-500 mb-0.5">{fmtLabel(f.output_label)}</dt>
|
||||||
|
<dd className="text-[14px] font-medium text-stone-900 dark:text-zinc-100 break-words">
|
||||||
|
{fmtVal(data[f.field_key], f.data_type, f.field_key)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
)) : (
|
||||||
|
<div className="text-[13px] text-stone-300 dark:text-zinc-700 italic">
|
||||||
|
Booking and feedback details will appear here once the AI completes its call.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── AI Analysis ─────────────────────────────────────────────── */}
|
||||||
|
{groups.ai.length > 0 && (
|
||||||
|
<div className="px-7 py-5">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest text-stone-400 dark:text-zinc-600 mb-4 flex items-center gap-1.5">
|
||||||
|
<Sparkles size={10} className="text-[#C8102E]" />AI · Call Outcome
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{aiShort.length > 0 && (
|
||||||
|
<dl className="grid grid-cols-2 sm:grid-cols-3 gap-x-8 gap-y-5 mb-5">
|
||||||
|
{aiShort.map(f => {
|
||||||
|
const val = data[f.field_key];
|
||||||
|
const score = isScore(f);
|
||||||
|
const num = score ? Number(val) : null;
|
||||||
|
const pct = num !== null ? Math.min(num > 1 ? num : num * 100, 100) : 0;
|
||||||
|
const barCls = pct >= 70 ? "bg-emerald-500" : pct >= 40 ? "bg-amber-500" : "bg-red-500";
|
||||||
|
const pctCls = pct >= 70 ? "text-emerald-600 dark:text-emerald-400" : pct >= 40 ? "text-amber-600 dark:text-amber-400" : "text-red-500";
|
||||||
|
return (
|
||||||
|
<div key={f.field_key}>
|
||||||
|
<dt className="text-[11px] text-stone-400 dark:text-zinc-500 mb-1">{fmtLabel(f.output_label)}</dt>
|
||||||
|
{score && num !== null ? (
|
||||||
|
<dd className="flex items-center gap-2.5">
|
||||||
|
<div className="flex-1 h-1.5 bg-stone-100 dark:bg-zinc-800 rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full rounded-full ${barCls}`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className={`text-[12px] font-bold tabular-nums w-8 text-right ${pctCls}`}>{pct.toFixed(0)}%</span>
|
||||||
|
</dd>
|
||||||
|
) : (
|
||||||
|
<dd className="text-[14px] font-semibold text-stone-900 dark:text-zinc-100">
|
||||||
|
{fmtVal(val, f.data_type, f.field_key)}
|
||||||
|
</dd>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{aiLong.length > 0 && (
|
||||||
|
<div className="space-y-4 border-t border-stone-100 dark:border-zinc-800 pt-4">
|
||||||
|
{aiLong.map(f => {
|
||||||
|
const val = data[f.field_key];
|
||||||
|
const isClarif = f.field_key.includes("clarification") || f.field_key.includes("response");
|
||||||
|
return (
|
||||||
|
<div key={f.field_key} className="border-b border-stone-50 dark:border-zinc-800/50 pb-4 last:border-0 last:pb-0">
|
||||||
|
<div className="flex items-center gap-1.5 mb-1.5">
|
||||||
|
{isClarif && <MessageSquare size={11} className="text-amber-400 shrink-0" />}
|
||||||
|
<span className="text-[11px] font-medium text-stone-400 dark:text-zinc-500">{fmtLabel(f.output_label)}</span>
|
||||||
|
</div>
|
||||||
|
<ClampedText text={String(val ?? "—")} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Activity timeline ──────────────────────────────────────── */}
|
||||||
|
{data.instance_id && <TimelineSection instanceId={String(data.instance_id)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TimelineSection — derived from instance.data._activities (no audit endpoint
|
||||||
|
// needed). INIT is synthesised from instance.created_at since the platform
|
||||||
|
// doesn't write INIT into _activities.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ActivityRecord {
|
||||||
|
_system?: { created_at?: string; user_id?: string; user_roles?: string[] | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimelineEntry {
|
||||||
|
activityId: string;
|
||||||
|
createdAt: string;
|
||||||
|
userRoles: string[] | null;
|
||||||
|
synthetic: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FAILURE_STATE_IDS = new Set<string>([
|
||||||
|
STATE_IDS.SCHEDULING_CALL_FAILED,
|
||||||
|
STATE_IDS.FEEDBACK_CALL_FAILED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
function TimelineSection({ instanceId }: { instanceId: string }) {
|
||||||
|
const [inst, setInst] = useState<InstanceResponse | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setInst(null); setError(null);
|
||||||
|
getInstance(WORKFLOW_ID, instanceId)
|
||||||
|
.then((r) => !cancelled && setInst(r))
|
||||||
|
.catch((e: any) => !cancelled && setError(e?.message ?? "failed to load"));
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
if (error) return null;
|
||||||
|
if (inst === null) {
|
||||||
|
return (
|
||||||
|
<div className="px-7 py-5 border-t border-stone-100 dark:border-zinc-800 text-[12px] text-stone-300 dark:text-zinc-700">
|
||||||
|
Loading activity timeline…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activities = (inst.data as { _activities?: Record<string, ActivityRecord> } | null)?._activities;
|
||||||
|
const entries: TimelineEntry[] = [];
|
||||||
|
|
||||||
|
// Synthesise INIT from instance.created_at — _activities never includes it.
|
||||||
|
entries.push({
|
||||||
|
activityId: ACTIVITY_IDS.INIT_ACTIVITY,
|
||||||
|
createdAt: inst.created_at,
|
||||||
|
userRoles: null,
|
||||||
|
synthetic: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activities) {
|
||||||
|
for (const [activityId, record] of Object.entries(activities)) {
|
||||||
|
entries.push({
|
||||||
|
activityId,
|
||||||
|
createdAt: record._system?.created_at ?? inst.created_at,
|
||||||
|
userRoles: record._system?.user_roles ?? null,
|
||||||
|
synthetic: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||||
|
|
||||||
|
const lastIdx = entries.length - 1;
|
||||||
|
const inFailureState = FAILURE_STATE_IDS.has(inst.current_state_id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-7 py-5 border-t border-stone-100 dark:border-zinc-800">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest text-stone-400 dark:text-zinc-600 mb-4 flex items-center gap-1.5">
|
||||||
|
<Activity size={10} className="text-[#C8102E]" /> Activity Timeline
|
||||||
|
</p>
|
||||||
|
<ol className="relative pl-6">
|
||||||
|
<span aria-hidden className="absolute left-[7px] top-1 bottom-1 w-px bg-stone-200 dark:bg-zinc-700" />
|
||||||
|
{entries.map((e, i) => {
|
||||||
|
const isLast = i === lastIdx;
|
||||||
|
const isFirst = i === 0;
|
||||||
|
const failed = isLast && inFailureState;
|
||||||
|
const dotBg = failed ? "#dc2626" : isLast ? "#C8102E" : isFirst ? "#10b981" : "#d1d5db";
|
||||||
|
const actor = (e.userRoles && e.userRoles.length > 0) ? e.userRoles.join(", ") : "system";
|
||||||
|
return (
|
||||||
|
<li key={`${e.activityId}-${e.createdAt}`} className="relative pb-4 last:pb-0">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={`absolute -left-[22px] top-1 w-3.5 h-3.5 rounded-full border-2 border-white dark:border-zinc-900 shadow-sm ${isLast ? "ring-2 ring-rose-200 dark:ring-rose-900" : ""}`}
|
||||||
|
style={{ background: dotBg, boxShadow: isLast ? "0 0 8px rgba(200,16,46,0.5)" : undefined }}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className={`text-[13px] font-medium ${
|
||||||
|
failed
|
||||||
|
? "text-red-600 dark:text-red-400"
|
||||||
|
: isLast
|
||||||
|
? "text-[#9D0D24] dark:text-rose-300"
|
||||||
|
: "text-stone-700 dark:text-zinc-200"
|
||||||
|
}`}>
|
||||||
|
{fmtAct(e.activityId)}
|
||||||
|
</span>
|
||||||
|
{failed && (
|
||||||
|
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-300">
|
||||||
|
Failed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isLast && !failed && (
|
||||||
|
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full"
|
||||||
|
style={{ background: "rgba(200,16,46,0.10)", color: "#C8102E" }}>
|
||||||
|
Latest
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-stone-400 dark:text-zinc-500 mt-0.5">
|
||||||
|
{new Date(e.createdAt).toLocaleString("en-IN", {
|
||||||
|
day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit",
|
||||||
|
})}
|
||||||
|
<span className="mx-1.5 text-stone-200 dark:text-zinc-700">·</span>
|
||||||
|
<span className="text-stone-500 dark:text-zinc-400">{actor}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ClampedText
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ClampedText({ text }: { text: string }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [clamped, setClamped] = useState(false);
|
||||||
|
const ref = useRef<HTMLParagraphElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (el) setClamped(el.scrollHeight > el.clientHeight + 2);
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={`text-[13px] leading-relaxed text-stone-700 dark:text-zinc-200 transition-all ${expanded ? "" : "line-clamp-3"}`}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</p>
|
||||||
|
{(clamped || expanded) && (
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(e => !e)}
|
||||||
|
className="mt-1.5 flex items-center gap-1 text-[11px] font-semibold text-rose-600 dark:text-rose-400 hover:text-rose-700 dark:hover:text-rose-300 transition-colors"
|
||||||
|
>
|
||||||
|
{expanded ? <><ChevronUp size={12} /> Show less</> : <><ChevronDown size={12} /> Show more</>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function fmtLabel(label: string): string {
|
||||||
|
return label.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtVal(val: unknown, type: string, key: string): string {
|
||||||
|
if (val == null || val === "") return "—";
|
||||||
|
if (type === "phone" && typeof val === "object") {
|
||||||
|
const p = val as { phone_with_dial_code?: string; phone?: string; dial_code?: string };
|
||||||
|
return p.phone_with_dial_code || (p.dial_code && p.phone ? `${p.dial_code}${p.phone}` : p.phone || "—");
|
||||||
|
}
|
||||||
|
if (type === "number") {
|
||||||
|
const n = Number(val);
|
||||||
|
if (["amount","tax","price","cost"].some(x => key.toLowerCase().includes(x))) return fmtCurrency(n);
|
||||||
|
return n.toLocaleString("en-IN");
|
||||||
|
}
|
||||||
|
if (type === "datetime" || type === "date") return fmtDate(String(val));
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtCurrency(n: number): string {
|
||||||
|
return n.toLocaleString("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(val: string): string {
|
||||||
|
try { return new Date(val).toLocaleDateString("en-IN", { day: "2-digit", month: "short", year: "numeric" }); }
|
||||||
|
catch { return val; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPhone(val: unknown): string {
|
||||||
|
if (val == null || val === "") return "";
|
||||||
|
if (typeof val === "object") {
|
||||||
|
const p = val as { phone_with_dial_code?: string; phone?: string; dial_code?: string };
|
||||||
|
return p.phone_with_dial_code || (p.dial_code && p.phone ? `${p.dial_code}${p.phone}` : p.phone || "");
|
||||||
|
}
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
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: string | null;
|
||||||
|
onNavigate: (screenUuid: string) => void;
|
||||||
|
onSidebarChange?: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DynamicHeader({ activeScreenId, onNavigate, onSidebarChange }: Props) {
|
||||||
|
const { user, logout } = useAuthContext();
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [config, setConfig] = useState<HeaderConfig | null>(null);
|
||||||
|
const [profileOpen, setProfileOpen] = useState(false);
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getHeaderConfig("desktop").then(setConfig).catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const navItems = config?.components?.nav?.items ?? [];
|
||||||
|
const logoText = (config?.components as any)?.logo?.text || "Mahindra · AI Sales Ops";
|
||||||
|
|
||||||
|
const handleLogout = () => { logout(); navigate("/login"); };
|
||||||
|
|
||||||
|
const initials = (user?.name || "U")
|
||||||
|
.split(" ").map((n) => n[0]).join("").slice(0, 2).toUpperCase();
|
||||||
|
const roleName = user?.roles?.[0] ?? "";
|
||||||
|
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
const next = !sidebarOpen;
|
||||||
|
setSidebarOpen(next);
|
||||||
|
onSidebarChange?.(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* ── Top bar ─────────────────────────────────────────────── */}
|
||||||
|
<header className="bg-white/90 dark:bg-zinc-950/90 backdrop-blur-md border-b border-stone-200 dark:border-zinc-800/70 sticky top-0 z-50 h-14 flex items-center px-4 justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-stone-400 dark:text-zinc-500 hover:text-stone-700 dark:hover:text-zinc-200 hover:bg-stone-100 dark:hover:bg-zinc-800 transition-colors"
|
||||||
|
>
|
||||||
|
{sidebarOpen ? <PanelLeftClose size={17} /> : <PanelLeftOpen size={17} />}
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-7 h-7 rounded-lg flex items-center justify-center gradient-mh shadow-md shadow-rose-500/20">
|
||||||
|
<Car size={14} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[13px] font-bold text-stone-800 dark:text-zinc-100 tracking-tight">
|
||||||
|
{logoText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
title={theme === "dark" ? "Switch to light" : "Switch to dark"}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-stone-400 dark:text-zinc-500 hover:text-[#C8102E] dark:hover:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-950/40 transition-colors"
|
||||||
|
>
|
||||||
|
{theme === "dark" ? <Sun size={16} /> : <Moon size={16} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setProfileOpen(!profileOpen)}
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center ring-2 ring-white dark:ring-zinc-900 shadow-sm hover:ring-rose-200 dark:hover:ring-rose-800 transition-all gradient-mh"
|
||||||
|
>
|
||||||
|
<span className="text-[11px] font-bold text-white leading-none">{initials}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{profileOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setProfileOpen(false)} />
|
||||||
|
<div className="absolute right-0 top-full mt-2 w-60 bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl shadow-stone-900/10 dark:shadow-black/40 border border-stone-200 dark:border-zinc-700/70 overflow-hidden z-50">
|
||||||
|
<div className="px-4 py-3.5 border-b border-stone-100 dark:border-zinc-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full flex items-center justify-center shrink-0 gradient-mh">
|
||||||
|
<span className="text-[13px] font-bold text-white leading-none">{initials}</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[13px] font-semibold text-stone-900 dark:text-zinc-100 truncate">{user?.name}</div>
|
||||||
|
<div className="text-[11px] text-stone-400 dark:text-zinc-500 truncate">{user?.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{roleName && (
|
||||||
|
<div className="mt-2.5">
|
||||||
|
<span
|
||||||
|
className="text-[10px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded-full"
|
||||||
|
style={{ background: "rgba(200,16,46,0.10)", color: "#C8102E", border: "1px solid rgba(200,16,46,0.22)" }}
|
||||||
|
>
|
||||||
|
{roleName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="py-1">
|
||||||
|
<button className="w-full text-left px-4 py-2.5 text-[13px] text-stone-600 dark:text-zinc-300 hover:bg-stone-50 dark:hover:bg-zinc-800 flex items-center gap-2.5 transition-colors">
|
||||||
|
<Settings size={14} className="text-stone-400 dark:text-zinc-500" /> Settings
|
||||||
|
</button>
|
||||||
|
<div className="h-px bg-stone-100 dark:bg-zinc-800 mx-3 my-0.5" />
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full text-left px-4 py-2.5 text-[13px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30 flex items-center gap-2.5 transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut size={14} /> Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── Sidebar ─────────────────────────────────────────────── */}
|
||||||
|
<aside
|
||||||
|
className={`fixed top-14 left-0 bottom-0 z-40 flex flex-col transition-all duration-200 ${sidebarOpen ? "w-60" : "w-0 overflow-hidden"}`}
|
||||||
|
style={{ background: "linear-gradient(180deg, #fffbfb 0%, #fff5f5 100%)" }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col h-full dark:bg-zinc-950 border-r border-rose-100/70 dark:border-zinc-800/80">
|
||||||
|
{/* Identity strip */}
|
||||||
|
<div className="px-5 pt-5 pb-4 border-b border-rose-100/60 dark:border-zinc-800/60">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0 gradient-mh shadow-md shadow-rose-500/20">
|
||||||
|
<Car size={13} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[13px] font-bold text-stone-800 dark:text-zinc-100 leading-none">{logoText}</div>
|
||||||
|
<div className="text-[10px] text-stone-400 dark:text-zinc-600 mt-0.5">Test-drive lifecycle</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-3 py-3">
|
||||||
|
<div className="text-[10px] font-bold text-stone-400 dark:text-zinc-600 uppercase tracking-widest px-2 mb-2">
|
||||||
|
Pipeline
|
||||||
|
</div>
|
||||||
|
<nav className="space-y-0.5">
|
||||||
|
{navItems.map((item: NavItem) => {
|
||||||
|
const active = activeScreenId === item.screen_uuid;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => onNavigate(item.screen_uuid)}
|
||||||
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-[13px] font-medium transition-all group ${
|
||||||
|
active
|
||||||
|
? "bg-rose-100/70 dark:bg-rose-950/40 text-[#9D0D24] dark:text-rose-300"
|
||||||
|
: "text-stone-500 dark:text-zinc-400 hover:bg-white dark:hover:bg-zinc-800/70 hover:text-stone-900 dark:hover:text-zinc-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`flex items-center justify-center w-7 h-7 rounded-lg shrink-0 transition-colors ${
|
||||||
|
active
|
||||||
|
? "bg-rose-200/70 dark:bg-rose-900/50 text-[#C8102E] dark:text-rose-400"
|
||||||
|
: "bg-white dark:bg-zinc-800 text-stone-400 dark:text-zinc-500 shadow-sm group-hover:text-[#C8102E] dark:group-hover:text-rose-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{iconFor(item.label)}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom — user card */}
|
||||||
|
<div className="px-3 py-3 border-t border-rose-100/60 dark:border-zinc-800/60">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2.5 px-2 py-2 rounded-xl hover:bg-white dark:hover:bg-zinc-800/70 transition-colors cursor-pointer group"
|
||||||
|
onClick={handleLogout}
|
||||||
|
title="Sign out"
|
||||||
|
>
|
||||||
|
<div className="w-7 h-7 rounded-full flex items-center justify-center shrink-0 text-[11px] font-bold text-white gradient-mh">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-[12px] font-semibold text-stone-700 dark:text-zinc-200 truncate leading-none">{user?.name}</div>
|
||||||
|
<div className="text-[10px] text-stone-400 dark:text-zinc-500 truncate mt-0.5">{roleName}</div>
|
||||||
|
</div>
|
||||||
|
<LogOut size={13} className="text-stone-300 dark:text-zinc-600 group-hover:text-[#C8102E] transition-colors shrink-0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
329
src/components/FormModal.tsx
Normal file
329
src/components/FormModal.tsx
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
import { X, ArrowRight, AlertCircle, CheckCircle2, ChevronDown } from "lucide-react";
|
||||||
|
import { getFormScreen, startWorkflow, performActivity } from "../api/viewService";
|
||||||
|
import { WORKFLOW_ID } from "../config";
|
||||||
|
|
||||||
|
// TM brand tokens — mirror VariantB.tsx. Inline to avoid a one-off tokens
|
||||||
|
// module; if a third consumer appears (Login likely), extract then.
|
||||||
|
const ACCENT = "#e31837";
|
||||||
|
const ACCENT_DARK = "#5f0229";
|
||||||
|
const ACCENT_SOFT = "#fde2e8";
|
||||||
|
const TM_BORDER = "#e4e4ed";
|
||||||
|
const INK = "#17181a";
|
||||||
|
|
||||||
|
interface FormField {
|
||||||
|
id: string;
|
||||||
|
uid: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
data_type: string;
|
||||||
|
mandatory: boolean;
|
||||||
|
value?: any;
|
||||||
|
properties?: {
|
||||||
|
options?: Array<{ label: string; value: string }>;
|
||||||
|
country_code?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
interface GridItem { i: string; x: number; y: number; w: number; h: number; }
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
activityId: string;
|
||||||
|
instanceId?: string;
|
||||||
|
title?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FormModal({ activityId, instanceId, title, onClose, onSuccess }: Props) {
|
||||||
|
const [fields, setFields] = useState<FormField[]>([]);
|
||||||
|
const [gridConfig, setGridConfig] = useState<GridItem[]>([]);
|
||||||
|
const [formTitle, setFormTitle] = useState(title || "");
|
||||||
|
const [formData, setFormData] = useState<Record<string, string>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [touched, setTouched] = useState<Set<string>>(new Set());
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => { requestAnimationFrame(() => setVisible(true)); }, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getFormScreen(activityId, instanceId)
|
||||||
|
.then((res: any) => {
|
||||||
|
const raw: FormField[] = res.fields ?? res.form_json?.fields ?? [];
|
||||||
|
const f = raw
|
||||||
|
.filter((field) => field.data_type !== "lookup")
|
||||||
|
.map((field) => ({ ...field, mandatory: false }));
|
||||||
|
setFields(f);
|
||||||
|
setGridConfig(res.grid_config ?? []);
|
||||||
|
setFormTitle(res.activity_name || title || "Form");
|
||||||
|
const pre: Record<string, string> = {};
|
||||||
|
f.forEach((field) => { if (field.value != null) pre[field.id] = String(field.value); });
|
||||||
|
setFormData(pre);
|
||||||
|
})
|
||||||
|
.catch((err: any) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [activityId, instanceId, title]);
|
||||||
|
|
||||||
|
const animateClose = (callback: () => void) => {
|
||||||
|
setVisible(false);
|
||||||
|
setTimeout(callback, 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setTouched(new Set(fields.map((f) => f.id)));
|
||||||
|
|
||||||
|
const missing = fields.filter((f) => f.mandatory && !formData[f.id]?.trim());
|
||||||
|
if (missing.length > 0) {
|
||||||
|
setError(`Please fill in: ${missing.map((f) => f.name).join(", ")}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true); setError(null);
|
||||||
|
try {
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
for (const f of fields) {
|
||||||
|
const v = formData[f.id] ?? "";
|
||||||
|
if (f.data_type === "number") {
|
||||||
|
data[f.id] = v ? Number(v) : 0;
|
||||||
|
} else if (f.data_type === "phone") {
|
||||||
|
const dial = f.properties?.country_code ?? "";
|
||||||
|
const digits = v.replace(/\D/g, "");
|
||||||
|
data[f.id] = digits
|
||||||
|
? { dial_code: dial, phone: digits, phone_with_dial_code: `${dial}${digits}` }
|
||||||
|
: "";
|
||||||
|
} else {
|
||||||
|
data[f.id] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
instanceId ? await performActivity(WORKFLOW_ID, instanceId, activityId, data) : await startWorkflow(WORKFLOW_ID, activityId, data);
|
||||||
|
setSuccess(true);
|
||||||
|
setTimeout(() => animateClose(onSuccess), 800);
|
||||||
|
} catch (err: any) { setError(err.message || "Failed"); }
|
||||||
|
finally { setSubmitting(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const sorted = [...fields].sort((a, b) => {
|
||||||
|
const ga = gridConfig.find((g) => g.i === a.uid), gb = gridConfig.find((g) => g.i === b.uid);
|
||||||
|
if (!ga || !gb) return 0;
|
||||||
|
return ga.y !== gb.y ? ga.y - gb.y : ga.x - gb.x;
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows: FormField[][] = []; let cy = -1;
|
||||||
|
for (const f of sorted) { const g = gridConfig.find((gi) => gi.i === f.uid); const y = g?.y ?? rows.length; if (y !== cy) { rows.push([]); cy = y; } rows[rows.length - 1].push(f); }
|
||||||
|
|
||||||
|
const isMissing = (f: FormField) => f.mandatory && touched.has(f.id) && !formData[f.id]?.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={() => animateClose(onClose)}>
|
||||||
|
<div className={`absolute inset-0 bg-stone-900/40 transition-opacity duration-200 ${visible ? "opacity-100" : "opacity-0"}`} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
className={`relative bg-white rounded-2xl w-full max-w-xl mx-4 max-h-[90vh] flex flex-col transition-all duration-200 ${visible ? "opacity-100 scale-100 translate-y-0" : "opacity-0 scale-95 translate-y-2"}`}
|
||||||
|
style={{ border: `1px solid ${TM_BORDER}`, boxShadow: "0 24px 60px -20px rgba(6, 31, 92, 0.25)" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{success && (
|
||||||
|
<div className="absolute inset-0 z-10 bg-white/97 flex flex-col items-center justify-center gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-xl flex items-center justify-center" style={{ background: ACCENT_SOFT }}>
|
||||||
|
<CheckCircle2 size={24} style={{ color: ACCENT }} />
|
||||||
|
</div>
|
||||||
|
<div className="text-[14px] font-bold tracking-tight" style={{ color: INK }}>Submitted</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div className="flex items-start justify-between px-7 py-5" style={{ borderBottom: `1px solid ${TM_BORDER}` }}>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.15em] mb-1.5" style={{ color: ACCENT }}>
|
||||||
|
{instanceId ? "Update" : "Create"}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-[20px] font-bold tracking-tight leading-tight truncate" style={{ color: INK }}>
|
||||||
|
{instanceId ? formTitle : "New Lead"}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => animateClose(onClose)}
|
||||||
|
className="w-8 h-8 flex items-center justify-center text-stone-400 hover:text-stone-700 transition-colors shrink-0 -mr-2"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Body ── */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-7 py-6">
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{[0, 1, 2, 3].map((r) => (
|
||||||
|
<div key={r} className="grid grid-cols-2 gap-4">
|
||||||
|
<div><div className="h-3 w-20 bg-stone-100 animate-pulse mb-2" /><div className="h-10 w-full bg-stone-100 animate-pulse" /></div>
|
||||||
|
<div><div className="h-3 w-24 bg-stone-100 animate-pulse mb-2" /><div className="h-10 w-full bg-stone-100 animate-pulse" /></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form id="act-form" onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
{rows.map((row, ri) => (
|
||||||
|
<div key={ri} className={`grid gap-4 ${row.length > 1 ? "grid-cols-2" : "grid-cols-1"}`}>
|
||||||
|
{row.map((f) => (
|
||||||
|
<div key={f.id}>
|
||||||
|
<label className="block text-[11px] font-semibold uppercase tracking-[0.03em] text-stone-500 mb-2">
|
||||||
|
{f.name}
|
||||||
|
{f.mandatory && <span className="ml-1" style={{ color: ACCENT }}>*</span>}
|
||||||
|
</label>
|
||||||
|
{renderInput(f, formData[f.id] ?? "", (v) => {
|
||||||
|
setFormData((p) => ({ ...p, [f.id]: v }));
|
||||||
|
setTouched((t) => new Set(t).add(f.id));
|
||||||
|
if (error) setError(null);
|
||||||
|
}, isMissing(f))}
|
||||||
|
{isMissing(f) && (
|
||||||
|
<p className="text-[11px] mt-1.5 flex items-center gap-1" style={{ color: ACCENT }}>
|
||||||
|
<AlertCircle size={11} /> Required
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="mt-5 flex items-start gap-2.5 text-[13px] px-4 py-3"
|
||||||
|
style={{ background: ACCENT_SOFT, border: `1px solid #fbb4be`, color: ACCENT_DARK }}
|
||||||
|
>
|
||||||
|
<AlertCircle size={15} className="shrink-0 mt-0.5" style={{ color: ACCENT }} />
|
||||||
|
<span className="font-medium">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Footer ── */}
|
||||||
|
{!loading && (
|
||||||
|
<div className="px-7 py-4 flex items-center justify-between" style={{ borderTop: `1px solid ${TM_BORDER}` }}>
|
||||||
|
<div className="text-[11px] uppercase tracking-[0.05em] text-stone-400 font-semibold">
|
||||||
|
<span style={{ color: ACCENT }}>*</span> Required
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => animateClose(onClose)}
|
||||||
|
className="h-9 px-4 text-[13px] font-semibold transition-colors"
|
||||||
|
style={{ color: INK, border: `1px solid ${TM_BORDER}`, borderRadius: 4, background: "white" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
form="act-form"
|
||||||
|
disabled={submitting}
|
||||||
|
className="h-9 px-5 text-[13px] font-semibold text-white disabled:opacity-60 inline-flex items-center gap-2 transition-colors hover:brightness-110"
|
||||||
|
style={{ background: ACCENT, borderRadius: 4 }}
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<><div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Submitting…</>
|
||||||
|
) : (
|
||||||
|
<>{instanceId ? "Update" : "Submit"} <ArrowRight size={14} /></>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Input renderer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderInput(f: FormField, value: string, onChange: (v: string) => void, hasError: boolean) {
|
||||||
|
const base = `w-full h-10 px-3 text-[13.5px] bg-white text-stone-900 focus:outline-none transition placeholder:text-stone-400`;
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
border: `1px solid ${hasError ? ACCENT : TM_BORDER}`,
|
||||||
|
borderRadius: 4,
|
||||||
|
};
|
||||||
|
const onFocus = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||||
|
e.currentTarget.style.borderColor = hasError ? ACCENT : ACCENT;
|
||||||
|
e.currentTarget.style.boxShadow = `0 0 0 3px ${ACCENT_SOFT}`;
|
||||||
|
};
|
||||||
|
const onBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||||
|
e.currentTarget.style.borderColor = hasError ? ACCENT : TM_BORDER;
|
||||||
|
e.currentTarget.style.boxShadow = "none";
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = f.properties?.options;
|
||||||
|
if (options && options.length > 0) {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
|
className={`${base} appearance-none pr-9 cursor-pointer`}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<option value="">Select {f.name.toLowerCase()}</option>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (f.data_type) {
|
||||||
|
case "longtext":
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
|
rows={3}
|
||||||
|
className={`${base} h-auto py-2.5`}
|
||||||
|
style={style}
|
||||||
|
placeholder={`Enter ${f.name.toLowerCase()}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "number":
|
||||||
|
return <input type="number" step="any" value={value} onChange={(e) => onChange(e.target.value)} onFocus={onFocus} onBlur={onBlur} className={base} style={style} placeholder="0" />;
|
||||||
|
case "date":
|
||||||
|
return <input type="date" value={value} onChange={(e) => onChange(e.target.value)} onFocus={onFocus} onBlur={onBlur} className={base} style={style} />;
|
||||||
|
case "email":
|
||||||
|
return <input type="email" value={value} onChange={(e) => onChange(e.target.value)} onFocus={onFocus} onBlur={onBlur} className={base} style={style} placeholder={`Enter ${f.name.toLowerCase()}`} />;
|
||||||
|
case "phone": {
|
||||||
|
const cc = f.properties?.country_code;
|
||||||
|
if (!cc) {
|
||||||
|
return <input type="tel" value={value} onChange={(e) => onChange(e.target.value)} onFocus={onFocus} onBlur={onBlur} className={base} style={style} placeholder={`Enter ${f.name.toLowerCase()}`} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-[13px] font-semibold text-stone-500 pointer-events-none select-none">
|
||||||
|
{cc}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
|
className={`${base} pl-12`}
|
||||||
|
style={style}
|
||||||
|
placeholder="Phone number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return <input type="text" value={value} onChange={(e) => onChange(e.target.value)} onFocus={onFocus} onBlur={onBlur} className={base} style={style} placeholder={`Enter ${f.name.toLowerCase()}`} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/components/PieChart.tsx
Normal file
53
src/components/PieChart.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { PieChart as RPieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from "recharts";
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
dimension: unknown;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title?: string;
|
||||||
|
rows: Row[];
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RED_PALETTE = ["#C8102E", "#E84258", "#F26471", "#F69199", "#FABFBF", "#FFD7D7"];
|
||||||
|
|
||||||
|
export default function PieChart({ title, rows, loading }: Props) {
|
||||||
|
const data = (rows ?? [])
|
||||||
|
.map(r => ({ name: String(r.dimension ?? "—"), value: Number(r.value ?? 0) }))
|
||||||
|
.filter(d => d.name && d.name !== "—" && d.value > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 min-w-[280px] bg-white dark:bg-zinc-900 rounded-2xl border border-stone-200/80 dark:border-zinc-700/60 px-5 py-4 shadow-sm">
|
||||||
|
{title && (
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-widest text-stone-400 dark:text-zinc-500 mb-3">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-[220px] bg-stone-100 dark:bg-zinc-800 rounded-full animate-pulse" />
|
||||||
|
) : data.length === 0 ? (
|
||||||
|
<div className="text-[12px] text-stone-400 dark:text-zinc-500 py-4 text-center">No data</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ width: "100%", height: 220 }}>
|
||||||
|
<ResponsiveContainer>
|
||||||
|
<RPieChart>
|
||||||
|
<Pie data={data} dataKey="value" nameKey="name" innerRadius={45} outerRadius={75} paddingAngle={2}>
|
||||||
|
{data.map((_, i) => <Cell key={i} fill={RED_PALETTE[i % RED_PALETTE.length]} />)}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip contentStyle={{ fontSize: 12, borderRadius: 8 }} />
|
||||||
|
<Legend
|
||||||
|
verticalAlign="middle"
|
||||||
|
align="right"
|
||||||
|
layout="vertical"
|
||||||
|
iconType="circle"
|
||||||
|
wrapperStyle={{ fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
</RPieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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}</>;
|
||||||
|
}
|
||||||
713
src/components/RecordViewTable.tsx
Normal file
713
src/components/RecordViewTable.tsx
Normal file
@ -0,0 +1,713 @@
|
|||||||
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
ChevronLeft, ChevronRight, Search, ArrowUp, ArrowDown,
|
||||||
|
X, Inbox, ArrowRight, RefreshCw, CheckCircle2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { getRVScreen, getRecordView, type RecordViewField, type SearchQuery, type TileValue, type ChartDataResponse } from "../api/viewService";
|
||||||
|
import FormModal from "./FormModal";
|
||||||
|
import Tile from "./Tile";
|
||||||
|
import BarChart from "./BarChart";
|
||||||
|
import PieChart from "./PieChart";
|
||||||
|
|
||||||
|
// Per-column max width (px). Long-text columns get the widest cap; the rest
|
||||||
|
// stay tight so a single overflowing summary can't push the whole table wide.
|
||||||
|
function colMaxWidth(dataType: string, fieldKey: string): number {
|
||||||
|
const k = fieldKey.toLowerCase();
|
||||||
|
if (dataType === "longtext") return 360;
|
||||||
|
if (k.includes("summary") || k.includes("reason") || k.includes("transcript") || k.includes("recording")) return 320;
|
||||||
|
if (k === "instance_id" || k.endsWith("_id")) return 110;
|
||||||
|
if (dataType === "number") return 130;
|
||||||
|
if (dataType === "date" || dataType === "datetime") return 140;
|
||||||
|
if (k.includes("email")) return 220;
|
||||||
|
if (k.includes("phone")) return 160;
|
||||||
|
return 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Action column (config-driven from rv-screen layout)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ActionCondition {
|
||||||
|
field_key: string;
|
||||||
|
operator: string;
|
||||||
|
value: unknown;
|
||||||
|
data_type?: string;
|
||||||
|
}
|
||||||
|
interface ActionConditions {
|
||||||
|
logic?: "AND" | "OR";
|
||||||
|
conditions: ActionCondition[];
|
||||||
|
}
|
||||||
|
interface ActionButton {
|
||||||
|
name: string;
|
||||||
|
icon?: string;
|
||||||
|
classes?: string[];
|
||||||
|
job_template?: string;
|
||||||
|
params?: { activity_uid?: string; workflow_uuid?: string; [k: string]: unknown };
|
||||||
|
show_conditions?: ActionConditions | null;
|
||||||
|
action_end?: Array<{ type: string }>;
|
||||||
|
}
|
||||||
|
interface ActionColumn {
|
||||||
|
id: string;
|
||||||
|
display: string;
|
||||||
|
buttons: ActionButton[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICONS: Record<string, React.ReactNode> = {
|
||||||
|
refresh: <RefreshCw size={13} />,
|
||||||
|
task_alt: <CheckCircle2 size={13} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
function iconFor(name?: string): React.ReactNode {
|
||||||
|
return (name && ICONS[name]) || <RefreshCw size={13} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walks the rv-screen layout to find columns with `type: "custom"` and their
|
||||||
|
// inner button configs.
|
||||||
|
function extractActionColumns(layout: any): ActionColumn[] {
|
||||||
|
const out: ActionColumn[] = [];
|
||||||
|
const walk = (nodes: any[]): void => {
|
||||||
|
for (const n of nodes ?? []) {
|
||||||
|
const cfg = n?.config;
|
||||||
|
if (cfg?.type === "rv_table" && Array.isArray(cfg.columns)) {
|
||||||
|
for (const col of cfg.columns) {
|
||||||
|
if (col?.type === "custom" && Array.isArray(col.config)) {
|
||||||
|
const buttons = col.config.filter((b: any) => b?.type === "button");
|
||||||
|
if (buttons.length > 0) {
|
||||||
|
out.push({ id: col.id, display: col.display || "Actions", buttons });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(n?.children)) walk(n.children);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(Array.isArray(layout) ? layout : []);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evalShowConditions(c: ActionConditions | null | undefined, row: Record<string, unknown>): boolean {
|
||||||
|
if (!c || !Array.isArray(c.conditions) || c.conditions.length === 0) return true;
|
||||||
|
const results = c.conditions.map((cond) => {
|
||||||
|
const v = row[cond.field_key];
|
||||||
|
switch (cond.operator) {
|
||||||
|
case "equals": return v === cond.value;
|
||||||
|
case "not_equals": return v !== cond.value;
|
||||||
|
case "is_empty": return v == null || v === "";
|
||||||
|
case "is_not_empty": return v != null && v !== "";
|
||||||
|
case "in": return Array.isArray(cond.value) && cond.value.includes(v as never);
|
||||||
|
case "not_in": return Array.isArray(cond.value) && !cond.value.includes(v as never);
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return (c.logic === "OR") ? results.some(Boolean) : results.every(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buttonClass(classes?: string[]): string {
|
||||||
|
const ghost = classes?.includes("z-btn-secondary");
|
||||||
|
const base = "h-7 px-2.5 text-[11px] font-semibold rounded-lg inline-flex items-center gap-1 transition-all";
|
||||||
|
return ghost
|
||||||
|
? `${base} text-stone-600 dark:text-zinc-300 bg-white dark:bg-zinc-800 border border-stone-200 dark:border-zinc-700 hover:bg-stone-50 dark:hover:bg-zinc-700`
|
||||||
|
: `${base} text-white mh-glow hover:scale-[1.02]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Skeleton
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function TableSkeleton({ rows, cols }: { rows: number; cols: number }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-stone-200/80 dark:border-zinc-700/60 bg-white dark:bg-zinc-900 overflow-hidden shadow-sm">
|
||||||
|
<div className="px-5 py-3 border-b border-stone-100 dark:border-zinc-800 flex items-center gap-3">
|
||||||
|
<div className="h-5 w-16 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse" />
|
||||||
|
<div className="ml-auto h-8 w-44 bg-stone-100 dark:bg-zinc-800 rounded-lg animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-stone-50/80 dark:bg-zinc-800/40 border-b border-stone-200/80 dark:border-zinc-700/80">
|
||||||
|
{Array.from({ length: cols }).map((_, i) => (
|
||||||
|
<th key={i} className={`py-2.5 ${i === 0 ? "pl-5" : "px-4"}`}>
|
||||||
|
<div className="h-3 bg-stone-200 dark:bg-zinc-700 rounded animate-pulse w-20" />
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-stone-100/80 dark:divide-zinc-800/60">
|
||||||
|
{Array.from({ length: rows }).map((_, r) => (
|
||||||
|
<tr key={r}>
|
||||||
|
{Array.from({ length: cols }).map((_, c) => (
|
||||||
|
<td key={c} className={`py-3.5 ${c === 0 ? "pl-5 pr-4" : "px-4"}`}>
|
||||||
|
<div className="h-3 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse" style={{ width: `${60 + Math.random() * 40}%` }} />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// Either the numeric source_screen_id or the source_uuid; the view-service
|
||||||
|
// accepts both at /app/:appId/rv-screens/:idOrUuid.
|
||||||
|
viewId: number | string;
|
||||||
|
onRowClick?: (instanceId: string) => void;
|
||||||
|
toolbarAction?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnalyticsSpec =
|
||||||
|
| { kind: "tile"; uid: string; title?: string }
|
||||||
|
| { kind: "chart"; uid: string; title?: string; chartType?: string };
|
||||||
|
|
||||||
|
// Walk rv-screen layout to find rv_tile / rv_chart elements (the platform
|
||||||
|
// places them as sibling blocks above the rv_table).
|
||||||
|
function extractAnalyticsElements(layout: any): AnalyticsSpec[] {
|
||||||
|
const out: AnalyticsSpec[] = [];
|
||||||
|
const walk = (nodes: any[]): void => {
|
||||||
|
for (const n of nodes ?? []) {
|
||||||
|
const cfg = n?.config;
|
||||||
|
if (cfg?.type === "rv_tile" && cfg.tile_uid) out.push({ kind: "tile", uid: cfg.tile_uid, title: cfg.title });
|
||||||
|
if (cfg?.type === "rv_chart" && cfg.chart_uid) out.push({ kind: "chart", uid: cfg.chart_uid, title: cfg.title, chartType: cfg.chart_type });
|
||||||
|
if (Array.isArray(n?.children)) walk(n.children);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(Array.isArray(layout) ? layout : []);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walks the rv-screen layout to find the rv_table element and returns a
|
||||||
|
// key_name → display map for column header labels (the recordview response's
|
||||||
|
// output_label is empty for v2 envelope screens).
|
||||||
|
function extractColumnLabels(layout: any): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
const walk = (nodes: any[]): void => {
|
||||||
|
for (const n of nodes ?? []) {
|
||||||
|
const cfg = n?.config;
|
||||||
|
if (cfg?.type === "rv_table" && Array.isArray(cfg.columns)) {
|
||||||
|
for (const c of cfg.columns) {
|
||||||
|
if (c?.key_name && c?.display) out[c.key_name] = c.display;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(n?.children)) walk(n.children);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(Array.isArray(layout) ? layout : []);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: Props) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [rvTemplateUid, setRvTemplateUid] = useState<string | null>(null);
|
||||||
|
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
|
||||||
|
const [actionColumns, setActionColumns] = useState<ActionColumn[]>([]);
|
||||||
|
const [analyticsSpecs, setAnalyticsSpecs] = useState<AnalyticsSpec[]>([]);
|
||||||
|
const [tileValues, setTileValues] = useState<TileValue[]>([]);
|
||||||
|
const [chartData, setChartData] = useState<ChartDataResponse[]>([]);
|
||||||
|
const [fields, setFields] = useState<RecordViewField[]>([]);
|
||||||
|
const [rows, setRows] = useState<Record<string, unknown>[]>([]);
|
||||||
|
const [pagination, setPagination] = useState({ page: 1, limit: 10, total_count: 0, total_pages: 0 });
|
||||||
|
const [query, setQuery] = useState<SearchQuery>({ page: 1, limit: 10, sort_dir: "desc" });
|
||||||
|
const [searchInput, setSearchInput] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [dataLoading, setDataLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [formModal, setFormModal] = useState<{ activityId: string; instanceId: string; title: string } | null>(null);
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setRvTemplateUid(null);
|
||||||
|
setColumnLabels({});
|
||||||
|
setActionColumns([]);
|
||||||
|
setAnalyticsSpecs([]);
|
||||||
|
setTileValues([]);
|
||||||
|
setChartData([]);
|
||||||
|
getRVScreen(viewId)
|
||||||
|
.then((rv) => {
|
||||||
|
setColumnLabels(extractColumnLabels(rv.layout));
|
||||||
|
setActionColumns(extractActionColumns(rv.layout));
|
||||||
|
setAnalyticsSpecs(extractAnalyticsElements(rv.layout));
|
||||||
|
setRvTemplateUid(rv.rv_template_uid);
|
||||||
|
})
|
||||||
|
.catch((err) => { setError(err.message); setLoading(false); });
|
||||||
|
}, [viewId]);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
if (!rvTemplateUid) return;
|
||||||
|
if (!loading) setDataLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await getRecordView(rvTemplateUid, viewId, query);
|
||||||
|
setTileValues(res.tile_values ?? []);
|
||||||
|
setChartData(res.chart_data ?? []);
|
||||||
|
setFields(res.config.fields.filter((f) => f.field_key !== ""));
|
||||||
|
const dataRows = Array.isArray(res.data) ? res.data : [];
|
||||||
|
setRows(dataRows);
|
||||||
|
if (res.pagination) {
|
||||||
|
setPagination(res.pagination);
|
||||||
|
} else {
|
||||||
|
setPagination((p) => ({ ...p, total_count: dataRows.length, total_pages: Math.ceil(dataRows.length / p.limit) || 1 }));
|
||||||
|
}
|
||||||
|
} catch (err: any) { setError(err.message); }
|
||||||
|
finally { setLoading(false); setDataLoading(false); }
|
||||||
|
}, [rvTemplateUid, viewId, query]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
|
|
||||||
|
const handleSort = (key: string) => setQuery((q) => ({
|
||||||
|
...q, page: 1, sort_by: key,
|
||||||
|
sort_dir: q.sort_by === key && q.sort_dir === "asc" ? "desc" : "asc",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setQuery((q) => ({ ...q, page: 1, search: searchInput }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSearch = () => {
|
||||||
|
setSearchInput("");
|
||||||
|
setQuery((q) => ({ ...q, page: 1, search: "" }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePage = (p: number) => setQuery((q) => ({ ...q, page: p }));
|
||||||
|
|
||||||
|
const handleRowClick = (row: Record<string, unknown>) => {
|
||||||
|
const id = String(row.instance_id ?? row._pk_value ?? "");
|
||||||
|
if (!id) return;
|
||||||
|
onRowClick ? onRowClick(id) : navigate(`/detail/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tileSpecs = analyticsSpecs.filter(a => a.kind === "tile");
|
||||||
|
const chartSpecs = analyticsSpecs.filter(a => a.kind === "chart");
|
||||||
|
const analyticsRow = analyticsSpecs.length > 0 ? (
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
{tileSpecs.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-stretch gap-3">
|
||||||
|
{tileSpecs.map((a, i) => {
|
||||||
|
const tv = tileValues.find(t => t.tile_uid === a.uid);
|
||||||
|
return <Tile key={`tile-${i}`} title={a.title} value={tv?.value as number | undefined} loading={loading || dataLoading} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{chartSpecs.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-stretch gap-3">
|
||||||
|
{chartSpecs.map((a, i) => {
|
||||||
|
const cd = chartData.find(c => c.chart_uid === a.uid);
|
||||||
|
const Chart = (a as { chartType?: string }).chartType === "pie" ? PieChart : BarChart;
|
||||||
|
return <Chart key={`chart-${i}`} title={a.title} rows={cd?.rows ?? []} loading={loading || dataLoading} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
if (loading) return <>{analyticsRow}<TableSkeleton rows={6} cols={fields.length || 6} /></>;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{analyticsRow}
|
||||||
|
<div className="rounded-2xl border border-red-100 dark:border-red-900/50 bg-red-50/50 dark:bg-red-950/20 p-10 text-center">
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400 mb-2">{error}</p>
|
||||||
|
<button onClick={fetchData} className="text-xs text-rose-600 dark:text-rose-400 hover:text-rose-800 font-semibold">Try again</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryFieldKey = fields.find(
|
||||||
|
(f) => f.field_key !== "current_state_name" && f.field_key !== "instance_id" && f.data_type === "text"
|
||||||
|
)?.field_key;
|
||||||
|
|
||||||
|
const { page, total_count, total_pages, limit } = pagination;
|
||||||
|
const from = total_count > 0 ? (page - 1) * limit + 1 : 0;
|
||||||
|
const to = Math.min(page * limit, total_count);
|
||||||
|
const pages = buildPageRange(page, total_pages);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{analyticsRow}
|
||||||
|
<div className="rounded-2xl border border-stone-200/80 dark:border-zinc-700/60 bg-white dark:bg-zinc-900 overflow-hidden shadow-sm dark:shadow-black/20">
|
||||||
|
|
||||||
|
{/* ── Toolbar ───────────────────────────────────────────────────────── */}
|
||||||
|
<div className="px-5 py-3 flex items-center justify-between gap-4 border-b border-stone-100 dark:border-zinc-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[15px] font-bold text-stone-800 dark:text-zinc-100 tabular-nums leading-none">{total_count}</span>
|
||||||
|
<span className="text-[13px] text-stone-400 dark:text-zinc-500 leading-none">records</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{query.search && (
|
||||||
|
<div className="flex items-center gap-1.5 pl-3 border-l border-stone-200 dark:border-zinc-700">
|
||||||
|
<span className="text-[11px] text-stone-400 dark:text-zinc-500">Filtered:</span>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[11px] font-semibold border"
|
||||||
|
style={{ background: "rgba(200,16,46,0.08)", borderColor: "rgba(200,16,46,0.22)", color: "#C8102E" }}
|
||||||
|
>
|
||||||
|
{query.search}
|
||||||
|
<button onClick={clearSearch} className="ml-0.5 hover:opacity-60"><X size={9} /></button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{toolbarAction && (
|
||||||
|
<div className="pl-3 border-l border-stone-200 dark:border-zinc-700">
|
||||||
|
{toolbarAction}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={fetchData}
|
||||||
|
disabled={dataLoading}
|
||||||
|
title="Refresh"
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-stone-400 dark:text-zinc-500 hover:text-stone-600 dark:hover:text-zinc-300 hover:bg-stone-100 dark:hover:bg-zinc-800 transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} className={dataLoading ? "animate-spin" : ""} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<form onSubmit={handleSearch}>
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={13} className="absolute left-2.5 top-1/2 -transtone-y-1/2 text-stone-400 dark:text-zinc-500 pointer-events-none" />
|
||||||
|
<input
|
||||||
|
ref={searchRef}
|
||||||
|
type="text"
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
placeholder="Search…"
|
||||||
|
className="w-44 h-8 pl-8 pr-7 text-[13px] border border-stone-200 dark:border-zinc-700 rounded-lg bg-stone-50 dark:bg-zinc-800 text-stone-800 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-rose-500/20 focus:border-rose-400 dark:focus:border-rose-500 focus:bg-white dark:focus:bg-zinc-800 transition placeholder:text-stone-400 dark:placeholder:text-zinc-600"
|
||||||
|
/>
|
||||||
|
{searchInput && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearSearch}
|
||||||
|
className="absolute right-2 top-1/2 -transtone-y-1/2 text-stone-300 dark:text-zinc-600 hover:text-stone-500"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Table ─────────────────────────────────────────────────────────── */}
|
||||||
|
<div className="overflow-x-auto relative">
|
||||||
|
{dataLoading && (
|
||||||
|
<div className="absolute inset-0 bg-white/60 dark:bg-zinc-900/60 z-10 flex items-center justify-center backdrop-blur-[1px]">
|
||||||
|
<div className="w-5 h-5 border-2 border-rose-200 dark:border-rose-900 border-t-rose-600 rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<table className="w-full" style={{ tableLayout: "fixed" }}>
|
||||||
|
<colgroup>
|
||||||
|
{fields.map((f) => (
|
||||||
|
<col key={f.field_key} style={{ width: colMaxWidth(f.data_type, f.field_key) }} />
|
||||||
|
))}
|
||||||
|
{actionColumns.map((ac) => (
|
||||||
|
<col key={ac.id} style={{ width: 160 }} />
|
||||||
|
))}
|
||||||
|
<col style={{ width: 56 }} />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-stone-50/80 dark:bg-zinc-800/40 border-b border-stone-200/80 dark:border-zinc-700/80">
|
||||||
|
{fields.map((f, i) => {
|
||||||
|
const isSorted = query.sort_by === f.field_key;
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={f.field_key}
|
||||||
|
onClick={() => handleSort(f.field_key)}
|
||||||
|
style={{ maxWidth: colMaxWidth(f.data_type, f.field_key) }}
|
||||||
|
className={`py-2.5 text-left text-[12px] font-semibold cursor-pointer select-none whitespace-nowrap transition-colors group/th
|
||||||
|
${i === 0 ? "pl-5 pr-4" : "px-4"}
|
||||||
|
${isSorted
|
||||||
|
? "text-rose-600 dark:text-rose-400"
|
||||||
|
: "text-stone-500 dark:text-zinc-400 hover:text-stone-700 dark:hover:text-zinc-200"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{columnLabels[f.field_key] || f.output_label || f.field_key}
|
||||||
|
{isSorted
|
||||||
|
? (query.sort_dir === "asc"
|
||||||
|
? <ArrowUp size={11} className="text-rose-500" />
|
||||||
|
: <ArrowDown size={11} className="text-rose-500" />)
|
||||||
|
: <ArrowDown size={10} className="text-stone-300 dark:text-zinc-700 opacity-0 group-hover/th:opacity-100 transition-opacity" />
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{actionColumns.map((ac) => (
|
||||||
|
<th key={ac.id} className="py-2.5 px-4 text-left text-[12px] font-semibold text-stone-500 dark:text-zinc-400 whitespace-nowrap">
|
||||||
|
{ac.display}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th className="py-2.5 pr-4 w-16" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody className="divide-y divide-stone-100/80 dark:divide-zinc-800/60">
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={fields.length + 1 + actionColumns.length} className="py-20 text-center">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="w-14 h-14 rounded-2xl flex items-center justify-center"
|
||||||
|
style={{ background: "linear-gradient(135deg,#fff5f5,#ffe4e6)" }}>
|
||||||
|
<Inbox size={22} className="text-rose-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[14px] font-semibold text-stone-600 dark:text-zinc-300">No records found</div>
|
||||||
|
<div className="text-[12px] text-stone-400 dark:text-zinc-600 mt-1">
|
||||||
|
{query.search ? "Try a different search term" : "No data available"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{query.search && (
|
||||||
|
<button onClick={clearSearch} className="text-[12px] font-semibold text-rose-600 dark:text-rose-400 hover:text-rose-800 transition-colors">
|
||||||
|
Clear search
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : rows.map((row, i) => (
|
||||||
|
<tr
|
||||||
|
key={String(row.instance_id ?? i)}
|
||||||
|
onClick={() => handleRowClick(row)}
|
||||||
|
className="group/row cursor-pointer hover:bg-stone-50/80 dark:hover:bg-zinc-800/40 transition-colors"
|
||||||
|
>
|
||||||
|
{fields.map((f, fi) => {
|
||||||
|
const isPrimary = f.field_key === primaryFieldKey;
|
||||||
|
const isFirst = fi === 0;
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={f.field_key}
|
||||||
|
className={`py-3.5 text-[13px]
|
||||||
|
${isFirst
|
||||||
|
? "pl-5 pr-4 border-l-2 border-l-transparent group-hover/row:border-l-rose-500 transition-colors"
|
||||||
|
: "px-4"
|
||||||
|
}
|
||||||
|
${isPrimary
|
||||||
|
? "font-semibold text-stone-800 dark:text-zinc-100"
|
||||||
|
: "text-stone-500 dark:text-zinc-400"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="truncate"
|
||||||
|
style={{ maxWidth: colMaxWidth(f.data_type, f.field_key) }}
|
||||||
|
title={typeof row[f.field_key] === "string" ? String(row[f.field_key]) : undefined}
|
||||||
|
>
|
||||||
|
{renderCell(row[f.field_key], f.data_type, f.field_key)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{actionColumns.map((ac) => {
|
||||||
|
const visible = ac.buttons.filter((b) => evalShowConditions(b.show_conditions, row));
|
||||||
|
return (
|
||||||
|
<td key={ac.id} className="px-4 py-3 whitespace-nowrap" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{visible.length === 0 ? (
|
||||||
|
<span className="text-stone-300 dark:text-zinc-700">—</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{visible.map((b, bi) => {
|
||||||
|
const aid = b.params?.activity_uid;
|
||||||
|
if (!aid) return null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={bi}
|
||||||
|
onClick={() => setFormModal({
|
||||||
|
activityId: aid,
|
||||||
|
instanceId: String(row.instance_id),
|
||||||
|
title: b.name,
|
||||||
|
})}
|
||||||
|
className={buttonClass(b.classes)}
|
||||||
|
style={!b.classes?.includes("z-btn-secondary") ? { background: "linear-gradient(135deg, #C8102E, #E84258)" } : {}}
|
||||||
|
>
|
||||||
|
{iconFor(b.icon)}{b.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<td className="pr-4 py-3 w-16 text-right" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRowClick(row)}
|
||||||
|
className="w-7 h-7 rounded-lg flex items-center justify-center text-stone-400 dark:text-zinc-500 hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-100 dark:hover:bg-rose-950/50 transition-colors opacity-0 group-hover/row:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<ArrowRight size={14} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Pagination ────────────────────────────────────────────────────── */}
|
||||||
|
{total_count > 0 && (
|
||||||
|
<div className="px-5 py-3 border-t border-stone-100 dark:border-zinc-800 flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3 text-[12px] text-stone-400 dark:text-zinc-500">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
Show
|
||||||
|
<select
|
||||||
|
value={query.limit}
|
||||||
|
onChange={(e) => setQuery((q) => ({ ...q, page: 1, limit: Number(e.target.value) }))}
|
||||||
|
className="h-7 px-2 text-[12px] border border-stone-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-stone-600 dark:text-zinc-300 font-medium focus:outline-none cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value={10}>10</option>
|
||||||
|
<option value={25}>25</option>
|
||||||
|
<option value={50}>50</option>
|
||||||
|
</select>
|
||||||
|
per page
|
||||||
|
</div>
|
||||||
|
<span className="pl-3 border-l border-stone-200 dark:border-zinc-700 tabular-nums">
|
||||||
|
{from}–{to} of {total_count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{total_pages > 1 && (
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<button
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => handlePage(page - 1)}
|
||||||
|
className="w-7 h-7 rounded-lg flex items-center justify-center text-stone-400 dark:text-zinc-500 hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-950/30 disabled:opacity-30 disabled:pointer-events-none transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={15} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{pages.map((p, idx) =>
|
||||||
|
p === "…" ? (
|
||||||
|
<span key={`e${idx}`} className="w-7 h-7 flex items-center justify-center text-[12px] text-stone-300 dark:text-zinc-700 select-none">…</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => handlePage(p as number)}
|
||||||
|
className={`w-7 h-7 rounded-lg text-[12px] font-medium transition-colors ${
|
||||||
|
p === page
|
||||||
|
? "bg-rose-600 text-white"
|
||||||
|
: "text-stone-500 dark:text-zinc-400 hover:bg-rose-50 dark:hover:bg-rose-950/30 hover:text-rose-600 dark:hover:text-rose-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
disabled={page >= total_pages}
|
||||||
|
onClick={() => handlePage(page + 1)}
|
||||||
|
className="w-7 h-7 rounded-lg flex items-center justify-center text-stone-400 dark:text-zinc-500 hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-950/30 disabled:opacity-30 disabled:pointer-events-none transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formModal && (
|
||||||
|
<FormModal
|
||||||
|
activityId={formModal.activityId}
|
||||||
|
instanceId={formModal.instanceId}
|
||||||
|
title={formModal.title}
|
||||||
|
onClose={() => setFormModal(null)}
|
||||||
|
onSuccess={() => { setFormModal(null); fetchData(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function buildPageRange(current: number, total: number): Array<number | "…"> {
|
||||||
|
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
|
||||||
|
if (current <= 4) return [1, 2, 3, 4, 5, "…", total];
|
||||||
|
if (current >= total - 3) return [1, "…", total - 4, total - 3, total - 2, total - 1, total];
|
||||||
|
return [1, "…", current - 1, current, current + 1, "…", total];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCell(value: unknown, dataType: string, fieldKey: string): React.ReactNode {
|
||||||
|
if (value == null || value === "") return <span className="text-stone-300 dark:text-zinc-700">—</span>;
|
||||||
|
if (fieldKey === "current_state_name") return <StatusBadge value={String(value)} />;
|
||||||
|
if (dataType === "phone" && typeof value === "object") {
|
||||||
|
const p = value as { phone_with_dial_code?: string; phone?: string; dial_code?: string };
|
||||||
|
const text = p.phone_with_dial_code || (p.dial_code && p.phone ? `${p.dial_code}${p.phone}` : p.phone || "");
|
||||||
|
return text ? <span className="tabular-nums">{text}</span> : <span className="text-stone-300 dark:text-zinc-700">—</span>;
|
||||||
|
}
|
||||||
|
if (dataType === "number") {
|
||||||
|
const n = Number(value);
|
||||||
|
if (fieldKey.includes("amount") || fieldKey.includes("Amount") || fieldKey.includes("tax") || fieldKey.includes("price"))
|
||||||
|
return <span className="tabular-nums font-medium text-stone-700 dark:text-zinc-200">{n.toLocaleString("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 0 })}</span>;
|
||||||
|
return <span className="tabular-nums">{n.toLocaleString()}</span>;
|
||||||
|
}
|
||||||
|
if (dataType === "datetime" || dataType === "date") {
|
||||||
|
try { return <span>{new Date(String(value)).toLocaleDateString("en-IN", { day: "2-digit", month: "short", year: "numeric" })}</span>; }
|
||||||
|
catch { return String(value); }
|
||||||
|
}
|
||||||
|
if (fieldKey === "instance_id") {
|
||||||
|
return <span className="font-mono text-[11px] text-stone-400 dark:text-zinc-500 bg-stone-100 dark:bg-zinc-800 px-1.5 py-0.5 rounded-md">{String(value).slice(0, 8)}</span>;
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status badge — Mahindra workflow states
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const STATUS: Record<string, { bg: string; text: string; dot: string; darkBg: string; darkText: string; darkDot: string }> = {
|
||||||
|
"Awaiting Scheduler Call": {
|
||||||
|
bg: "bg-rose-50 border-rose-200", text: "text-rose-700", dot: "bg-rose-500 animate-pulse",
|
||||||
|
darkBg: "dark:bg-rose-950/30 dark:border-rose-900", darkText: "dark:text-rose-300", darkDot: "dark:bg-rose-400",
|
||||||
|
},
|
||||||
|
"Scheduled": {
|
||||||
|
bg: "bg-amber-50 border-amber-200", text: "text-amber-700", dot: "bg-amber-500",
|
||||||
|
darkBg: "dark:bg-amber-950/30 dark:border-amber-900", darkText: "dark:text-amber-300", darkDot: "dark:bg-amber-400",
|
||||||
|
},
|
||||||
|
"Awaiting Feedback Call": {
|
||||||
|
bg: "bg-rose-50 border-rose-200", text: "text-rose-700", dot: "bg-rose-500 animate-pulse",
|
||||||
|
darkBg: "dark:bg-rose-950/30 dark:border-rose-900", darkText: "dark:text-rose-300", darkDot: "dark:bg-rose-400",
|
||||||
|
},
|
||||||
|
"Feedback Collected": {
|
||||||
|
bg: "bg-emerald-50 border-emerald-200", text: "text-emerald-700", dot: "bg-emerald-500",
|
||||||
|
darkBg: "dark:bg-emerald-950/30 dark:border-emerald-900", darkText: "dark:text-emerald-300", darkDot: "dark:bg-emerald-400",
|
||||||
|
},
|
||||||
|
"Scheduling Call Failed": {
|
||||||
|
bg: "bg-red-50 border-red-200", text: "text-red-700", dot: "bg-red-500",
|
||||||
|
darkBg: "dark:bg-red-950/30 dark:border-red-900", darkText: "dark:text-red-300", darkDot: "dark:bg-red-400",
|
||||||
|
},
|
||||||
|
"Feedback Call Failed": {
|
||||||
|
bg: "bg-red-50 border-red-200", text: "text-red-700", dot: "bg-red-500",
|
||||||
|
darkBg: "dark:bg-red-950/30 dark:border-red-900", darkText: "dark:text-red-300", darkDot: "dark:bg-red-400",
|
||||||
|
},
|
||||||
|
"End State": {
|
||||||
|
bg: "bg-stone-100 border-stone-200", text: "text-stone-600", dot: "bg-stone-400",
|
||||||
|
darkBg: "dark:bg-zinc-800 dark:border-zinc-700", darkText: "dark:text-zinc-400", darkDot: "dark:bg-zinc-500",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatusBadge({ value }: { value: string }) {
|
||||||
|
const s = STATUS[value] ?? { bg: "bg-stone-100 border-stone-200", text: "text-stone-600", dot: "bg-stone-400", darkBg: "dark:bg-zinc-800 dark:border-zinc-700", darkText: "dark:text-zinc-400", darkDot: "dark:bg-zinc-500" };
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold border ${s.bg} ${s.text} ${s.darkBg} ${s.darkText}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${s.dot} ${s.darkDot}`} />
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
src/components/ScreenRenderer.tsx
Normal file
102
src/components/ScreenRenderer.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import { useState, ReactNode } from "react";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import type { LayoutElement } from "../api/viewService";
|
||||||
|
import RecordViewTable from "./RecordViewTable";
|
||||||
|
import DetailViewPanel from "./DetailViewPanel";
|
||||||
|
import FormModal from "./FormModal";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
layout: LayoutElement[];
|
||||||
|
instanceId?: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
onRowClick?: (instanceId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnyNode {
|
||||||
|
type?: string;
|
||||||
|
style?: Record<string, string>;
|
||||||
|
config?: Record<string, any>;
|
||||||
|
children?: AnyNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function flatten(nodes: AnyNode[] | undefined): AnyNode[] {
|
||||||
|
if (!nodes) return [];
|
||||||
|
const out: AnyNode[] = [];
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.config && (n.config.type || n.config.job_template)) out.push(n);
|
||||||
|
if (n.children?.length) out.push(...flatten(n.children));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScreenRenderer({ layout, instanceId, onRefresh, onRowClick }: Props) {
|
||||||
|
const [formModal, setFormModal] = useState<{ activityId: string; instanceId?: string; title?: string } | null>(null);
|
||||||
|
|
||||||
|
const buttons: ReactNode[] = [];
|
||||||
|
const elements: { type: "rv" | "dv"; viewId: number | string; style?: Record<string, string> }[] = [];
|
||||||
|
|
||||||
|
for (const node of flatten(layout as AnyNode[])) {
|
||||||
|
const cfg = node.config!;
|
||||||
|
if (cfg.type === "button" || cfg.job_template) {
|
||||||
|
const aid =
|
||||||
|
cfg.params?.activity_uid_init ||
|
||||||
|
cfg.params?.activity_id_init ||
|
||||||
|
cfg.params?.activity_uid ||
|
||||||
|
cfg.params?.activity_id;
|
||||||
|
if (!aid) continue;
|
||||||
|
const label = (cfg.name || "").replace(/^\+\s*/, "");
|
||||||
|
buttons.push(
|
||||||
|
<button
|
||||||
|
key={aid}
|
||||||
|
onClick={() => setFormModal({ activityId: aid, instanceId: cfg.job_template === "perform_activity" ? instanceId : undefined, title: label })}
|
||||||
|
className="h-8 px-3.5 text-[12px] font-semibold text-white rounded-xl inline-flex items-center gap-1.5 transition-all hover:scale-[1.02] active:scale-[0.98] mh-glow"
|
||||||
|
style={{ background: "linear-gradient(135deg, #C8102E, #E84258)" }}
|
||||||
|
>
|
||||||
|
<Plus size={13} strokeWidth={2.5} />{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
} else if (cfg.type === "recordsview") {
|
||||||
|
const id = cfg.view_uuid || cfg.view_id;
|
||||||
|
elements.push({ type: "rv", viewId: id, style: node.style });
|
||||||
|
} else if (cfg.type === "detailsview") {
|
||||||
|
const id = cfg.view_uuid || cfg.view_id;
|
||||||
|
elements.push({ type: "dv", viewId: id, style: node.style });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{elements.map((el, i) => {
|
||||||
|
if (el.type === "rv") {
|
||||||
|
return (
|
||||||
|
<div key={i} style={el.style}>
|
||||||
|
<RecordViewTable
|
||||||
|
viewId={el.viewId}
|
||||||
|
onRowClick={onRowClick}
|
||||||
|
toolbarAction={buttons.length > 0 ? <div className="flex items-center gap-2">{buttons}</div> : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (el.type === "dv") {
|
||||||
|
return <div key={i} style={el.style}><DetailViewPanel viewId={el.viewId} instanceId={instanceId || ""} /></div>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
{elements.length === 0 && buttons.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2">{buttons}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{formModal && (
|
||||||
|
<FormModal
|
||||||
|
activityId={formModal.activityId}
|
||||||
|
instanceId={formModal.instanceId}
|
||||||
|
title={formModal.title}
|
||||||
|
onClose={() => setFormModal(null)}
|
||||||
|
onSuccess={() => { setFormModal(null); onRefresh?.(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/components/Tile.tsx
Normal file
21
src/components/Tile.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
interface Props {
|
||||||
|
title?: string;
|
||||||
|
value: number | string | null | undefined;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Tile({ title, value, loading }: Props) {
|
||||||
|
const display = value == null ? "—" : typeof value === "number" ? value.toLocaleString("en-IN") : String(value);
|
||||||
|
return (
|
||||||
|
<div className="flex-1 min-w-[140px] bg-white dark:bg-zinc-900 rounded-2xl border border-stone-200/80 dark:border-zinc-700/60 px-4 py-3 shadow-sm">
|
||||||
|
{title && (
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-widest text-stone-400 dark:text-zinc-500 mb-1">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-[22px] font-bold text-stone-900 dark:text-zinc-100 tabular-nums leading-tight">
|
||||||
|
{loading ? <span className="inline-block h-6 w-12 bg-stone-100 dark:bg-zinc-800 rounded animate-pulse" /> : display}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
571
src/components/variants/Calendar.tsx
Normal file
571
src/components/variants/Calendar.tsx
Normal file
@ -0,0 +1,571 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { ChevronLeft, ChevronRight, Phone, X, Calendar as CalendarIcon, Home, Building2 } from "lucide-react";
|
||||||
|
import { fetchRdbmsLookupRecords } from "../../api/viewService";
|
||||||
|
import { CALENDAR_LOOKUPS, WORKFLOW_NUMERIC_ID, ACTIVITY_IDS } from "../../config";
|
||||||
|
|
||||||
|
const ACCENT = "#e31837";
|
||||||
|
const ACCENT_SOFT = "#fde2e8";
|
||||||
|
const TM_NAVY = "#061f5c";
|
||||||
|
const TM_BORDER = "#e4e4ed";
|
||||||
|
const INK = "#17181a";
|
||||||
|
const IST_OFFSET_MIN = 330;
|
||||||
|
|
||||||
|
interface TestDrive {
|
||||||
|
id: number;
|
||||||
|
dealer_id: number;
|
||||||
|
car_id: number;
|
||||||
|
slot_start: string;
|
||||||
|
slot_end: string;
|
||||||
|
status: "available" | "booked";
|
||||||
|
customer_name?: string | null;
|
||||||
|
customer_phone?: string | null;
|
||||||
|
booked_at?: string | null;
|
||||||
|
completed_at?: string | null;
|
||||||
|
feedback_rating?: number | null;
|
||||||
|
feedback_liked_most?: string | null;
|
||||||
|
feedback_concern?: string | null;
|
||||||
|
feedback_would_recommend?: boolean | null;
|
||||||
|
feedback_collected_at?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
td_mode?: "home" | "showroom" | null;
|
||||||
|
is_expert_td?: boolean | null;
|
||||||
|
home_address?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Car {
|
||||||
|
id: number;
|
||||||
|
model: string;
|
||||||
|
variant?: string | null;
|
||||||
|
color?: string | null;
|
||||||
|
price_inr?: number | string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function carSlug(model: string): string {
|
||||||
|
return model.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||||
|
}
|
||||||
|
function carImageUrl(model: string): string {
|
||||||
|
return `${import.meta.env.BASE_URL}cars/${carSlug(model)}.jpg`;
|
||||||
|
}
|
||||||
|
function fmtPriceInr(v: number | string | null | undefined): string {
|
||||||
|
if (v == null || v === "") return "—";
|
||||||
|
const n = typeof v === "string" ? Number(v) : v;
|
||||||
|
if (!Number.isFinite(n)) return String(v);
|
||||||
|
return n.toLocaleString("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIST(iso: string): Date {
|
||||||
|
// Shift UTC to IST by adding 5:30. Returns a Date whose UTC fields equal IST wall-clock.
|
||||||
|
return new Date(new Date(iso).getTime() + IST_OFFSET_MIN * 60_000);
|
||||||
|
}
|
||||||
|
function istDayKey(iso: string): string {
|
||||||
|
const d = toIST(iso);
|
||||||
|
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
function istTimeKey(iso: string): string {
|
||||||
|
const d = toIST(iso);
|
||||||
|
return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
function fmtDayLabel(key: string): { weekday: string; day: string; month: string } {
|
||||||
|
const [y, m, d] = key.split("-").map(Number);
|
||||||
|
const dt = new Date(Date.UTC(y, m - 1, d));
|
||||||
|
return {
|
||||||
|
weekday: dt.toLocaleDateString("en-US", { weekday: "short", timeZone: "UTC" }),
|
||||||
|
day: String(d).padStart(2, "0"),
|
||||||
|
month: dt.toLocaleDateString("en-US", { month: "short", timeZone: "UTC" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function addDaysKey(key: string, days: number): string {
|
||||||
|
const [y, m, d] = key.split("-").map(Number);
|
||||||
|
const dt = new Date(Date.UTC(y, m - 1, d + days));
|
||||||
|
return `${dt.getUTCFullYear()}-${String(dt.getUTCMonth() + 1).padStart(2, "0")}-${String(dt.getUTCDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarView() {
|
||||||
|
const [drives, setDrives] = useState<TestDrive[]>([]);
|
||||||
|
const [cars, setCars] = useState<Car[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [weekStart, setWeekStart] = useState<string | null>(null);
|
||||||
|
const [selected, setSelected] = useState<TestDrive | null>(null);
|
||||||
|
const [modelFilter, setModelFilter] = useState<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// The /rdbms-templates/.../records handler clamps limit to 200 server-side,
|
||||||
|
// so loop with offset until we've drained `total`. Without this, ~80 of 280
|
||||||
|
// test_drives are silently dropped and entire cars (heap-order-last) vanish.
|
||||||
|
const PAGE = 200;
|
||||||
|
async function fetchAllDrives(): Promise<TestDrive[]> {
|
||||||
|
const out: TestDrive[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
for (;;) {
|
||||||
|
const resp = await fetchRdbmsLookupRecords(CALENDAR_LOOKUPS.TEST_DRIVES.rdbmsTemplateUuid, {
|
||||||
|
workflowId: WORKFLOW_NUMERIC_ID,
|
||||||
|
activityId: ACTIVITY_IDS.INIT_ACTIVITY,
|
||||||
|
fieldId: CALENDAR_LOOKUPS.TEST_DRIVES.fieldId,
|
||||||
|
limit: PAGE,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
const batch = (resp.records as unknown as TestDrive[]) ?? [];
|
||||||
|
out.push(...batch);
|
||||||
|
if (batch.length < PAGE || out.length >= resp.total) break;
|
||||||
|
offset += PAGE;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
fetchAllDrives(),
|
||||||
|
fetchRdbmsLookupRecords(CALENDAR_LOOKUPS.CARS.rdbmsTemplateUuid, {
|
||||||
|
workflowId: WORKFLOW_NUMERIC_ID,
|
||||||
|
activityId: ACTIVITY_IDS.INIT_ACTIVITY,
|
||||||
|
fieldId: CALENDAR_LOOKUPS.CARS.fieldId,
|
||||||
|
limit: 100,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
.then(([td, c]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setDrives(td);
|
||||||
|
setCars((c.records as unknown as Car[]) ?? []);
|
||||||
|
const days = td.map((r) => istDayKey(r.slot_start)).sort();
|
||||||
|
setWeekStart(days[0] ?? null);
|
||||||
|
})
|
||||||
|
.catch((e: any) => !cancelled && setError(e?.message ?? "Failed to load"))
|
||||||
|
.finally(() => !cancelled && setLoading(false));
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const carById = useMemo(() => {
|
||||||
|
const m = new Map<number, Car>();
|
||||||
|
cars.forEach((c) => m.set(Number(c.id), c));
|
||||||
|
return m;
|
||||||
|
}, [cars]);
|
||||||
|
|
||||||
|
const models = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
cars.forEach((c) => c.model && set.add(c.model));
|
||||||
|
return Array.from(set).sort();
|
||||||
|
}, [cars]);
|
||||||
|
|
||||||
|
const visibleCars = useMemo(
|
||||||
|
() => (modelFilter ? cars.filter((c) => c.model === modelFilter) : cars),
|
||||||
|
[cars, modelFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
const weekDays = useMemo(() => {
|
||||||
|
if (!weekStart) return [];
|
||||||
|
return Array.from({ length: 7 }, (_, i) => addDaysKey(weekStart, i));
|
||||||
|
}, [weekStart]);
|
||||||
|
|
||||||
|
// time-slot keys present in the data, sorted
|
||||||
|
const timeSlots = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
drives.forEach((d) => set.add(istTimeKey(d.slot_start)));
|
||||||
|
return Array.from(set).sort();
|
||||||
|
}, [drives]);
|
||||||
|
|
||||||
|
// index drives by (day, time, car) for O(1) lookup
|
||||||
|
const grid = useMemo(() => {
|
||||||
|
const m = new Map<string, TestDrive>();
|
||||||
|
drives.forEach((d) => {
|
||||||
|
const key = `${istDayKey(d.slot_start)}|${istTimeKey(d.slot_start)}|${d.car_id}`;
|
||||||
|
m.set(key, d);
|
||||||
|
});
|
||||||
|
return m;
|
||||||
|
}, [drives]);
|
||||||
|
|
||||||
|
const visibleCarIds = useMemo(() => new Set(visibleCars.map((c) => Number(c.id))), [visibleCars]);
|
||||||
|
|
||||||
|
// For the visible week, count bookings per day for the header summary.
|
||||||
|
const bookingsByDay = useMemo(() => {
|
||||||
|
const m = new Map<string, number>();
|
||||||
|
drives.forEach((d) => {
|
||||||
|
if (d.status !== "booked") return;
|
||||||
|
if (!visibleCarIds.has(Number(d.car_id))) return;
|
||||||
|
const k = istDayKey(d.slot_start);
|
||||||
|
m.set(k, (m.get(k) ?? 0) + 1);
|
||||||
|
});
|
||||||
|
return m;
|
||||||
|
}, [drives, visibleCarIds]);
|
||||||
|
|
||||||
|
const totalBookings = useMemo(
|
||||||
|
() => drives.filter((d) => d.status === "booked" && visibleCarIds.has(Number(d.car_id))).length,
|
||||||
|
[drives, visibleCarIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-stone-400 text-sm">Loading calendar…</div>;
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 text-sm" style={{ background: ACCENT_SOFT, color: "#5f0229", border: `1px solid ${ACCENT}` }}>
|
||||||
|
Couldn't load calendar: {error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!weekStart || timeSlots.length === 0) {
|
||||||
|
return <div className="text-stone-400 text-sm">No slots configured.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Header — page title + week nav */}
|
||||||
|
<div className="mb-6 flex items-end justify-between gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.15em]" style={{ color: ACCENT }}>
|
||||||
|
Test-drive lifecycle
|
||||||
|
</div>
|
||||||
|
<h1 className="text-[24px] font-semibold tracking-tight leading-tight mt-1.5" style={{ color: INK }}>
|
||||||
|
Slots calendar
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={modelFilter}
|
||||||
|
onChange={(e) => setModelFilter(e.target.value)}
|
||||||
|
className="h-9 pl-3 pr-8 text-[12.5px] font-semibold bg-white appearance-none cursor-pointer focus:outline-none"
|
||||||
|
style={{ border: `1px solid ${TM_BORDER}`, color: INK, borderRadius: 4 }}
|
||||||
|
>
|
||||||
|
<option value="">All models</option>
|
||||||
|
{models.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||||
|
</select>
|
||||||
|
<span className="absolute right-2.5 top-1/2 -translate-y-1/2 text-stone-400 pointer-events-none text-[10px]">▾</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setWeekStart((k) => k && addDaysKey(k, -7))}
|
||||||
|
className="h-9 w-9 inline-flex items-center justify-center bg-white hover:bg-stone-50 transition-colors"
|
||||||
|
style={{ border: `1px solid ${TM_BORDER}`, color: INK }}
|
||||||
|
aria-label="Previous week"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<div className="text-[13px] font-semibold tabular-nums px-3" style={{ color: INK }}>
|
||||||
|
{fmtDayLabel(weekDays[0]).day} {fmtDayLabel(weekDays[0]).month} — {fmtDayLabel(weekDays[6]).day} {fmtDayLabel(weekDays[6]).month}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setWeekStart((k) => k && addDaysKey(k, 7))}
|
||||||
|
className="h-9 w-9 inline-flex items-center justify-center bg-white hover:bg-stone-50 transition-colors"
|
||||||
|
style={{ border: `1px solid ${TM_BORDER}`, color: INK }}
|
||||||
|
aria-label="Next week"
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="mb-4 flex items-center gap-5 text-[11.5px] text-stone-500 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3" style={{ background: ACCENT }} />
|
||||||
|
Booked
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 bg-white" style={{ border: `1px solid ${TM_BORDER}` }} />
|
||||||
|
Available
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Building2 size={12} className="text-stone-500" /> Showroom
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Home size={12} className="text-stone-500" /> Home
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3" style={{ background: ACCENT, boxShadow: "inset 0 0 0 2px #d4a017" }} />
|
||||||
|
Expert TD
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto text-stone-400">
|
||||||
|
{totalBookings} bookings across {weekDays.length} days
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div className="bg-white overflow-x-auto" style={{ border: `1px solid ${TM_BORDER}` }}>
|
||||||
|
<table className="w-full" style={{ tableLayout: "fixed", borderCollapse: "collapse" }}>
|
||||||
|
<colgroup>
|
||||||
|
<col style={{ width: 80 }} />
|
||||||
|
{weekDays.map((d) => <col key={d} />)}
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-3 py-3 text-[10px] font-semibold uppercase tracking-[0.12em] text-stone-400" style={{ borderBottom: `1px solid ${TM_BORDER}` }}>
|
||||||
|
IST
|
||||||
|
</th>
|
||||||
|
{weekDays.map((dk) => {
|
||||||
|
const lbl = fmtDayLabel(dk);
|
||||||
|
const count = bookingsByDay.get(dk) ?? 0;
|
||||||
|
return (
|
||||||
|
<th key={dk} className="text-left px-3 py-3" style={{ borderBottom: `1px solid ${TM_BORDER}`, borderLeft: `1px solid ${TM_BORDER}` }}>
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-[0.12em] text-stone-400">{lbl.weekday}</div>
|
||||||
|
<div className="text-[15px] font-bold leading-none mt-1 tabular-nums" style={{ color: INK }}>
|
||||||
|
{lbl.day} <span className="text-[11px] font-medium text-stone-400 ml-0.5">{lbl.month}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{count > 0 && (
|
||||||
|
<span className="text-[10px] font-bold tabular-nums text-white px-1.5 py-0.5" style={{ background: ACCENT }}>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{timeSlots.map((tk) => (
|
||||||
|
<tr key={tk}>
|
||||||
|
<td className="px-3 py-3 align-top text-[12px] font-semibold tabular-nums text-stone-500" style={{ borderTop: `1px solid ${TM_BORDER}` }}>
|
||||||
|
{tk}
|
||||||
|
</td>
|
||||||
|
{weekDays.map((dk) => (
|
||||||
|
<td key={dk + tk} className="px-2 py-2 align-top" style={{ borderTop: `1px solid ${TM_BORDER}`, borderLeft: `1px solid ${TM_BORDER}` }}>
|
||||||
|
<div className="grid grid-cols-1 gap-1">
|
||||||
|
{visibleCars.map((c) => {
|
||||||
|
const cell = grid.get(`${dk}|${tk}|${Number(c.id)}`);
|
||||||
|
if (!cell) {
|
||||||
|
return (
|
||||||
|
<div key={c.id} className="h-7 px-2 flex items-center text-[10.5px] text-stone-400" style={{ border: `1px solid ${TM_BORDER}` }}>
|
||||||
|
—
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const booked = cell.status === "booked";
|
||||||
|
const isHome = cell.td_mode === "home";
|
||||||
|
const isExpert = !!cell.is_expert_td;
|
||||||
|
const ModeIcon = isHome ? Home : Building2;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => booked && setSelected(cell)}
|
||||||
|
className={`h-7 px-2 flex items-center justify-between gap-1.5 text-[10.5px] font-medium transition-colors ${booked ? "cursor-pointer" : "cursor-default"}`}
|
||||||
|
style={
|
||||||
|
booked
|
||||||
|
? {
|
||||||
|
background: ACCENT,
|
||||||
|
color: "white",
|
||||||
|
boxShadow: isExpert ? "inset 0 0 0 2px #d4a017" : undefined,
|
||||||
|
}
|
||||||
|
: { background: "white", color: "#78716c", border: `1px solid ${TM_BORDER}` }
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
booked
|
||||||
|
? `${cell.customer_name ?? "Booked"} · ${c.model} · ${isHome ? "Home" : "Showroom"}${isExpert ? " · Expert TD" : ""}`
|
||||||
|
: `Available · ${c.model}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1 min-w-0">
|
||||||
|
{booked && <ModeIcon size={10} className="shrink-0 opacity-90" />}
|
||||||
|
<span className="truncate">{c.model}</span>
|
||||||
|
</span>
|
||||||
|
{booked && cell.customer_name && (
|
||||||
|
<span className="truncate font-semibold text-white/90">
|
||||||
|
{cell.customer_name.split(" ")[0]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BookingPanel
|
||||||
|
drive={selected}
|
||||||
|
car={selected ? carById.get(Number(selected.car_id)) : undefined}
|
||||||
|
onClose={() => setSelected(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BookingPanel({ drive, car, onClose }: { drive: TestDrive | null; car: Car | undefined; onClose: () => void }) {
|
||||||
|
const open = !!drive;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [open, onClose]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`fixed inset-0 z-40 transition-opacity duration-200 ${open ? "opacity-100" : "opacity-0 pointer-events-none"}`}
|
||||||
|
style={{ background: "rgba(6,31,92,0.35)" }}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<aside
|
||||||
|
className={`fixed top-0 right-0 bottom-0 z-50 w-full max-w-md bg-white shadow-2xl flex flex-col transition-transform duration-200 ease-out ${open ? "translate-x-0" : "translate-x-full"}`}
|
||||||
|
style={{ borderLeft: `1px solid ${TM_BORDER}` }}
|
||||||
|
role="dialog"
|
||||||
|
aria-hidden={!open}
|
||||||
|
>
|
||||||
|
{drive && <BookingPanelBody drive={drive} car={car} onClose={onClose} />}
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BookingPanelBody({ drive, car, onClose }: { drive: TestDrive; car: Car | undefined; onClose: () => void }) {
|
||||||
|
const day = fmtDayLabel(istDayKey(drive.slot_start));
|
||||||
|
const time = `${istTimeKey(drive.slot_start)} – ${istTimeKey(drive.slot_end)} IST`;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="relative px-6 py-5 text-white shrink-0" style={{ background: TM_NAVY }}>
|
||||||
|
<span className="absolute top-0 left-0 bottom-0 w-1.5" style={{ background: ACCENT }} />
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.15em] inline-flex items-center gap-1.5" style={{ color: ACCENT_SOFT }}>
|
||||||
|
<CalendarIcon size={11} /> Booking
|
||||||
|
{drive.is_expert_td && (
|
||||||
|
<span
|
||||||
|
className="ml-1 inline-flex items-center gap-1 px-1.5 py-0.5 text-[9.5px] font-bold uppercase tracking-wide"
|
||||||
|
style={{ background: "#d4a017", color: "#1a1a1a", borderRadius: 3 }}
|
||||||
|
>
|
||||||
|
★ Expert TD
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[22px] font-bold leading-tight mt-1 tabular-nums">
|
||||||
|
{day.weekday}, {day.day} {day.month} · {time}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[13px] text-white/85">
|
||||||
|
{drive.customer_name ?? "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-white/70 hover:text-white transition-colors shrink-0"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-y-auto flex-1">
|
||||||
|
{/* Car image hero */}
|
||||||
|
{car && (
|
||||||
|
<div className="relative w-full bg-stone-100" style={{ aspectRatio: "16 / 9" }}>
|
||||||
|
<img
|
||||||
|
src={carImageUrl(car.model)}
|
||||||
|
alt={car.model}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
const img = e.currentTarget as HTMLImageElement;
|
||||||
|
const fallback = `${import.meta.env.BASE_URL}cars/xuv700.jpg`;
|
||||||
|
if (img.src.endsWith(fallback)) { img.style.display = "none"; return; }
|
||||||
|
img.src = fallback;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-x-0 bottom-0 px-5 py-3" style={{ background: "linear-gradient(to top, rgba(6,31,92,0.85), transparent)" }}>
|
||||||
|
<div className="text-white text-[18px] font-bold leading-none">{car.model}</div>
|
||||||
|
{car.variant && <div className="text-white/80 text-[12px] mt-1">{car.variant}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PanelSection label="Vehicle">
|
||||||
|
<Row label="Color">
|
||||||
|
{car?.color ? (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded-full" style={{ background: colorSwatch(car.color), border: "1px solid rgba(0,0,0,0.1)" }} />
|
||||||
|
{car.color}
|
||||||
|
</span>
|
||||||
|
) : "—"}
|
||||||
|
</Row>
|
||||||
|
<Row label="Ex-showroom">
|
||||||
|
<span className="font-semibold tabular-nums" style={{ color: INK }}>{fmtPriceInr(car?.price_inr)}</span>
|
||||||
|
</Row>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection label="Customer">
|
||||||
|
<Row label="Name">
|
||||||
|
<span className="font-semibold" style={{ color: INK }}>{drive.customer_name ?? "—"}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="Phone">
|
||||||
|
{drive.customer_phone ? (
|
||||||
|
<a href={`tel:${drive.customer_phone}`} className="inline-flex items-center gap-1.5 font-semibold text-stone-700 hover:text-[#e31837] transition-colors tabular-nums">
|
||||||
|
<Phone size={12} className="text-stone-400" />
|
||||||
|
{drive.customer_phone}
|
||||||
|
</a>
|
||||||
|
) : "—"}
|
||||||
|
</Row>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection label="Dealer calendar">
|
||||||
|
<Row label="Slot">
|
||||||
|
<span className="font-semibold tabular-nums" style={{ color: INK }}>{day.weekday} {day.day} {day.month}, {time}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="Mode">
|
||||||
|
<span className="inline-flex items-center gap-1.5 font-semibold" style={{ color: INK }}>
|
||||||
|
{drive.td_mode === "home" ? <Home size={12} /> : <Building2 size={12} />}
|
||||||
|
{drive.td_mode === "home" ? "Home visit" : "Showroom"}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
{drive.td_mode === "home" && drive.home_address && (
|
||||||
|
<Row label="Address">{drive.home_address}</Row>
|
||||||
|
)}
|
||||||
|
{drive.booked_at && (
|
||||||
|
<Row label="Reserved at">
|
||||||
|
{new Date(drive.booked_at).toLocaleString("en-IN", { dateStyle: "medium", timeStyle: "short" })}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{drive.notes && <Row label="Notes">{drive.notes}</Row>}
|
||||||
|
</PanelSection>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelSection({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section className="px-6 py-5" style={{ borderBottom: `1px solid ${TM_BORDER}` }}>
|
||||||
|
<div className="text-[10px] font-bold uppercase tracking-[0.15em] text-stone-400 mb-3">{label}</div>
|
||||||
|
<div className="space-y-3">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ status, tone }: { status: string; tone: "booked" | "completed" | "failed" }) {
|
||||||
|
const palette: Record<string, { bg: string; fg: string; dot: string }> = {
|
||||||
|
booked: { bg: ACCENT_SOFT, fg: "#5f0229", dot: ACCENT },
|
||||||
|
completed: { bg: "#e8f5ec", fg: "#0f5132", dot: "#2f855a" },
|
||||||
|
failed: { bg: "#fde8e8", fg: "#7f1d1d", dot: "#dc2626" },
|
||||||
|
};
|
||||||
|
const p = palette[tone];
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2 py-1 text-[11px] font-bold uppercase tracking-[0.04em]" style={{ background: p.bg, color: p.fg, borderRadius: 4 }}>
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full" style={{ background: p.dot }} />
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorSwatch(color: string): string {
|
||||||
|
const c = color.toLowerCase();
|
||||||
|
if (c.includes("red") || c.includes("tango")) return "#c62828";
|
||||||
|
if (c.includes("white")) return "#f5f5f5";
|
||||||
|
if (c.includes("black") || c.includes("napoli") || c.includes("stealth")) return "#1a1a1a";
|
||||||
|
if (c.includes("silver") || c.includes("grey") || c.includes("gray")) return "#9ca3af";
|
||||||
|
if (c.includes("blue")) return "#1e3a8a";
|
||||||
|
return "#9ca3af";
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-baseline gap-4 text-[13px]">
|
||||||
|
<div className="w-24 shrink-0 text-[11px] font-semibold uppercase tracking-[0.05em] text-stone-500">{label}</div>
|
||||||
|
<div className="flex-1 min-w-0 break-words" style={{ color: INK }}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1285
src/components/variants/VariantB.tsx
Normal file
1285
src/components/variants/VariantB.tsx
Normal file
File diff suppressed because it is too large
Load Diff
90
src/config.ts
Normal file
90
src/config.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
// Tech Mahindra Demo v2 — App 164, Org 43
|
||||||
|
|
||||||
|
export const ORG_ID = "43";
|
||||||
|
export const APP_ID = "164";
|
||||||
|
|
||||||
|
// Core-service expects the workflow UUID (clone-portable) under `workflow_uuid`.
|
||||||
|
export const WORKFLOW_ID = "17670bed-d52c-455c-87cd-0c1a34417b4a";
|
||||||
|
|
||||||
|
export const ACTIVITY_IDS = {
|
||||||
|
INIT_ACTIVITY: "d951de1f-282f-4156-88c3-5bb3bb60d499", // INIT — Sales Agent captures lead
|
||||||
|
MARK_TEST_DRIVE_DONE: "5e419afc-3ddf-4351-91f4-6bf59035817c", // Manual — after the drive
|
||||||
|
AI_CONFIRM_SCHEDULING_SUCCESS: "a2222222-2222-4abc-8000-000000000002", // AI submits on call success
|
||||||
|
AI_CONFIRM_SCHEDULING_FAILED: "a3333333-3333-4abc-8000-000000000003", // AI submits on call failure
|
||||||
|
RETRY_SCHEDULING_CALL: "a4444444-4444-4abc-8000-000000000004", // Re-fire AI from failed state
|
||||||
|
AI_CONFIRM_FEEDBACK_COLLECTED: "a6666666-6666-4abc-8000-000000000006", // AI submits on feedback success
|
||||||
|
AI_CONFIRM_FEEDBACK_FAILED: "a7777777-7777-4abc-8000-000000000007", // AI submits on feedback failure
|
||||||
|
RETRY_FEEDBACK_CALL: "a8888888-8888-4abc-8000-000000000008", // Re-fire AI from failed feedback
|
||||||
|
CONCERN_EXPERT_TD_BOOKED: "e1111111-1111-4abc-8000-000000000009", // AI submits when feedback call surfaces concern + expert TD slot booked
|
||||||
|
MARK_EXPERT_TD_DONE: "e2222222-2222-4abc-8000-000000000010", // Dealer marks the expert/specialist drive complete
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const RECORD_VIEW_IDS = {
|
||||||
|
LEADS: "e4499533-1269-490e-be24-a744da8411af",
|
||||||
|
ACTIVE_SCHEDULING: "rv-mahindra-active-scheduling",
|
||||||
|
PENDING_FEEDBACK: "rv-mahindra-pending-feedback",
|
||||||
|
COMPLETED: "rv-mahindra-completed",
|
||||||
|
SCHEDULED_TEST_DRIVES: "rv-mahindra-scheduled-td",
|
||||||
|
CONCERN_EXPERT_TD: "ab66664a-19e1-45a2-be51-dca0211a8be7",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// rv_uid -> field_keys that should NOT render as table columns even though
|
||||||
|
// they're declared on the RV. current_state_id lives on these RVs only so the
|
||||||
|
// server-side prefilter validator accepts it; the rendered table doesn't need
|
||||||
|
// the raw UUID column.
|
||||||
|
export const RV_HIDDEN_COLUMNS: Record<string, string[]> = {
|
||||||
|
"rv-mahindra-active-scheduling": ["current_state_id"],
|
||||||
|
"rv-mahindra-scheduled-td": ["current_state_id"],
|
||||||
|
"rv-mahindra-pending-feedback": ["current_state_id"],
|
||||||
|
"rv-mahindra-completed": ["current_state_id"],
|
||||||
|
"ab66664a-19e1-45a2-be51-dca0211a8be7": ["current_state_id"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DETAIL_VIEW_IDS = {
|
||||||
|
INSTANCE_DETAIL: "7865e1d9-9f7c-489e-a12b-98463ebbb5c1",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Parent screen source_uuid (nav target) → dv-screen source_uuid.
|
||||||
|
// Each parent screen embeds an rv-screen via a `recordsview` element; this map
|
||||||
|
// short-circuits the lookup until row-click jobs land on the rv_tables in studio.
|
||||||
|
export const SCREEN_TO_DV: Record<string, string> = {
|
||||||
|
"bde69570-1b55-48ae-b670-bcfcaa093505": "05169c21-b5a5-4aa1-8faa-b0ddc8989c7d", // Active Scheduling
|
||||||
|
"98af93e0-0f66-444b-a007-129b322be86b": "ca9d2c41-f998-44ae-95d7-299b4c8aa895", // Scheduled Test Drives
|
||||||
|
"dfa82b37-ecc0-4b21-99aa-ae50d62c4dfb": "1eb72636-f6fc-41b6-9107-775f93de1fa1", // Pending Feedback
|
||||||
|
"1fe0a811-19bf-4dbd-a643-32ae1a067162": "f9828eb2-2c32-4a87-b7f1-fc0c8c16ac86", // Completed
|
||||||
|
"66c003dc-0162-46f2-9b87-a7b73051a4ca": "f07f3a7f-300e-4040-b5ff-dba75493738a", // Leads
|
||||||
|
"857596bf-2d50-489d-9316-c5dd10b1b42d": "9cb88bfa-13b5-42e3-88dd-c7eb1f419439", // Concern - Expert TD
|
||||||
|
};
|
||||||
|
|
||||||
|
// Workflow states
|
||||||
|
export const STATE_IDS = {
|
||||||
|
AWAITING_SCHEDULER_CALL: "9df9c9fa-7222-446c-a9f5-a40c8dbe91c2",
|
||||||
|
SCHEDULED: "7ecbf865-a1ce-48ce-a409-7fb5cf5213b8",
|
||||||
|
AWAITING_FEEDBACK_CALL: "cd8e7333-8a4f-41c0-afe2-fbb1f5b9f52e",
|
||||||
|
FEEDBACK_COLLECTED: "aab92dd6-46e6-4448-b61b-fe37f8727c00",
|
||||||
|
SCHEDULING_CALL_FAILED: "b1f1ce11-fa11-4abc-9001-0000000000a1",
|
||||||
|
FEEDBACK_CALL_FAILED: "b1f1ce11-fa11-4abc-9001-0000000000a2",
|
||||||
|
EXPERT_TD_SCHEDULED: "e0000000-0000-4abc-9003-000000000001",
|
||||||
|
END_STATE: "33cce5a0-f493-4fac-a1b7-7b68fbcbffce",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Demo calendar — backed by mahindra_demo schema via rdbms-template lookups on
|
||||||
|
// the INIT activity (storage_columns=id, display_columns=everything we need).
|
||||||
|
export const WORKFLOW_NUMERIC_ID = 29347;
|
||||||
|
export const DEMO_SLOTS_SCREEN_UUID = "98b1b946-f67d-4faa-b50f-4566f7e194d0";
|
||||||
|
export const CALENDAR_LOOKUPS = {
|
||||||
|
TEST_DRIVES: {
|
||||||
|
rdbmsTemplateUuid: "c71a16b0-8fc6-4ef2-b52d-2672e4dad8db",
|
||||||
|
fieldId: "test_drives_lookup",
|
||||||
|
},
|
||||||
|
CARS: {
|
||||||
|
rdbmsTemplateUuid: "3efdfe0f-8ed9-4fbc-95ce-0f3788622c9c",
|
||||||
|
fieldId: "cars_lookup",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Roles
|
||||||
|
export const ROLES = {
|
||||||
|
SALES_AGENT: "Sales Agent",
|
||||||
|
DEALER: "Dealer",
|
||||||
|
} as const;
|
||||||
17
src/hooks/AuthContext.tsx
Normal file
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}/usr/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
...(orgId ? { org_id: orgId } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = "Login failed";
|
||||||
|
try {
|
||||||
|
const body = await res.json();
|
||||||
|
if (body.error) msg = body.error;
|
||||||
|
} catch {}
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
const { token, user } = data as { token: string; user: User };
|
||||||
|
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||||
|
localStorage.setItem("zino_token", token);
|
||||||
|
|
||||||
|
setState({ user, token, loading: false, error: null });
|
||||||
|
return { token, user };
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.message || "Login failed";
|
||||||
|
setState((s) => ({ ...s, loading: false, error: msg }));
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[apiUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(USER_KEY);
|
||||||
|
localStorage.removeItem("zino_token");
|
||||||
|
setState({ user: null, token: null, loading: false, error: null });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
setState((s) => ({ ...s, error: null }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: state.user,
|
||||||
|
token: state.token,
|
||||||
|
isAuthenticated: !!state.token,
|
||||||
|
loading: state.loading,
|
||||||
|
error: state.error,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
clearError,
|
||||||
|
};
|
||||||
|
}
|
||||||
414
src/hooks/usePrototypeData.ts
Normal file
414
src/hooks/usePrototypeData.ts
Normal file
@ -0,0 +1,414 @@
|
|||||||
|
// PROTOTYPE — shared data hooks for /variants/*. Sharing DATA, not UI.
|
||||||
|
// When a variant wins, the winner stays; this hook is deleted.
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
getHeaderConfig, getScreen, getRVScreen, getRecordView,
|
||||||
|
getDVScreen, getDetailView, getInstance,
|
||||||
|
type NavItem, type LayoutElement, type RecordViewField,
|
||||||
|
type SearchQuery, type TileValue, type ChartDataResponse,
|
||||||
|
type AuditEntry,
|
||||||
|
} from "../api/viewService";
|
||||||
|
import { WORKFLOW_ID, RV_HIDDEN_COLUMNS, ACTIVITY_IDS } from "../config";
|
||||||
|
import { activityContext } from "../lib/activity";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// rv-screen layout walkers (same logic the production RecordViewTable uses;
|
||||||
|
// duplicated here so the prototype is self-contained and the existing red
|
||||||
|
// table is untouched until a variant wins).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ActionCondition {
|
||||||
|
field_key: string; operator: string; value: unknown; data_type?: string;
|
||||||
|
}
|
||||||
|
export interface ActionConditions { logic?: "AND" | "OR"; conditions: ActionCondition[]; }
|
||||||
|
export interface ActionButton {
|
||||||
|
name: string; icon?: string; classes?: string[];
|
||||||
|
job_template?: string;
|
||||||
|
params?: { activity_uid?: string; workflow_uuid?: string; [k: string]: unknown };
|
||||||
|
show_conditions?: ActionConditions | null;
|
||||||
|
}
|
||||||
|
export interface ActionColumn { id: string; display: string; buttons: ActionButton[]; }
|
||||||
|
|
||||||
|
export type AnalyticsSpec =
|
||||||
|
| { kind: "tile"; uid: string; title?: string }
|
||||||
|
| { kind: "chart"; uid: string; title?: string; chartType?: string };
|
||||||
|
|
||||||
|
function walkLayout(layout: any, visit: (node: any) => void): void {
|
||||||
|
const walk = (nodes: any[]): void => {
|
||||||
|
for (const n of nodes ?? []) {
|
||||||
|
visit(n);
|
||||||
|
if (Array.isArray(n?.children)) walk(n.children);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(Array.isArray(layout) ? layout : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractColumnLabels(layout: any): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
walkLayout(layout, (n) => {
|
||||||
|
const cfg = n?.config;
|
||||||
|
if (cfg?.type === "rv_table" && Array.isArray(cfg.columns)) {
|
||||||
|
for (const c of cfg.columns) {
|
||||||
|
if (c?.key_name) {
|
||||||
|
const label = c.custom_label || c.display;
|
||||||
|
if (label) out[c.key_name] = label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractColumnKeys(layout: any): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
walkLayout(layout, (n) => {
|
||||||
|
const cfg = n?.config;
|
||||||
|
if (cfg?.type === "rv_table" && Array.isArray(cfg.columns)) {
|
||||||
|
for (const c of cfg.columns) {
|
||||||
|
if (c?.key_name && c?.type !== "custom") out.push(c.key_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractActionColumns(layout: any): ActionColumn[] {
|
||||||
|
const out: ActionColumn[] = [];
|
||||||
|
walkLayout(layout, (n) => {
|
||||||
|
const cfg = n?.config;
|
||||||
|
if (cfg?.type === "rv_table" && Array.isArray(cfg.columns)) {
|
||||||
|
for (const col of cfg.columns) {
|
||||||
|
if (col?.type === "custom" && Array.isArray(col.config)) {
|
||||||
|
const buttons = col.config.filter((b: any) => b?.type === "button");
|
||||||
|
if (buttons.length > 0) out.push({ id: col.id, display: col.display || "Actions", buttons });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractAnalytics(layout: any): AnalyticsSpec[] {
|
||||||
|
const out: AnalyticsSpec[] = [];
|
||||||
|
walkLayout(layout, (n) => {
|
||||||
|
const cfg = n?.config;
|
||||||
|
if (cfg?.type === "rv_tile" && cfg.tile_uid) out.push({ kind: "tile", uid: cfg.tile_uid, title: cfg.title });
|
||||||
|
if (cfg?.type === "rv_chart" && cfg.chart_uid) out.push({ kind: "chart", uid: cfg.chart_uid, title: cfg.title, chartType: cfg.chart_type });
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evalShowConditions(c: ActionConditions | null | undefined, row: Record<string, unknown>): boolean {
|
||||||
|
if (!c || !Array.isArray(c.conditions) || c.conditions.length === 0) return true;
|
||||||
|
const results = c.conditions.map((cond) => {
|
||||||
|
const v = row[cond.field_key];
|
||||||
|
switch (cond.operator) {
|
||||||
|
case "equals": return v === cond.value;
|
||||||
|
case "not_equals": return v !== cond.value;
|
||||||
|
case "is_empty": return v == null || v === "";
|
||||||
|
case "is_not_empty": return v != null && v !== "";
|
||||||
|
case "in": return Array.isArray(cond.value) && cond.value.includes(v as never);
|
||||||
|
case "not_in": return Array.isArray(cond.value) && !cond.value.includes(v as never);
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return (c.logic === "OR") ? results.some(Boolean) : results.every(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pull the recordview viewId out of a screen layout (the parent screen
|
||||||
|
// embeds an rv-screen via a `recordsview` element).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function findRecordsViewId(layout: LayoutElement[]): number | string | null {
|
||||||
|
let found: number | string | null = null;
|
||||||
|
walkLayout(layout as any, (n) => {
|
||||||
|
if (found) return;
|
||||||
|
const cfg = n?.config;
|
||||||
|
if (cfg?.type === "recordsview") found = cfg.view_uuid || cfg.view_id || null;
|
||||||
|
});
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Header config — nav items
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useHeaderConfig() {
|
||||||
|
const [navItems, setNavItems] = useState<NavItem[]>([]);
|
||||||
|
const [logoText, setLogoText] = useState("Mahindra · AI Sales Ops");
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
getHeaderConfig("desktop").then((cfg) => {
|
||||||
|
setNavItems(cfg.components?.nav?.items ?? []);
|
||||||
|
const lt = (cfg.components as any)?.logo?.text;
|
||||||
|
if (lt) setLogoText(lt);
|
||||||
|
}).catch(console.error).finally(() => setReady(true));
|
||||||
|
}, []);
|
||||||
|
return { navItems, logoText, ready };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Active screen → recordview viewId
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ScreenButton { activityId: string; label: string; jobTemplate: string }
|
||||||
|
|
||||||
|
// Pull screen-level CTA buttons (e.g. "Add Lead") from a layout. Mirrors the
|
||||||
|
// parsing in ScreenRenderer.tsx — buttons declared at the screen level kick off
|
||||||
|
// an activity via activity_uid_init / activity_id_init.
|
||||||
|
function findScreenButtons(layout: LayoutElement[]): ScreenButton[] {
|
||||||
|
const out: ScreenButton[] = [];
|
||||||
|
walkLayout(layout as any, (n) => {
|
||||||
|
const cfg = n?.config;
|
||||||
|
if (!cfg) return;
|
||||||
|
if (cfg.type !== "button" && !cfg.job_template) return;
|
||||||
|
const aid =
|
||||||
|
cfg.params?.activity_uid_init ||
|
||||||
|
cfg.params?.activity_id_init ||
|
||||||
|
cfg.params?.activity_uid ||
|
||||||
|
cfg.params?.activity_id;
|
||||||
|
if (!aid) return;
|
||||||
|
out.push({
|
||||||
|
activityId: aid,
|
||||||
|
label: (cfg.name || "").replace(/^\+\s*/, "") || "New",
|
||||||
|
jobTemplate: cfg.job_template || "start_state_machine",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActiveScreenRecordView(activeScreenId: string | null) {
|
||||||
|
const [viewId, setViewId] = useState<number | string | null>(null);
|
||||||
|
const [screenButtons, setScreenButtons] = useState<ScreenButton[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeScreenId) { setViewId(null); setScreenButtons([]); return; }
|
||||||
|
setLoading(true);
|
||||||
|
getScreen(activeScreenId)
|
||||||
|
.then((s) => {
|
||||||
|
const layout = s.layout ?? [];
|
||||||
|
setViewId(findRecordsViewId(layout));
|
||||||
|
setScreenButtons(findScreenButtons(layout));
|
||||||
|
})
|
||||||
|
.catch(() => { setViewId(null); setScreenButtons([]); })
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [activeScreenId]);
|
||||||
|
return { viewId, screenButtons, loading };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Full record view (rv-screen + recordview data)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface UseRecordViewState {
|
||||||
|
loading: boolean;
|
||||||
|
dataLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
fields: RecordViewField[];
|
||||||
|
rows: Record<string, unknown>[];
|
||||||
|
columnLabels: Record<string, string>;
|
||||||
|
actionColumns: ActionColumn[];
|
||||||
|
analytics: AnalyticsSpec[];
|
||||||
|
tileValues: TileValue[];
|
||||||
|
chartData: ChartDataResponse[];
|
||||||
|
pagination: { page: number; limit: number; total_count: number; total_pages: number };
|
||||||
|
query: SearchQuery;
|
||||||
|
setQuery: (q: SearchQuery | ((q: SearchQuery) => SearchQuery)) => void;
|
||||||
|
refetch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRecordView(viewId: number | string | null): UseRecordViewState {
|
||||||
|
const [rvTemplateUid, setRvTemplateUid] = useState<string | null>(null);
|
||||||
|
const [layoutColumnKeys, setLayoutColumnKeys] = useState<string[] | null>(null);
|
||||||
|
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
|
||||||
|
const [actionColumns, setActionColumns] = useState<ActionColumn[]>([]);
|
||||||
|
const [analytics, setAnalytics] = useState<AnalyticsSpec[]>([]);
|
||||||
|
const [tileValues, setTileValues] = useState<TileValue[]>([]);
|
||||||
|
const [chartData, setChartData] = useState<ChartDataResponse[]>([]);
|
||||||
|
const [fields, setFields] = useState<RecordViewField[]>([]);
|
||||||
|
const [rows, setRows] = useState<Record<string, unknown>[]>([]);
|
||||||
|
const [pagination, setPagination] = useState({ page: 1, limit: 25, total_count: 0, total_pages: 0 });
|
||||||
|
const [query, setQuery] = useState<SearchQuery>({ page: 1, limit: 25, sort_dir: "desc" });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [dataLoading, setDataLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [refreshTick, setRefreshTick] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!viewId) return;
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
setRvTemplateUid(null); setLayoutColumnKeys(null); setColumnLabels({}); setActionColumns([]);
|
||||||
|
setAnalytics([]); setTileValues([]); setChartData([]);
|
||||||
|
getRVScreen(viewId)
|
||||||
|
.then((rv) => {
|
||||||
|
setColumnLabels(extractColumnLabels(rv.layout));
|
||||||
|
setLayoutColumnKeys(extractColumnKeys(rv.layout));
|
||||||
|
setActionColumns(extractActionColumns(rv.layout));
|
||||||
|
setAnalytics(extractAnalytics(rv.layout));
|
||||||
|
setRvTemplateUid(rv.rv_template_uid);
|
||||||
|
})
|
||||||
|
.catch((err) => { setError(err.message); setLoading(false); });
|
||||||
|
}, [viewId]);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
if (!rvTemplateUid || !viewId) return;
|
||||||
|
if (!loading) setDataLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await getRecordView(rvTemplateUid, viewId, query);
|
||||||
|
setTileValues(res.tile_values ?? []);
|
||||||
|
setChartData(res.chart_data ?? []);
|
||||||
|
const hidden = new Set(RV_HIDDEN_COLUMNS[rvTemplateUid] ?? []);
|
||||||
|
const picked = res.config.fields.filter((f) => f.field_key !== "" && !hidden.has(f.field_key));
|
||||||
|
// If the screen layout picks an explicit column set, use it as the authoritative list + order.
|
||||||
|
if (layoutColumnKeys && layoutColumnKeys.length > 0) {
|
||||||
|
const byKey = new Map(picked.map((f) => [f.field_key, f]));
|
||||||
|
setFields(layoutColumnKeys.map((k) => byKey.get(k)).filter((f): f is RecordViewField => !!f));
|
||||||
|
} else {
|
||||||
|
setFields(picked);
|
||||||
|
}
|
||||||
|
const dataRows = Array.isArray(res.data) ? res.data : [];
|
||||||
|
setRows(dataRows);
|
||||||
|
if (res.pagination) setPagination(res.pagination);
|
||||||
|
else setPagination((p) => ({ ...p, total_count: dataRows.length, total_pages: Math.ceil(dataRows.length / p.limit) || 1 }));
|
||||||
|
} catch (err: any) { setError(err.message); }
|
||||||
|
finally { setLoading(false); setDataLoading(false); }
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [rvTemplateUid, viewId, query, refreshTick, layoutColumnKeys]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading, dataLoading, error,
|
||||||
|
fields, rows, columnLabels, actionColumns, analytics, tileValues, chartData,
|
||||||
|
pagination, query, setQuery,
|
||||||
|
refetch: () => setRefreshTick((t) => t + 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Detail view
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface DVField { field_key: string; output_label: string; data_type: string }
|
||||||
|
|
||||||
|
export function useDetailView(viewId: number | string, instanceId: string) {
|
||||||
|
const [data, setData] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [fields, setFields] = useState<DVField[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setLoading(true); setError(null);
|
||||||
|
getDVScreen(viewId)
|
||||||
|
.then((dv) => getDetailView(dv.dv_template_uid, instanceId))
|
||||||
|
.catch(() => getDetailView(viewId, instanceId))
|
||||||
|
.then((res) => {
|
||||||
|
setFields(res.config?.fields?.filter((f: any) => f.field_key !== "") ?? []);
|
||||||
|
setData(res.data ?? {});
|
||||||
|
})
|
||||||
|
.catch((e: any) => setError(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [viewId, instanceId]);
|
||||||
|
|
||||||
|
return { data, fields, loading, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Instance + audit (for state badge / action menu / timeline)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Synthesise an AuditEntry-compatible array from instance.data._activities so
|
||||||
|
// the existing timeline UI works without the broken /view/audit endpoint.
|
||||||
|
// INIT is synthesised from instance.created_at since the platform doesn't
|
||||||
|
// write INIT into _activities.
|
||||||
|
interface ActivityRecord {
|
||||||
|
_system?: { created_at?: string; user_id?: string; user_roles?: string[] | null };
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveAuditFromActivities(
|
||||||
|
createdAt: string,
|
||||||
|
instanceData: Record<string, unknown>,
|
||||||
|
activities: Record<string, ActivityRecord> | undefined,
|
||||||
|
): AuditEntry[] {
|
||||||
|
const out: AuditEntry[] = [];
|
||||||
|
let id = 1;
|
||||||
|
out.push({
|
||||||
|
id: id++,
|
||||||
|
user_id: "",
|
||||||
|
user_roles: [],
|
||||||
|
activity_id: ACTIVITY_IDS.INIT_ACTIVITY,
|
||||||
|
data: {},
|
||||||
|
execution_state: "completed",
|
||||||
|
created_at: createdAt,
|
||||||
|
});
|
||||||
|
if (activities) {
|
||||||
|
for (const [activityId, record] of Object.entries(activities)) {
|
||||||
|
out.push({
|
||||||
|
id: id++,
|
||||||
|
user_id: record._system?.user_id ?? "",
|
||||||
|
user_roles: record._system?.user_roles ?? [],
|
||||||
|
activity_id: activityId,
|
||||||
|
data: record.data ?? {},
|
||||||
|
execution_state: "completed",
|
||||||
|
created_at: record._system?.created_at ?? createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
||||||
|
// Renumber ids chronologically and attach a context line drawn from the
|
||||||
|
// instance's top-level data (named keys, latest values).
|
||||||
|
return out.map((e, i) => ({
|
||||||
|
...e,
|
||||||
|
id: i + 1,
|
||||||
|
context: activityContext(e.activity_id, instanceData),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInstanceMeta(instanceId: string | null, refreshKey: number = 0) {
|
||||||
|
const [instanceState, setInstanceState] = useState<string | null>(null);
|
||||||
|
const [audit, setAudit] = useState<AuditEntry[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!instanceId) { setInstanceState(null); setAudit([]); return; }
|
||||||
|
getInstance(WORKFLOW_ID, instanceId)
|
||||||
|
.then((i) => {
|
||||||
|
setInstanceState(i.current_state_id);
|
||||||
|
const instanceData = (i.data as Record<string, unknown>) ?? {};
|
||||||
|
const acts = (instanceData as { _activities?: Record<string, ActivityRecord> })._activities;
|
||||||
|
setAudit(deriveAuditFromActivities(i.created_at, instanceData, acts));
|
||||||
|
})
|
||||||
|
.catch(() => { setAudit([]); });
|
||||||
|
}, [instanceId, refreshKey]);
|
||||||
|
return { instanceState, audit };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cell formatters — shared utilities (not UI)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function fmtCellText(value: unknown, dataType: string, fieldKey: string): string {
|
||||||
|
if (value == null || value === "") return "—";
|
||||||
|
if (dataType === "phone" && typeof value === "object") {
|
||||||
|
const p = value as { phone_with_dial_code?: string; phone?: string; dial_code?: string };
|
||||||
|
return p.phone_with_dial_code || (p.dial_code && p.phone ? `${p.dial_code}${p.phone}` : p.phone || "—");
|
||||||
|
}
|
||||||
|
if (dataType === "number") {
|
||||||
|
const n = Number(value);
|
||||||
|
if (["amount", "tax", "price", "cost"].some((x) => fieldKey.toLowerCase().includes(x)))
|
||||||
|
return n.toLocaleString("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 0 });
|
||||||
|
return n.toLocaleString("en-IN");
|
||||||
|
}
|
||||||
|
if (dataType === "datetime" || dataType === "date") {
|
||||||
|
try { return new Date(String(value)).toLocaleDateString("en-IN", { day: "2-digit", month: "short", year: "numeric" }); }
|
||||||
|
catch { return String(value); }
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtLabel(label: string): string {
|
||||||
|
return label.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
}
|
||||||
37
src/index.css
Normal file
37
src/index.css
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html, body {
|
||||||
|
@apply font-sans antialiased text-stone-800 bg-white;
|
||||||
|
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
transition-property: background-color, border-color, color;
|
||||||
|
transition-duration: 150ms;
|
||||||
|
transition-timing-function: ease;
|
||||||
|
}
|
||||||
|
.animate-spin, .animate-pulse {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.tabular-nums {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
/* Mahindra signature red ramp */
|
||||||
|
.mh-red { background-color: #C8102E; }
|
||||||
|
.mh-red-dark { background-color: #9D0D24; }
|
||||||
|
.mh-red-soft { background-color: #FEE7EB; }
|
||||||
|
.gradient-mh { background: linear-gradient(135deg, #C8102E 0%, #E84258 100%); }
|
||||||
|
.gradient-mh-deep { background: linear-gradient(135deg, #9D0D24 0%, #C8102E 60%, #E84258 100%); }
|
||||||
|
.mh-glow { box-shadow: 0 10px 30px -10px rgba(200, 16, 46, 0.45); }
|
||||||
|
.mh-text-grad {
|
||||||
|
background: linear-gradient(135deg, #C8102E, #E84258);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/lib/activity.ts
Normal file
89
src/lib/activity.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { ACTIVITY_IDS, STATE_IDS } from "../config";
|
||||||
|
|
||||||
|
export function fmtAct(id: string): string {
|
||||||
|
switch (id) {
|
||||||
|
case ACTIVITY_IDS.INIT_ACTIVITY: return "Lead Captured";
|
||||||
|
case ACTIVITY_IDS.MARK_TEST_DRIVE_DONE: return "Test Drive Completed";
|
||||||
|
case ACTIVITY_IDS.AI_CONFIRM_SCHEDULING_SUCCESS: return "AI · Scheduling Confirmed";
|
||||||
|
case ACTIVITY_IDS.AI_CONFIRM_SCHEDULING_FAILED: return "AI · Scheduling Failed";
|
||||||
|
case ACTIVITY_IDS.RETRY_SCHEDULING_CALL: return "Retry Scheduling Call";
|
||||||
|
case ACTIVITY_IDS.AI_CONFIRM_FEEDBACK_COLLECTED: return "AI · Feedback Collected";
|
||||||
|
case ACTIVITY_IDS.AI_CONFIRM_FEEDBACK_FAILED: return "AI · Feedback Failed";
|
||||||
|
case ACTIVITY_IDS.RETRY_FEEDBACK_CALL: return "Retry Feedback Call";
|
||||||
|
case ACTIVITY_IDS.CONCERN_EXPERT_TD_BOOKED: return "AI · Concern → Expert TD Booked";
|
||||||
|
case ACTIVITY_IDS.MARK_EXPERT_TD_DONE: return "Expert TD Completed";
|
||||||
|
default: return id.slice(0, 8) + "…";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FAILURE_STATE_IDS: ReadonlySet<string> = new Set([
|
||||||
|
STATE_IDS.SCHEDULING_CALL_FAILED,
|
||||||
|
STATE_IDS.FEEDBACK_CALL_FAILED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isFailedExecution(execution_state: string): boolean {
|
||||||
|
return execution_state === "TRIGGER_RESPONSE_TIMEOUT" || FAILURE_STATE_IDS.has(execution_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInflightExecution(execution_state: string): boolean {
|
||||||
|
return execution_state === "TRIGGER_DISPATCHED";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// activityContext — short human-readable summary per activity, drawn from
|
||||||
|
// the workflow instance's top-level data fields (clean named keys, latest
|
||||||
|
// values). Returns "" when nothing useful is available.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function truncate(s: string, n: number): string {
|
||||||
|
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtSlot(iso: unknown): string | null {
|
||||||
|
if (typeof iso !== "string" || !iso) return null;
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return null;
|
||||||
|
return d.toLocaleString("en-IN", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activityContext(activityId: string, data: Record<string, unknown>): string {
|
||||||
|
const d = data as Record<string, any>;
|
||||||
|
switch (activityId) {
|
||||||
|
case ACTIVITY_IDS.INIT_ACTIVITY: {
|
||||||
|
const name = d.customer_name;
|
||||||
|
const model = d.model_interest;
|
||||||
|
return [name, model].filter(Boolean).join(" · ");
|
||||||
|
}
|
||||||
|
case ACTIVITY_IDS.AI_CONFIRM_SCHEDULING_SUCCESS: {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (d.booking_id) parts.push(`Booked #${d.booking_id}`);
|
||||||
|
if (d.dealer_name) parts.push(`at ${d.dealer_name}`);
|
||||||
|
const slot = fmtSlot(d.booking_slot);
|
||||||
|
if (slot) parts.push(slot);
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
case ACTIVITY_IDS.AI_CONFIRM_SCHEDULING_FAILED:
|
||||||
|
case ACTIVITY_IDS.AI_CONFIRM_FEEDBACK_FAILED:
|
||||||
|
return d.call_summary ? truncate(String(d.call_summary), 120) : "Call did not complete";
|
||||||
|
case ACTIVITY_IDS.AI_CONFIRM_FEEDBACK_COLLECTED: {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (d.feedback_rating != null) parts.push(`Rated ${d.feedback_rating}/10`);
|
||||||
|
if (d.feedback_concern) parts.push(`Concern: ${truncate(String(d.feedback_concern), 80)}`);
|
||||||
|
else if (d.feedback_liked_most) parts.push(`Liked: ${truncate(String(d.feedback_liked_most), 80)}`);
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
case ACTIVITY_IDS.MARK_TEST_DRIVE_DONE:
|
||||||
|
return "Dealer marked test drive complete";
|
||||||
|
case ACTIVITY_IDS.MARK_EXPERT_TD_DONE:
|
||||||
|
return "Dealer marked expert test drive complete";
|
||||||
|
case ACTIVITY_IDS.CONCERN_EXPERT_TD_BOOKED: {
|
||||||
|
const slot = fmtSlot(d.booking_slot);
|
||||||
|
return slot ? `Expert TD scheduled · ${slot}` : "Expert TD scheduled";
|
||||||
|
}
|
||||||
|
case ACTIVITY_IDS.RETRY_SCHEDULING_CALL:
|
||||||
|
case ACTIVITY_IDS.RETRY_FEEDBACK_CALL:
|
||||||
|
return "Re-dispatched call";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/main.tsx
Normal file
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>,
|
||||||
|
);
|
||||||
184
src/pages/Login.tsx
Normal file
184
src/pages/Login.tsx
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
|
import { useAuthContext } from "../hooks/AuthContext";
|
||||||
|
import { ORG_ID } from "../config";
|
||||||
|
import { PhoneCall, CalendarCheck2, Sparkles, ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
// TM brand tokens — mirror VariantB.tsx / FormModal.tsx.
|
||||||
|
const ACCENT = "#e31837";
|
||||||
|
const ACCENT_SOFT = "#fde2e8";
|
||||||
|
const TM_NAVY = "#061f5c";
|
||||||
|
const TM_BORDER = "#e4e4ed";
|
||||||
|
const INK = "#17181a";
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const { login, loading, error, clearError } = useAuthContext();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [orgId] = useState(ORG_ID);
|
||||||
|
|
||||||
|
const from = (location.state as any)?.from?.pathname || "/";
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearError();
|
||||||
|
try {
|
||||||
|
await login({ email, password, orgId: orgId || undefined });
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = { border: `1px solid ${TM_BORDER}`, borderRadius: 4 };
|
||||||
|
const onFocus = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||||
|
e.currentTarget.style.borderColor = ACCENT;
|
||||||
|
e.currentTarget.style.boxShadow = `0 0 0 3px ${ACCENT_SOFT}`;
|
||||||
|
};
|
||||||
|
const onBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||||
|
e.currentTarget.style.borderColor = TM_BORDER;
|
||||||
|
e.currentTarget.style.boxShadow = "none";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex bg-white">
|
||||||
|
{/* ── Left — TM brand panel ───────────────────────────────────── */}
|
||||||
|
<div
|
||||||
|
className="hidden lg:flex lg:w-[45%] flex-col justify-between p-14 relative overflow-hidden text-white"
|
||||||
|
style={{ background: TM_NAVY }}
|
||||||
|
>
|
||||||
|
{/* Red vertical rail — TM editorial signature (mirrors the hero on DV) */}
|
||||||
|
<span className="absolute top-0 left-0 bottom-0 w-1.5" style={{ background: ACCENT }} />
|
||||||
|
|
||||||
|
{/* Diagonal red slice on the right — fills dead space, brand accent */}
|
||||||
|
<span
|
||||||
|
className="absolute top-0 right-0 bottom-0 w-56 pointer-events-none opacity-50"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(115deg, transparent 0%, transparent 55%, ${ACCENT} 55%, ${ACCENT} 58%, transparent 58%, transparent 72%, ${ACCENT} 72%, ${ACCENT} 74%, transparent 74%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Brand lockup — zino mark + Sales Ops subtitle */}
|
||||||
|
<div className="relative z-10 leading-tight">
|
||||||
|
<img src={`${import.meta.env.BASE_URL}zino.svg`} alt="Zino" className="h-7 w-auto" />
|
||||||
|
<div className="text-[10.5px] uppercase tracking-[0.18em] text-white/60 mt-2">AI Sales Ops</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Headline + feature bullets */}
|
||||||
|
<div className="relative z-10 max-w-md">
|
||||||
|
<div className="text-[12px] font-semibold uppercase tracking-[0.15em] mb-5" style={{ color: ACCENT }}>
|
||||||
|
AI · Test-drive scheduler
|
||||||
|
</div>
|
||||||
|
<h1 className="text-[2.6rem] font-bold leading-[1.05] tracking-tight mb-10 text-white">
|
||||||
|
Drive every lead<br />to the showroom floor.
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
{[
|
||||||
|
{ icon: <PhoneCall size={14} />, title: "Auto-dial leads", desc: "Maya, your AI scheduler, calls customers within seconds of capture." },
|
||||||
|
{ icon: <CalendarCheck2 size={14} />, title: "Live slot booking", desc: "Real-time availability against the dealer's calendar." },
|
||||||
|
{ icon: <Sparkles size={14} />, title: "Feedback loop", desc: "Post-drive AI follow-up captures NPS and concerns." },
|
||||||
|
].map((item) => (
|
||||||
|
<div key={item.title} className="flex items-start gap-3.5">
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
|
||||||
|
style={{ background: ACCENT, color: "white" }}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[13.5px] font-semibold text-white leading-tight">{item.title}</div>
|
||||||
|
<div className="text-[12px] mt-1 text-white/65 leading-relaxed">{item.desc}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer rule */}
|
||||||
|
<div className="relative z-10 text-[11px] uppercase tracking-[0.18em] text-white/40">
|
||||||
|
© Zino
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Right — login form ──────────────────────────────────────── */}
|
||||||
|
<div className="flex-1 flex items-center justify-center px-8" style={{ background: "white" }}>
|
||||||
|
<div className="w-full max-w-[360px]">
|
||||||
|
{/* Compact lockup for mobile (left panel hidden) */}
|
||||||
|
<div className="flex items-center mb-10 lg:hidden">
|
||||||
|
<img src={`${import.meta.env.BASE_URL}zino.svg`} alt="Zino" className="h-6 w-auto" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.15em] mb-2" style={{ color: ACCENT }}>
|
||||||
|
Sign in
|
||||||
|
</div>
|
||||||
|
<h2 className="text-[26px] font-bold tracking-tight leading-none mb-2" style={{ color: INK }}>
|
||||||
|
Welcome back
|
||||||
|
</h2>
|
||||||
|
<p className="text-[13px] text-stone-500">
|
||||||
|
Sign in to the test-drive operations console.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold uppercase tracking-[0.03em] text-stone-500 mb-2">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email" required autoComplete="email" autoFocus
|
||||||
|
value={email} onChange={(e) => { setEmail(e.target.value); clearError(); }}
|
||||||
|
onFocus={onFocus} onBlur={onBlur}
|
||||||
|
className="w-full h-11 px-3.5 text-[13.5px] bg-white text-stone-900 focus:outline-none transition placeholder:text-stone-400"
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="you@techmahindra.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold uppercase tracking-[0.03em] text-stone-500 mb-2">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password" required autoComplete="current-password"
|
||||||
|
value={password} onChange={(e) => { setPassword(e.target.value); clearError(); }}
|
||||||
|
onFocus={onFocus} onBlur={onBlur}
|
||||||
|
className="w-full h-11 px-3.5 text-[13.5px] bg-white text-stone-900 focus:outline-none transition placeholder:text-stone-400"
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="text-[12.5px] px-3.5 py-2.5 font-medium"
|
||||||
|
style={{ background: ACCENT_SOFT, border: `1px solid #fbb4be`, color: "#5f0229", borderRadius: 4 }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit" disabled={loading}
|
||||||
|
className="w-full h-11 mt-2 disabled:opacity-60 text-white text-[13.5px] font-semibold inline-flex items-center justify-center gap-2 transition-colors hover:brightness-110"
|
||||||
|
style={{ background: ACCENT, borderRadius: 4 }}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<><div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Signing in…</>
|
||||||
|
) : (
|
||||||
|
<>Sign in <ArrowRight size={14} /></>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-1.5 mt-12">
|
||||||
|
<span className="text-[11px] uppercase tracking-[0.12em] text-stone-400">Powered by</span>
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-[0.12em]" style={{ color: INK }}>Zino</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/vite-env.d.ts
vendored
Normal file
11
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_ZINO_API_URL?: string;
|
||||||
|
readonly VITE_ZINO_MOCK?: string;
|
||||||
|
readonly VITE_BASE_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
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 } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
14
tailwind.config.js
Normal file
14
tailwind.config.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Poppins', 'Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||||
|
brand: ['Poppins', 'arial', 'sans-serif'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
19
tsconfig.app.json
Normal file
19
tsconfig.app.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": false,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
15
tsconfig.node.json
Normal file
15
tsconfig.node.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
28
vite.config.ts
Normal file
28
vite.config.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
const devTarget = "https://sandbox.getzino.in";
|
||||||
|
const proxy = (target: string) => ({ target, changeOrigin: true, secure: false });
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
base: process.env.VITE_BASE_URL ?? "/tech_mahindra/",
|
||||||
|
server: {
|
||||||
|
port: 3005,
|
||||||
|
proxy: {
|
||||||
|
"/login": proxy(devTarget),
|
||||||
|
"/apps": proxy(devTarget),
|
||||||
|
"/agents": proxy(devTarget),
|
||||||
|
"/start": proxy(devTarget),
|
||||||
|
"/activity": proxy(devTarget),
|
||||||
|
"/instance": proxy(devTarget),
|
||||||
|
"/form": proxy(devTarget),
|
||||||
|
"/pa": proxy(devTarget),
|
||||||
|
"/recordview": proxy(devTarget),
|
||||||
|
"/detailview": proxy(devTarget),
|
||||||
|
"/audit": proxy(devTarget),
|
||||||
|
"/api-docs": proxy(devTarget),
|
||||||
|
"/app/": proxy(devTarget),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user