initial commit: p2p-dev — custom frontend wired to dev env (app 169)

This commit is contained in:
Bhanu Prakash Sai Potteri 2026-06-02 19:40:15 +05:30
commit dea13acd2b
46 changed files with 7906 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules/
dist/
.env
*.local
.DS_Store
*-local-command-caveatcaveat-*.txt

View File

@ -0,0 +1,36 @@
-- ============================================================
-- Frontgen Projects — Dev → Studio Migration
-- Run against: studio DB
--
-- Studio already has: clinic-workflow, support-ticket, customer-onboarding,
-- task-management, truck, p2p (already on studio URL)
-- Missing in studio: dealer-1, grfm-app, dealer-new-1, grfm, truck2, adani-dealer
-- ============================================================
BEGIN;
-- 1. Repoint zino_api_url for projects already in studio (excluding p2p which is correct)
UPDATE frontgen.projects
SET zino_api_url = 'https://studio.getzino.in'
WHERE id IN (
'09c3357e-9b6c-4a93-abaf-9464e90ebbe4', -- Clinic workflow
'14307640-8e37-4448-ba3c-c3672da68bbf', -- Support Ticket
'a22cd6ba-c5e4-4f7e-ac84-1db17559c075', -- Customer Onboarding
'242eb4da-066f-4c46-9f5b-479f3e1df3f1', -- Task Management
'9134befe-f74b-4c87-925d-d4eaa096dbf0' -- Truck
);
-- 2. Insert the 6 missing projects (all pre-pointed at studio)
INSERT INTO frontgen.projects (id, name, slug, framework, repo_name, preview_url, workflow_id, zino_api_url, last_build_status, last_build_at, last_commit_sha, created_at, updated_at) VALUES
('0afe0940-6ced-4310-bd95-acbda3d9da6e', 'dealer 1', 'dealer-1', 'react', 'dealer-1', 'https://preview.getzino.in/dealer-1/', 'fd7e72a5-5920-4d41-a660-40aa14a21fb6', 'https://studio.getzino.in', 'success', '2026-04-02 10:17:20.206652+00', '1bdd529a7bb461dee4ffc33c8c75dd7795f710a6', '2026-03-31 07:01:58.61003+00', '2026-04-02 10:17:20.209667+00'),
('0ca7e50c-ea3c-4238-9ae1-07cdd61c9023', 'GRFM APP', 'grfm-app', 'react', 'grfm-app', 'http://preview.getzino.in/grfm-app/', '7dabcd26-30a4-498c-82a8-0fc88f1ad0dc', 'https://studio.getzino.in', 'success', '2026-03-31 10:24:14.231953+00', '5b4c587976d2ba4ba5a8c3bc03617ee9e52c63a2', '2026-03-31 10:06:47.566653+00', '2026-03-31 10:24:14.23535+00'),
('69aa6f58-0e45-4562-8413-19de03f9f8b5', 'Dealer new 1', 'dealer-new-1', 'react', 'dealer-new-1', 'http://preview.getzino.in/dealer-new-1/', '817a45fe-882f-4502-b3cb-4ace65ca7b48', 'https://studio.getzino.in', 'success', '2026-04-10 13:30:05.24563+00', '9369958d64c266ef2c03afeab0c99117a7981388', '2026-04-08 05:58:17.232322+00', '2026-04-10 13:30:05.24978+00'),
('8660fc96-9a06-4813-956f-2a22c8a6c69f', 'GRFM', 'grfm', 'react', 'grfm', 'http://preview.getzino.in/grfm/', '276d050c-c668-4fb4-9d4c-c627fc71c217', 'https://studio.getzino.in', 'success', '2026-04-13 06:15:35.019749+00', 'f9917e10eb4534ecff57bf743393676d8c844b0e', '2026-04-13 06:00:26.479567+00', '2026-04-13 06:15:35.024828+00'),
('a12e234c-be37-4eb8-8410-0bff989e9b0b', 'Truck2', 'truck2', 'react', 'truck2', 'http://preview.getzino.in/truck2/', '6e144d23-dd76-478c-b330-6acd3b736129"', 'https://studio.getzino.in', 'success', '2026-04-13 11:56:50.316279+00', '9014d4522664b12315fae11f516632c20ada1617', '2026-04-13 11:31:17.284247+00', '2026-04-13 11:56:50.320774+00'),
('0782a310-8dbc-4032-93b4-98454ae33317', 'Adani Dealer', 'adani-dealer', 'react', 'adani-dealer', 'http://preview.getzino.in/adani-dealer/', 'fc0dc1f1-bf24-4e5b-a066-6d16efc95899', 'https://studio.getzino.in', 'success', '2026-04-16 05:57:06.831479+00', '22025ea5fe2a28df6cb954de9141edeb3d68ead0', '2026-04-16 05:40:06.970758+00', '2026-04-16 05:57:06.834982+00')
ON CONFLICT (id) DO NOTHING;
COMMIT;
-- Verify
SELECT id, name, slug, zino_api_url FROM frontgen.projects ORDER BY created_at;

16
index.html Normal file
View File

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

405
migration_dev_to_studio.sql Normal file

File diff suppressed because one or more lines are too long

164
p2p_demo_schema.sql Normal file
View File

@ -0,0 +1,164 @@
-- P2P Demo Schema Data
CREATE SCHEMA IF NOT EXISTS p2p_demo;
CREATE TABLE p2p_demo.contract_line_items (
id integer NOT NULL,
contract_id text,
product_code text NOT NULL,
description text,
unit_price numeric(12,2) NOT NULL,
currency text DEFAULT 'INR'::text
);
ALTER TABLE p2p_demo.contract_line_items OWNER TO postgres;
CREATE SEQUENCE p2p_demo.contract_line_items_id_seq
ALTER SEQUENCE p2p_demo.contract_line_items_id_seq OWNER TO postgres;
ALTER SEQUENCE p2p_demo.contract_line_items_id_seq OWNED BY p2p_demo.contract_line_items.id;
CREATE TABLE p2p_demo.contracts (
contract_id text NOT NULL,
vendor_id text NOT NULL,
vendor_name text NOT NULL,
effective_date date,
expiry_date date,
status text DEFAULT 'active'::text,
payment_terms text,
price_tolerance_pct numeric(5,2) DEFAULT 2.0,
quantity_tolerance_pct numeric(5,2) DEFAULT 5.0,
created_at timestamp without time zone DEFAULT now()
);
ALTER TABLE p2p_demo.contracts OWNER TO postgres;
CREATE TABLE p2p_demo.goods_receipts (
gr_number text NOT NULL,
po_number text NOT NULL,
vendor_id text NOT NULL,
receipt_date date,
received_by text,
notes text,
created_at timestamp without time zone DEFAULT now()
);
ALTER TABLE p2p_demo.goods_receipts OWNER TO postgres;
CREATE TABLE p2p_demo.gr_line_items (
id integer NOT NULL,
gr_number text,
product_code text NOT NULL,
quantity_received integer NOT NULL,
condition text DEFAULT 'good'::text
);
ALTER TABLE p2p_demo.gr_line_items OWNER TO postgres;
CREATE SEQUENCE p2p_demo.gr_line_items_id_seq
ALTER SEQUENCE p2p_demo.gr_line_items_id_seq OWNER TO postgres;
ALTER SEQUENCE p2p_demo.gr_line_items_id_seq OWNED BY p2p_demo.gr_line_items.id;
CREATE TABLE p2p_demo.payments (
payment_id text NOT NULL,
invoice_number text,
po_number text,
vendor_id text NOT NULL,
amount numeric(14,2) NOT NULL,
payment_date date,
status text DEFAULT 'completed'::text,
created_at timestamp without time zone DEFAULT now()
);
ALTER TABLE p2p_demo.payments OWNER TO postgres;
CREATE TABLE p2p_demo.po_line_items (
id integer NOT NULL,
po_number text,
product_code text NOT NULL,
description text,
quantity integer NOT NULL,
unit_price numeric(12,2) NOT NULL
);
ALTER TABLE p2p_demo.po_line_items OWNER TO postgres;
CREATE SEQUENCE p2p_demo.po_line_items_id_seq
ALTER SEQUENCE p2p_demo.po_line_items_id_seq OWNER TO postgres;
ALTER SEQUENCE p2p_demo.po_line_items_id_seq OWNED BY p2p_demo.po_line_items.id;
CREATE TABLE p2p_demo.purchase_orders (
po_number text NOT NULL,
vendor_id text NOT NULL,
vendor_name text NOT NULL,
contract_id text,
order_date date,
expected_delivery date,
status text DEFAULT 'open'::text,
total_amount numeric(14,2),
created_at timestamp without time zone DEFAULT now()
);
ALTER TABLE p2p_demo.purchase_orders OWNER TO postgres;
ALTER TABLE ONLY p2p_demo.contract_line_items ALTER COLUMN id SET DEFAULT nextval('p2p_demo.contract_line_items_id_seq'::regclass);
ALTER TABLE ONLY p2p_demo.contract_line_items ALTER COLUMN id SET DEFAULT nextval('p2p_demo.contract_line_items_id_seq'::regclass);
ALTER TABLE ONLY p2p_demo.gr_line_items ALTER COLUMN id SET DEFAULT nextval('p2p_demo.gr_line_items_id_seq'::regclass);
ALTER TABLE ONLY p2p_demo.gr_line_items ALTER COLUMN id SET DEFAULT nextval('p2p_demo.gr_line_items_id_seq'::regclass);
ALTER TABLE ONLY p2p_demo.po_line_items ALTER COLUMN id SET DEFAULT nextval('p2p_demo.po_line_items_id_seq'::regclass);
ALTER TABLE ONLY p2p_demo.po_line_items ALTER COLUMN id SET DEFAULT nextval('p2p_demo.po_line_items_id_seq'::regclass);
COPY p2p_demo.contract_line_items (id, contract_id, product_code, description, unit_price, currency) FROM stdin;
233 CNT-100 PRD-101 Steel Rods 12mm 1000.00 INR
234 CNT-100 PRD-102 Steel Plates 5mm 2500.00 INR
235 CNT-200 PRD-201 A4 Paper Ream (500 sheets) 200.00 INR
236 CNT-200 PRD-202 Printer Ink Cartridge 1500.00 INR
237 CNT-300 PRD-301 Circuit Board Type-A 500.00 INR
238 CNT-300 PRD-302 LED Panel 24inch 3500.00 INR
239 CNT-400 PRD-401 Safety Helmet 150.00 INR
240 CNT-400 PRD-402 Industrial Gloves (pair) 80.00 INR
\.
COPY p2p_demo.contracts (contract_id, vendor_id, vendor_name, effective_date, expiry_date, status, payment_terms, price_tolerance_pct, quantity_tolerance_pct, created_at) FROM stdin;
CNT-100 VND-001 Tata Steel Ltd 2026-01-01 2026-12-31 active Net 30 2.00 5.00 2026-03-30 18:34:11.089882
CNT-200 VND-002 Reliance Office Supplies 2026-01-01 2026-12-31 active Net 15 3.00 5.00 2026-03-30 18:34:11.091337
CNT-300 VND-003 Bharat Electronics 2026-01-01 2026-12-31 active Net 30 2.00 5.00 2026-03-30 18:34:11.092484
CNT-400 VND-004 Godrej Industrial 2026-01-01 2026-12-31 active 2/10 Net 30 2.00 5.00 2026-03-30 18:34:11.093581
\.
COPY p2p_demo.goods_receipts (gr_number, po_number, vendor_id, receipt_date, received_by, notes, created_at) FROM stdin;
GR-2001 PO-2001 VND-002 2026-03-18 Amit Kumar Full delivery received. All items in good condition. 2026-03-30 18:34:11.100119
GR-1001 PO-1001 VND-001 2026-03-18 Rajesh Sharma Partial delivery. 700 of 1000 units received. Vendor confirmed remaining 300 shipped separately, expected April 5. 2026-03-30 18:34:11.101225
GR-3001 PO-3001 VND-003 2026-03-18 Sunil Verma Full delivery received. QC passed. 2026-03-30 18:34:11.102194
GR-2002 PO-2002 VND-002 2026-03-18 Amit Kumar Full delivery received. 2026-03-30 18:34:11.103112
GR-1002 PO-1002 VND-001 2026-03-18 Rajesh Sharma Partial delivery. 600 of 800 plates received. Remaining 200 delayed due to transport issues. 2026-03-30 18:34:11.104018
\.
COPY p2p_demo.gr_line_items (id, gr_number, product_code, quantity_received, condition) FROM stdin;
146 GR-2001 PRD-201 500 good
147 GR-1001 PRD-101 700 good
148 GR-3001 PRD-301 200 good
149 GR-2002 PRD-202 50 good
150 GR-1002 PRD-102 600 good
\.
COPY p2p_demo.payments (payment_id, invoice_number, po_number, vendor_id, amount, payment_date, status, created_at) FROM stdin;
PAY-5001 INV-2026-00700 PO-2002 VND-002 75000.00 2026-03-10 completed 2026-03-30 18:34:11.10478
PAY-5002 INV-2026-00800 PO-1002 VND-001 200000.00 2026-03-05 completed 2026-03-30 18:34:11.105142
\.
COPY p2p_demo.po_line_items (id, po_number, product_code, description, quantity, unit_price) FROM stdin;
175 PO-2001 PRD-201 A4 Paper Ream (500 sheets) 500 200.00
176 PO-1001 PRD-101 Steel Rods 12mm 1000 1000.00
177 PO-3001 PRD-301 Circuit Board Type-A 200 500.00
178 PO-2002 PRD-202 Printer Ink Cartridge 50 1500.00
179 PO-4001 PRD-401 Safety Helmet 300 150.00
180 PO-1002 PRD-102 Steel Plates 5mm 800 1000.00
\.
COPY p2p_demo.purchase_orders (po_number, vendor_id, vendor_name, contract_id, order_date, expected_delivery, status, total_amount, created_at) FROM stdin;
PO-2001 VND-002 Reliance Office Supplies CNT-200 2026-02-15 2026-03-15 fully_received 100000.00 2026-03-30 18:34:11.094652
PO-1001 VND-001 Tata Steel Ltd CNT-100 2026-02-15 2026-03-15 partially_received 1000000.00 2026-03-30 18:34:11.095959
PO-3001 VND-003 Bharat Electronics CNT-300 2026-02-15 2026-03-15 fully_received 100000.00 2026-03-30 18:34:11.096847
PO-2002 VND-002 Reliance Office Supplies CNT-200 2026-02-15 2026-03-15 fully_received 75000.00 2026-03-30 18:34:11.09772
PO-4001 VND-004 Godrej Industrial CNT-400 2026-02-15 2026-03-15 open 45000.00 2026-03-30 18:34:11.098572
PO-1002 VND-001 Tata Steel Ltd CNT-100 2026-02-15 2026-03-15 partially_received 800000.00 2026-03-30 18:34:11.099363
\.
ALTER TABLE ONLY p2p_demo.contract_line_items
ALTER TABLE ONLY p2p_demo.contracts
ALTER TABLE ONLY p2p_demo.goods_receipts
ALTER TABLE ONLY p2p_demo.gr_line_items
ALTER TABLE ONLY p2p_demo.payments
ALTER TABLE ONLY p2p_demo.po_line_items
ALTER TABLE ONLY p2p_demo.purchase_orders
ALTER TABLE ONLY p2p_demo.contract_line_items
ALTER TABLE ONLY p2p_demo.gr_line_items
ALTER TABLE ONLY p2p_demo.po_line_items

2745
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "p2p-dev",
"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"
},
"devDependencies": {
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.41",
"tailwindcss": "^3.4.10",
"typescript": "~5.5.4",
"vite": "^5.4.2"
}
}

6
postcss.config.js Normal file
View File

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

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

9
public/zino-logo.svg Normal file
View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 90 36" fill="none">
<defs>
<linearGradient id="zinoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#C96442"/>
<stop offset="100%" stop-color="#E8A87C"/>
</linearGradient>
</defs>
<text x="45" y="26" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="24" font-weight="700" fill="url(#zinoGrad)" text-anchor="middle" letter-spacing="2">ZINO</text>
</svg>

After

Width:  |  Height:  |  Size: 480 B

9
public/zino.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 27 KiB

47
seed.sql Normal file

File diff suppressed because one or more lines are too long

37
src/App.tsx Normal file
View File

@ -0,0 +1,37 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { ProtectedRoute } from './components/ProtectedRoute'
import Login from './pages/Login'
import AppShell from './components/AppShell'
export default function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/screen/:screenId/detail/:instanceId"
element={
<ProtectedRoute>
<AppShell />
</ProtectedRoute>
}
/>
<Route
path="/screen/:screenId"
element={
<ProtectedRoute>
<AppShell />
</ProtectedRoute>
}
/>
<Route
path="/"
element={
<ProtectedRoute>
<AppShell />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}

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

@ -0,0 +1,422 @@
import { APP_ID } from "../config";
const TOKEN_KEY = "zino_token";
function baseUrl(): string {
return (import.meta.env.VITE_ZINO_API_URL || "").replace(/\/+$/, "");
}
function headers(): Record<string, string> {
const h: Record<string, string> = { "Content-Type": "application/json" };
const token = localStorage.getItem(TOKEN_KEY);
if (token) h["Authorization"] = `Bearer ${token}`;
return h;
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(`${baseUrl()}${path}`, {
method,
headers: headers(),
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 401) {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem("zino_user");
window.location.href = `${import.meta.env.BASE_URL}login`;
throw new Error("Unauthorized");
}
if (!res.ok) {
let msg = res.statusText;
try { const e = await res.json(); if (e.error) msg = e.error; } catch {}
throw new Error(msg);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
// ---------------------------------------------------------------------------
// Header
// ---------------------------------------------------------------------------
export interface HeaderConfig {
slots: { left: string[]; right: string[]; center: string[] };
layout: { type: string; height: string; position: string; full_width: boolean };
components: {
nav?: { items: NavItem[] };
logo?: { text?: string; image_url?: string; display_type: string };
profile?: { shape: string; show_name: boolean };
};
}
export interface NavItem {
id: string;
icon: string;
type: string;
label: string;
screen_uuid: string;
}
export async function getHeaderConfig(deviceType = "desktop"): Promise<HeaderConfig> {
const raw = await request<any>("GET", `/app/${APP_ID}/view/header/${deviceType}`);
return raw.config ?? raw;
}
// ---------------------------------------------------------------------------
// Screens
// ---------------------------------------------------------------------------
export interface ScreenListItem {
id: number;
source_screen_id: number;
screen_name: string;
device: string;
description: string;
}
export interface ScreenLayout {
id: number;
source_screen_id: number;
screen_name: string;
layout: LayoutElement[];
}
export interface LayoutElement {
type: string;
style?: Record<string, string>;
config: Record<string, any>;
classes?: string[];
}
export function getScreens(deviceType = "desktop"): Promise<ScreenListItem[]> {
return request("GET", `/app/${APP_ID}/view/screens?device_type=${deviceType}`);
}
export function getScreen(idOrUuid: number | string): Promise<ScreenLayout> {
return request("GET", `/app/${APP_ID}/view/screens/${idOrUuid}`);
}
// ---------------------------------------------------------------------------
// RV Screens
// ---------------------------------------------------------------------------
export interface RVScreen {
id: number;
source_screen_id: number;
screen_name: string;
rv_template_uid: string;
layout?: any;
}
// Accepts either the numeric source_screen_id OR the source_uuid — both
// are accepted by the view-service's rv-screens endpoint.
export function getRVScreen(idOrUuid: number | string): Promise<RVScreen> {
return request("GET", `/app/${APP_ID}/view/rv-screens/${idOrUuid}`);
}
// ---------------------------------------------------------------------------
// Record View (POST-based)
// ---------------------------------------------------------------------------
export interface SearchQuery {
page?: number;
limit?: number;
sort_by?: string;
sort_dir?: "asc" | "desc";
search?: string;
filters?: Array<{ field_key: string; value: string; data_type?: string }>;
}
export interface RecordViewField {
field_key: string;
output_label: string;
data_type: string;
is_filter: boolean;
is_search: boolean;
type?: string;
activity_id?: string;
}
export interface TileValue {
tile_uid: string;
key: string;
value: unknown;
}
export interface ChartDataRow {
dimension: unknown;
series?: string;
value: unknown;
}
export interface ChartDataResponse {
chart_uid: string;
key: string;
rows: ChartDataRow[];
}
export interface RecordViewResponse {
config: { fields: RecordViewField[] };
data: Record<string, unknown>[];
pagination?: {
page: number;
limit: number;
total_count: number;
total_pages: number;
};
tile_values?: TileValue[];
chart_data?: ChartDataResponse[];
}
export function getRecordView(
rvTemplateUid: string,
rvScreenId: string | number,
query: SearchQuery = {},
): Promise<RecordViewResponse> {
return request("POST", `/app/${APP_ID}/view/recordview`, {
rv_template_uid: rvTemplateUid,
rv_screen_id: String(rvScreenId),
search_query: {
page: query.page || 1,
limit: query.limit || 50,
sort_by: query.sort_by || "",
sort_dir: query.sort_dir || "desc",
search: query.search || "",
filters: query.filters || [],
},
});
}
export function getRecordViewLegacy(
rvUid: string,
query: SearchQuery = {},
): Promise<RecordViewResponse> {
const qs = new URLSearchParams();
qs.set("rv_id", rvUid);
if (query.page) qs.set("page", String(query.page));
if (query.limit) qs.set("limit", String(query.limit));
if (query.sort_by) qs.set("sort_by", query.sort_by);
if (query.sort_dir) qs.set("sort_dir", query.sort_dir);
if (query.search) qs.set("search", query.search);
if (query.filters) {
for (const f of query.filters) {
qs.set(`filter.${f.field_key}`, f.value);
}
}
return request("GET", `/recordview?${qs.toString()}`);
}
// ---------------------------------------------------------------------------
// DV Screens
// ---------------------------------------------------------------------------
export interface DVScreen {
id: number;
source_screen_id: number;
screen_name: string;
dv_template_uid: string;
layout?: any;
}
export function getDVScreen(idOrUuid: number | string): Promise<DVScreen> {
return request("GET", `/app/${APP_ID}/view/dv-screens/${idOrUuid}`);
}
// ---------------------------------------------------------------------------
// Detail View
// ---------------------------------------------------------------------------
export interface DetailViewResponse {
config: { fields: Array<{ field_key: string; output_label: string; data_type: string }> };
data: Record<string, unknown>;
}
export function getDetailView(dvSourceIdOrUid: number | string, instanceId: string): Promise<DetailViewResponse> {
return request(
"GET",
`/app/${APP_ID}/view/detailview/${encodeURIComponent(String(dvSourceIdOrUid))}?instance_id=${encodeURIComponent(instanceId)}`,
);
}
export function getDetailViewLegacy(dvUid: string, instanceId: string): Promise<DetailViewResponse> {
return request(
"GET",
`/detailview?dv_id=${encodeURIComponent(dvUid)}&instance_id=${encodeURIComponent(instanceId)}`,
);
}
// ---------------------------------------------------------------------------
// Form Screens
// ---------------------------------------------------------------------------
export interface FormScreenField {
id: string;
uid: string;
name: string;
type: string;
data_type: string;
mandatory: boolean;
value?: any;
properties?: {
options?: Array<{ label: string; value: string }>;
country_code?: string;
};
}
export interface FormScreenResponse {
id: number;
activity_uid: string;
activity_name: string;
device_type: string;
fields: FormScreenField[];
grid_config: Array<{ i: string; x: number; y: number; w: number; h: number }>;
layout: any[];
}
export function getFormScreen(
activityId: string,
instanceId?: string,
deviceType = "desktop",
): Promise<FormScreenResponse> {
const numericInstanceId = instanceId ? Number(instanceId) : undefined;
return request("POST", `/app/${APP_ID}/view/form-screens`, {
activity_id: activityId,
device_type: deviceType,
...(numericInstanceId && !Number.isNaN(numericInstanceId)
? { instance_id: numericInstanceId }
: {}),
});
}
// ---------------------------------------------------------------------------
// Workflow Actions
// ---------------------------------------------------------------------------
export interface WorkflowResponse {
success: boolean;
status_code?: number;
message?: string;
data?: Record<string, unknown>;
instance_id?: string;
}
// Core-service expects the workflow UUID as `workflow_uuid` — the numeric
// `workflow_id` field was removed when the clone-portable identifier rolled out.
export function startWorkflow(
workflowUuid: string,
activityId: string,
data?: Record<string, unknown>,
): Promise<WorkflowResponse> {
return request("POST", `/app/${APP_ID}/start`, {
workflow_uuid: workflowUuid,
activity_id: activityId,
data,
});
}
export function performActivity(
workflowUuid: string,
instanceId: string,
activityId: string,
data?: Record<string, unknown>,
): Promise<WorkflowResponse> {
const numericInstanceId = Number(instanceId);
return request("POST", `/app/${APP_ID}/activity`, {
workflow_uuid: workflowUuid,
instance_id: Number.isNaN(numericInstanceId) ? instanceId : numericInstanceId,
activity_id: activityId,
data,
});
}
export function submitForm(
workflowUuid: string,
activityId: string,
formData: Record<string, unknown>,
instanceId?: string,
deviceType = "desktop",
): Promise<WorkflowResponse> {
const numericInstanceId = instanceId ? Number(instanceId) : undefined;
return request("POST", `/app/${APP_ID}/form/submit`, {
workflow_uuid: workflowUuid,
activity_id: activityId,
device_type: deviceType,
form_data: formData,
...(numericInstanceId && !Number.isNaN(numericInstanceId)
? { instance_id: numericInstanceId }
: {}),
});
}
// ---------------------------------------------------------------------------
// Instance
// ---------------------------------------------------------------------------
export interface InstanceResponse {
instance_id: string;
workflow_id: string;
current_state_id: string;
current_state_name: string;
data: Record<string, unknown>;
created_at: string;
updated_at: string;
}
export function getInstance(workflowUuid: string, instanceId: string): Promise<InstanceResponse> {
return request("POST", `/app/${APP_ID}/instance`, {
workflow_uuid: workflowUuid,
instance_id: Number(instanceId),
});
}
// ---------------------------------------------------------------------------
// Audit
// ---------------------------------------------------------------------------
export interface AuditEntry {
id: number;
user_id: string;
user_roles: string[];
activity_id: string;
data: Record<string, unknown>;
execution_state: string;
created_at: string;
// Optional human-readable summary line. Populated by useInstanceMeta when
// the audit is synthesised from instance.data._activities.
context?: string;
}
export function getAuditLog(instanceId: string): Promise<AuditEntry[]> {
return request("GET", `/app/${APP_ID}/view/audit?instance_id=${encodeURIComponent(instanceId)}`);
}
// ---------------------------------------------------------------------------
// RDBMS lookup records — used by the demo calendar to pull rows from the
// `mahindra_demo` schema via the lookup-field config on the INIT activity.
// ---------------------------------------------------------------------------
export interface RdbmsLookupResponse {
records: Record<string, unknown>[];
total: number;
limit: number;
offset: number;
}
export interface RdbmsLookupQuery {
workflowId: number;
activityId: string;
fieldId: string;
limit?: number;
offset?: number;
search?: string;
}
export function fetchRdbmsLookupRecords(templateUuid: string, q: RdbmsLookupQuery): Promise<RdbmsLookupResponse> {
return request("POST", `/app/${APP_ID}/rdbms-templates/${encodeURIComponent(templateUuid)}/records`, {
workflow_id: q.workflowId,
activity_id: q.activityId,
field_id: q.fieldId,
limit: q.limit ?? 1000,
offset: q.offset ?? 0,
search: q.search ?? "",
});
}

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

@ -0,0 +1,302 @@
import { useEffect, useState, useCallback } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Loader2, CheckCircle2, XCircle, PauseCircle, MessageSquare, Clock } from "lucide-react";
import DynamicHeader from "./DynamicHeader";
import DashboardStats from "./DashboardStats";
import ScreenRenderer from "./ScreenRenderer";
import { TableSkeleton } from "./Skeleton";
import DetailViewPanel from "./DetailViewPanel";
import FormModal from "./FormModal";
import { getHeaderConfig, getScreen, getAuditLog, getInstance, type LayoutElement, type NavItem, type AuditEntry } from "../api/viewService";
import { WORKFLOW_ID, ACTIVITY_IDS, STATE_IDS, SCREEN_UUIDS, RDBMS_RV_SCREEN_UUID, RDBMS_DV_SCREEN_UUID } from "../config";
import RecordViewTable from "./RecordViewTable";
// Keys + values are UUIDs (source_uuid from tbl_appconfig_rv_screens / dv_screens / screens).
const RDBMS_SCREENS: Record<string, string> = {
[RDBMS_RV_SCREEN_UUID]: RDBMS_RV_SCREEN_UUID, // Purchase Orders (Vendor Data) rv_screen
};
const RDBMS_SCREEN_LABELS: Record<string, string> = {
[RDBMS_RV_SCREEN_UUID]: "Purchase Orders",
};
// RDBMS list screen → DV screen (both are dv/rv screen source_uuids).
const RDBMS_DETAIL_SCREENS: Record<string, string> = {
[RDBMS_RV_SCREEN_UUID]: RDBMS_DV_SCREEN_UUID,
};
// List screen → detail screen (tbl_appconfig_screens source_uuid).
const SCREEN_TO_DETAIL_SCREEN: Record<string, string> = {
[SCREEN_UUIDS.MY_INVOICES]: SCREEN_UUIDS.INVOICE_DETAIL,
[SCREEN_UUIDS.PENDING_CLARIFICATIONS]: SCREEN_UUIDS.CLARIFICATION_DETAIL,
[SCREEN_UUIDS.FINANCE_REVIEW]: SCREEN_UUIDS.FINANCE_REVIEW_DETAIL,
[SCREEN_UUIDS.ALL_INVOICES]: SCREEN_UUIDS.INVOICE_DETAIL,
};
const ACTIVITY_META: Record<string, { label: string; icon: React.ReactNode; style: "primary" | "danger" | "ghost" }> = {
[ACTIVITY_IDS.PROVIDE_CLARIFICATION]: { label: "Provide Clarification", icon: <MessageSquare size={14} />, style: "primary" },
[ACTIVITY_IDS.APPROVE_PAYMENT]: { label: "Approve", icon: <CheckCircle2 size={14} />, style: "primary" },
[ACTIVITY_IDS.REJECT_PAYMENT]: { label: "Reject", icon: <XCircle size={14} />, style: "danger" },
[ACTIVITY_IDS.HOLD_PAYMENT]: { label: "Hold", icon: <PauseCircle size={14} />, style: "ghost" },
};
const BTN_STYLES: Record<string, string> = {
primary: "text-white shadow-lg shadow-violet-500/25 hover:shadow-violet-500/40 hover:scale-[1.02]",
danger: "text-red-600 dark:text-red-400 bg-white dark:bg-zinc-900 border border-red-200 dark:border-red-800 hover:bg-red-50 dark:hover:bg-red-950/30",
ghost: "text-slate-600 dark:text-zinc-300 bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-700 hover:bg-slate-50 dark:hover:bg-zinc-800",
};
export default function AppShell() {
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 [detailViewId, setDetailViewId] = useState<string | null>(null);
const [detailScreenLayout, setDetailScreenLayout] = useState<LayoutElement[] | null>(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
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 {
setReady(true);
if (instanceIdParam && screenIdParam) {
setDetailInstanceId(instanceIdParam);
setDetailViewId(SCREEN_TO_DETAIL_SCREEN[screenIdParam] ?? RDBMS_DETAIL_SCREENS[screenIdParam] ?? SCREEN_UUIDS.INVOICE_DETAIL);
}
}
}, []);
// Sync detail state with URL params on browser back/forward
useEffect(() => {
if (!params.instanceId && detailInstanceId) {
setDetailInstanceId(null);
setDetailViewId(null);
setDetailScreenLayout(null);
setInstanceState(null);
setAudit([]);
} else if (params.instanceId && params.instanceId !== detailInstanceId) {
setDetailInstanceId(params.instanceId);
const sid = params.screenId ?? activeScreenId;
if (sid) setDetailViewId(SCREEN_TO_DETAIL_SCREEN[sid] ?? RDBMS_DETAIL_SCREENS[sid] ?? SCREEN_UUIDS.INVOICE_DETAIL);
}
}, [params.instanceId]);
// Load screen layout (skip for RDBMS screens — rendered directly)
useEffect(() => {
if (!activeScreenId || detailInstanceId) return;
if (RDBMS_SCREENS[activeScreenId]) { setLoading(false); return; }
setLoading(true);
getScreen(activeScreenId)
.then((s) => setLayout(s.layout ?? []))
.catch(console.error)
.finally(() => setLoading(false));
}, [activeScreenId, detailInstanceId]);
// Load detail screen
useEffect(() => {
if (!detailViewId) return;
getScreen(detailViewId)
.then((s) => setDetailScreenLayout(s.layout ?? []))
.catch(() => setDetailScreenLayout(null));
}, [detailViewId]);
// 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]);
const handleNavigate = useCallback((id: string) => {
setActiveScreenId(id); setDetailInstanceId(null); setDetailViewId(null);
setDetailScreenLayout(null); setInstanceState(null); setAudit([]);
if (RDBMS_SCREENS[id]) setLayout([]);
navigate(`/screen/${id}`);
}, [navigate]);
const handleRowClick = useCallback((instanceId: string) => {
const dvScreenId = activeScreenId ? (SCREEN_TO_DETAIL_SCREEN[activeScreenId] ?? SCREEN_UUIDS.INVOICE_DETAIL) : SCREEN_UUIDS.INVOICE_DETAIL;
setDetailInstanceId(instanceId);
setDetailViewId(dvScreenId);
navigate(`/screen/${activeScreenId}/detail/${instanceId}`);
}, [activeScreenId, navigate]);
const handleBack = () => {
setDetailInstanceId(null); setDetailViewId(null); setDetailScreenLayout(null);
setInstanceState(null); setAudit([]);
navigate(`/screen/${activeScreenId}`);
};
const handleFormSuccess = () => {
setFormModal(null);
if (detailInstanceId) {
const v = detailViewId; setDetailViewId(null); setTimeout(() => setDetailViewId(v), 50);
getAuditLog(detailInstanceId).then(setAudit).catch(() => {});
}
};
const actions = (() => {
if (!instanceState) return [];
if (instanceState === STATE_IDS.AWAITING_CLARIFICATION) return [ACTIVITY_IDS.PROVIDE_CLARIFICATION];
if (instanceState === STATE_IDS.FINANCE_REVIEW) return [ACTIVITY_IDS.APPROVE_PAYMENT, ACTIVITY_IDS.REJECT_PAYMENT, ACTIVITY_IDS.HOLD_PAYMENT];
return [];
})();
if (!ready) return (
<div className="min-h-screen bg-slate-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-violet-500" />
<span className="text-[12px] text-slate-400 dark:text-zinc-600">Loading</span>
</div>
</div>
);
return (
<div className="min-h-screen bg-slate-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 && detailViewId ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
{/* Breadcrumb */}
<nav className="flex items-center gap-1.5 text-[13px]">
<button
onClick={handleBack}
className="text-slate-400 dark:text-zinc-500 hover:text-violet-600 dark:hover:text-violet-400 transition-colors font-medium"
>
{navItems.find(n => n.screen_uuid === activeScreenId)?.label ?? (activeScreenId ? RDBMS_SCREEN_LABELS[activeScreenId] : undefined) ?? "Invoices"}
</button>
<span className="text-slate-300 dark:text-zinc-700">/</span>
<span className="text-slate-700 dark:text-zinc-200 font-medium truncate max-w-xs">
{detailInstanceId}
</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-8 px-3.5 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, #7c3aed, #a855f7)" } : {}}
>
{m.icon}{m.label}
</button>
);
})}
</div>
)}
</div>
{detailScreenLayout ? <ScreenRenderer layout={detailScreenLayout} instanceId={detailInstanceId} /> : <DetailViewPanel viewId={detailViewId} instanceId={detailInstanceId} />}
{audit.length > 0 && (
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 overflow-hidden">
<div className="px-5 py-3 border-b border-slate-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(124, 58, 237, 0.1)" }}>
<Clock size={13} className="text-violet-600 dark:text-violet-400" />
</div>
<h3 className="text-[13px] font-semibold text-slate-700 dark:text-zinc-200">Activity Timeline</h3>
<span className="text-[11px] text-slate-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, #7c3aed40, #a855f720, 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-violet-200 dark:ring-violet-900"
: isFirst
? ""
: ""
}`}
style={{
background: isLast ? "#7c3aed" : isFirst ? "#10b981" : "#d1d5db",
boxShadow: isLast ? "0 0 8px rgba(124, 58, 237, 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-violet-700 dark:text-violet-300" : "text-slate-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(124, 58, 237, 0.1)", color: "#7c3aed" }}>
Latest
</span>
)}
</div>
<span className="text-[11px] text-slate-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 ? (
<><DashboardStats /><TableSkeleton rows={6} cols={6} /></>
) : activeScreenId && RDBMS_SCREENS[activeScreenId] ? (
<RecordViewTable
key={`rdbms-${activeScreenId}`}
viewId={RDBMS_SCREENS[activeScreenId]}
onRowClick={RDBMS_DETAIL_SCREENS[activeScreenId] ? (pkValue) => {
setDetailInstanceId(pkValue);
setDetailViewId(RDBMS_DETAIL_SCREENS[activeScreenId]);
navigate(`/screen/${activeScreenId}/detail/${pkValue}`);
} : undefined}
/>
) : layout.length > 0 ? (
<><DashboardStats key={`stats-${activeScreenId}`} /><ScreenRenderer key={`screen-${activeScreenId}-${refreshKey}`} layout={layout} onRowClick={handleRowClick} onRefresh={() => setRefreshKey((k) => k + 1)} /></>
) : (
<div className="text-center py-20 text-sm text-slate-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>
);
}
function fmtAct(id: string): string { return id.replace(/^p2p-act-/, "").replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); }

View File

@ -0,0 +1,84 @@
import { useEffect, useState } from "react";
import { FileText, AlertCircle, Clock, CheckCircle2 } from "lucide-react";
import { getRecordView, getRVScreen } from "../api/viewService";
import { StatsSkeleton } from "./Skeleton";
import { ALL_INVOICES_RV_SCREEN_UUID, RECORD_VIEW_IDS } from "../config";
const ALL_INVOICES_VIEW = ALL_INVOICES_RV_SCREEN_UUID;
interface Stats {
total: number;
pendingClarification: number;
financeReview: number;
approved: number;
}
export default function DashboardStats() {
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchStats() {
try {
const rvScreen = await getRVScreen(ALL_INVOICES_VIEW);
const id = rvScreen.rv_template_uid;
const rvUid = RECORD_VIEW_IDS.ALL_INVOICES;
// Step 1: get exact total from pagination
const countRes = await getRecordView(id, rvUid, { page: 1, limit: 1 });
const total = countRes.pagination?.total_count ?? 0;
let pendingClarification = 0, financeReview = 0, approved = 0;
if (total > 0) {
// Step 2: fetch all rows in one call using exact total as limit
const allRes = await getRecordView(id, rvUid, { page: 1, limit: total });
for (const row of allRes.data ?? []) {
const state = String(row.current_state_name ?? "");
if (state === "Awaiting Clarification") pendingClarification++;
else if (state === "Finance Review") financeReview++;
else if (state === "Approved") approved++;
}
}
setStats({ total, pendingClarification, financeReview, approved });
} catch {
setStats({ total: 0, pendingClarification: 0, financeReview: 0, approved: 0 });
} finally {
setLoading(false);
}
}
fetchStats();
}, []);
if (loading) return <StatsSkeleton />;
if (!stats) return null;
const cards = [
{ label: "Total Invoices", value: stats.total, icon: FileText, color: "text-slate-500 dark:text-zinc-400", bg: "bg-slate-100 dark:bg-zinc-800" },
{ label: "Pending Clarification", value: stats.pendingClarification, icon: AlertCircle, color: "text-amber-600 dark:text-amber-400", bg: "bg-amber-50 dark:bg-amber-950/40" },
{ label: "Finance Review", value: stats.financeReview, icon: Clock, color: "text-indigo-600 dark:text-indigo-400", bg: "bg-indigo-50 dark:bg-indigo-950/40" },
{ label: "Approved", value: stats.approved, icon: CheckCircle2, color: "text-emerald-600 dark:text-emerald-400", bg: "bg-emerald-50 dark:bg-emerald-950/40" },
];
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-5">
{cards.map((card) => {
const Icon = card.icon;
return (
<div key={card.label} className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 px-5 py-4 flex items-center gap-4">
<div className={`w-9 h-9 rounded-xl flex items-center justify-center shrink-0 ${card.bg}`}>
<Icon size={16} className={card.color} />
</div>
<div className="min-w-0">
<div className="text-[22px] font-bold tabular-nums text-slate-900 dark:text-zinc-50 leading-none mb-0.5">
{card.value}
</div>
<div className="text-[11px] text-slate-400 dark:text-zinc-500 truncate">{card.label}</div>
</div>
</div>
);
})}
</div>
);
}

View File

@ -0,0 +1,363 @@
import { useEffect, useState, useRef } from "react";
import { Hash, Calendar, Sparkles, TrendingUp, MessageSquare, ChevronDown, ChevronUp } from "lucide-react";
import { getDVScreen, getDetailView } from "../api/viewService";
import { DetailSkeleton } from "./Skeleton";
interface Field { field_key: string; output_label: string; data_type: string }
interface Props { viewId: number | string; instanceId: string }
type GroupKey = "hero" | "vendor" | "financial" | "dates" | "ai" | "general";
// ---------------------------------------------------------------------------
// Classification
// ---------------------------------------------------------------------------
function classify(key: string): GroupKey {
const k = key.toLowerCase();
if (["invoice_number", "invoice_no", "inv_number", "po_number", "po_no", "order_number"].includes(k)) return "hero";
if (k.startsWith("ai_") || k.includes("recommendation") || k.includes("confidence") ||
k.includes("clarification") || k.includes("analysis") || k.includes("risk") ||
k.includes("anomal") || k.includes("match_summary") || k.includes("reasoning") ||
k.includes("reviewer") || k.includes("rejection") || k.includes("hold_reason") ||
k.includes("response_text") || k.includes("response_notes") ||
k.includes("confirmed_quantity") || k === "target_department" ||
k.includes("recommended_amount") || k.includes("approved_amount")) return "ai";
if (k.startsWith("vendor") || k.startsWith("supplier") || k === "gstin" || k === "pan") return "vendor";
if (["total_amount","tax_amount","net_amount","gross_amount","subtotal","discount",
"tds","cgst","sgst","igst","cess","line_total","line_amount"].includes(k) ||
["price","cost","payment"].some(x => k.includes(x))) return "financial";
if (k.includes("date") || k.includes("due") || k.includes("delivery") || k.includes("expected") ||
k === "period" || k === "financial_year") return "dates";
return "general";
}
// ---------------------------------------------------------------------------
// Status
// ---------------------------------------------------------------------------
const STATUS: Record<string, { pill: string; dot: string; bar: string; pulse?: boolean }> = {
"Invoice Submitted": { pill: "text-sky-700 bg-sky-50 ring-sky-200 dark:text-sky-300 dark:bg-sky-950/50 dark:ring-sky-800/60", dot: "bg-sky-500", bar: "bg-sky-500" },
"AI Analysis": { pill: "text-violet-700 bg-violet-50 ring-violet-200 dark:text-violet-300 dark:bg-violet-950/50 dark:ring-violet-800/60", dot: "bg-violet-500", bar: "bg-violet-500", pulse: true },
"Awaiting Clarification": { pill: "text-amber-700 bg-amber-50 ring-amber-200 dark:text-amber-300 dark:bg-amber-950/50 dark:ring-amber-800/60", dot: "bg-amber-500", bar: "bg-amber-400" },
"Finance Review": { pill: "text-indigo-700 bg-indigo-50 ring-indigo-200 dark:text-indigo-300 dark:bg-indigo-950/50 dark:ring-indigo-800/60", dot: "bg-indigo-500", bar: "bg-indigo-500" },
"Approved": { pill: "text-emerald-700 bg-emerald-50 ring-emerald-200 dark:text-emerald-300 dark:bg-emerald-950/50 dark:ring-emerald-800/60", dot: "bg-emerald-500", bar: "bg-emerald-500" },
"Rejected": { pill: "text-red-700 bg-red-50 ring-red-200 dark:text-red-300 dark:bg-red-950/50 dark:ring-red-800/60", dot: "bg-red-500", bar: "bg-red-500" },
"On Hold": { pill: "text-slate-600 bg-slate-100 ring-slate-200 dark:text-zinc-400 dark:bg-zinc-800 dark:ring-zinc-700", dot: "bg-slate-400", bar: "bg-slate-400" },
// PO statuses
"Pending": { pill: "text-amber-700 bg-amber-50 ring-amber-200 dark:text-amber-300 dark:bg-amber-950/50 dark:ring-amber-800/60", dot: "bg-amber-500", bar: "bg-amber-400" },
"Delivered": { pill: "text-sky-700 bg-sky-50 ring-sky-200 dark:text-sky-300 dark:bg-sky-950/50 dark:ring-sky-800/60", dot: "bg-sky-500", bar: "bg-sky-500" },
"Cancelled": { pill: "text-red-700 bg-red-50 ring-red-200 dark:text-red-300 dark:bg-red-950/50 dark:ring-red-800/60", dot: "bg-red-500", bar: "bg-red-500" },
};
const DS: { pill: string; dot: string; bar: string; pulse?: boolean } = { pill: "text-slate-600 bg-slate-100 ring-slate-200 dark:text-zinc-400 dark:bg-zinc-800 dark:ring-zinc-700", dot: "bg-slate-400", bar: "bg-slate-300" };
// Fields that are raw JSON — don't render
const SKIP_KEYS = new Set(["analysis_so_far"]);
// ---------------------------------------------------------------------------
// 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", "po_status"]);
const groups: Record<GroupKey, Field[]> = { hero: [], vendor: [], financial: [], dates: [], ai: [], general: [] };
for (const f of fields) {
if (sys.has(f.field_key) || SKIP_KEYS.has(f.field_key)) continue;
const v = data[f.field_key];
const g = classify(f.field_key);
if (g !== "hero" && (v == null || v === "")) continue;
groups[g].push(f);
}
const heroField = groups.hero[0];
const heroValue = heroField ? String(data[heroField.field_key] ?? "") : "";
const stateName = String(data.current_state_name ?? data.po_status ?? "");
const sc = STATUS[stateName] ?? DS;
// Primary amount shown large in header, removed from financials grid
const primaryF = groups.financial.find(f =>
["total_amount","amount","net_amount","gross_amount"].includes(f.field_key.toLowerCase())
);
const primaryAmt = primaryF ? Number(data[primaryF.field_key] ?? 0) : null;
const financials = groups.financial.filter(f => f !== primaryF);
// Vendor name for subtitle
const vendorNameF = groups.vendor.find(f => f.field_key === "vendor_name");
const vendorName = vendorNameF ? String(data.vendor_name ?? "") : "";
// Left columns: dates + general + vendor (compact metadata)
const isPoView = !!data.po_status || !!data.po_number;
const leftGroups = [
{ label: isPoView ? "Order Details" : "Invoice Details", fields: [...groups.dates, ...groups.general] },
{ label: "Vendor", fields: groups.vendor },
].filter(g => g.fields.length > 0);
// AI fields: short = inline grid (includes confidence bar), long = full-width text blocks
const isScore = (f: Field) => f.field_key.toLowerCase().includes("confidence") || 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-slate-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-slate-100 dark:border-zinc-800">
{/* Row 1: invoice number + status + amount */}
<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-slate-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>
{primaryAmt !== null && primaryAmt > 0 && (
<div className="text-right shrink-0">
<p className="text-[10px] font-medium text-slate-400 dark:text-zinc-600 mb-0.5 uppercase tracking-wider">Total Amount</p>
<p className="text-[26px] font-bold tabular-nums text-slate-900 dark:text-white leading-none">
{fmtCurrency(primaryAmt)}
</p>
</div>
)}
</div>
{/* Row 2: vendor + meta chips */}
<div className="flex items-center gap-3 flex-wrap text-[12px]">
{vendorName && (
<span className="font-medium text-slate-600 dark:text-zinc-300">{vendorName}</span>
)}
{vendorName && (data.created_at || data.instance_id) && (
<span className="text-slate-200 dark:text-zinc-700">·</span>
)}
{data.created_at && (
<span className="flex items-center gap-1 text-slate-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-slate-400 dark:text-zinc-500 font-mono text-[11px]">
<Hash size={10} />{String(data.instance_id).slice(0, 16)}
</span>
)}
</div>
</div>
{/* ── Two-column metadata ──────────────────────────────────────── */}
{(leftGroups.length > 0 || financials.length > 0) && (
<div className="grid lg:grid-cols-[3fr_2fr] divide-y lg:divide-y-0 lg:divide-x divide-slate-100 dark:divide-zinc-800 border-b border-slate-100 dark:border-zinc-800">
{/* Left — invoice details + vendor */}
<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-slate-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-slate-400 dark:text-zinc-500 mb-0.5">{fmtLabel(f.output_label)}</dt>
<dd className="text-[14px] font-medium text-slate-900 dark:text-zinc-100">
{fmtVal(data[f.field_key], f.data_type, f.field_key)}
</dd>
</div>
))}
</dl>
</section>
))}
</div>
{/* Right — financials */}
<div className="px-6 py-5">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 dark:text-zinc-600 mb-3">Financials</p>
{financials.length > 0 ? (
<div>
{financials
.filter(f => !f.field_key.toLowerCase().includes("total") && !f.field_key.toLowerCase().includes("net"))
.map(f => {
const n = Number(data[f.field_key] ?? 0);
return (
<div key={f.field_key} className="flex justify-between py-2.5 border-b border-slate-50 dark:border-zinc-800/50">
<span className="text-[13px] text-slate-500 dark:text-zinc-400">{fmtLabel(f.output_label)}</span>
<span className="text-[13px] tabular-nums text-slate-700 dark:text-zinc-200">{n ? fmtCurrency(n) : "—"}</span>
</div>
);
})}
{financials
.filter(f => f.field_key.toLowerCase().includes("total") || f.field_key.toLowerCase().includes("net"))
.map(f => {
const n = Number(data[f.field_key] ?? 0);
return (
<div key={f.field_key} className="flex justify-between pt-3 mt-1">
<span className="text-[13px] font-semibold text-slate-700 dark:text-zinc-200 flex items-center gap-1.5">
<TrendingUp size={12} className="text-emerald-500" />{fmtLabel(f.output_label)}
</span>
<span className="text-[15px] font-bold tabular-nums text-slate-900 dark:text-white">{n ? fmtCurrency(n) : "—"}</span>
</div>
);
})}
</div>
) : (
<p className="text-[13px] text-slate-300 dark:text-zinc-700"></p>
)}
</div>
</div>
)}
{/* ── AI Analysis — full width ─────────────────────────────────── */}
{groups.ai.length > 0 && (
<div className="px-7 py-5">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 dark:text-zinc-600 mb-4 flex items-center gap-1.5">
<Sparkles size={10} className="text-violet-500" />AI Analysis
</p>
{/* Short fields + confidence bars — inline grid */}
{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-slate-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-slate-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-slate-900 dark:text-zinc-100">
{fmtVal(val, f.data_type, f.field_key)}
</dd>
)}
</div>
);
})}
</dl>
)}
{/* Long text fields — full-width clamped paragraphs */}
{aiLong.length > 0 && (
<div className="space-y-4 border-t border-slate-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-slate-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-slate-400 dark:text-zinc-500">{fmtLabel(f.output_label)}</span>
</div>
<ClampedText text={String(val ?? "—")} />
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// ClampedText — show 3 lines, expand on demand
// ---------------------------------------------------------------------------
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-slate-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-violet-600 dark:text-violet-400 hover:text-violet-700 dark:hover:text-violet-300 transition-colors"
>
{expanded ? <><ChevronUp size={12} /> Show less</> : <><ChevronDown size={12} /> Show more</>}
</button>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function fmtLabel(label: string): string {
return label.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
}
function fmtVal(val: unknown, type: string, key: string): string {
if (val == null || val === "") return "—";
if (type === "number") {
const n = Number(val);
if (["amount","tax","price","cost"].some(x => key.toLowerCase().includes(x))) return fmtCurrency(n);
return n.toLocaleString("en-IN");
}
if (type === "datetime" || type === "date") return fmtDate(String(val));
return String(val);
}
function fmtCurrency(n: number): string {
return n.toLocaleString("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 0 });
}
function fmtDate(val: string): string {
try { return new Date(val).toLocaleDateString("en-IN", { day: "2-digit", month: "short", year: "numeric" }); }
catch { return val; }
}

View File

@ -0,0 +1,251 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
LogOut, FileText, HelpCircle, Landmark, List,
Settings, PanelLeftClose, PanelLeftOpen, Sun, Moon, ShoppingCart,
} from "lucide-react";
import { getHeaderConfig, type HeaderConfig, type NavItem } from "../api/viewService";
import { useAuthContext } from "../hooks/AuthContext";
import { useTheme } from "../hooks/ThemeContext";
import { RDBMS_RV_SCREEN_UUID } from "../config";
const ICON_MAP: Record<string, React.ReactNode> = {
receipt_long: <FileText size={16} />,
help_outline: <HelpCircle size={16} />,
account_balance: <Landmark size={16} />,
list_alt: <List size={16} />,
inventory_2: <FileText size={16} />,
question_mark: <HelpCircle size={16} />,
list: <List size={16} />,
};
const EXTRA_NAV_ITEMS = [
{ id: "vendor-data", screen_uuid: RDBMS_RV_SCREEN_UUID, label: "Purchase Orders", icon: <ShoppingCart 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 handleLogout = () => { logout(); navigate("/login"); };
const initials = (user?.name || "U")
.split(" ").map((n) => n[0]).join("").slice(0, 2).toUpperCase();
const roleName = user?.roles?.[0]
?.replace(/^p2p_/, "").replace(/_/g, " ") ?? "";
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-slate-200/70 dark:border-zinc-800/70 sticky top-0 z-50 h-14 flex items-center px-4 justify-between">
{/* Left */}
<div className="flex items-center gap-3">
<button
onClick={toggleSidebar}
className="w-8 h-8 rounded-lg flex items-center justify-center text-slate-400 dark:text-zinc-500 hover:text-slate-700 dark:hover:text-zinc-200 hover:bg-slate-100 dark:hover:bg-zinc-800 transition-colors"
>
{sidebarOpen ? <PanelLeftClose size={17} /> : <PanelLeftOpen size={17} />}
</button>
{/* Logo — unchanged from original */}
<img
src={import.meta.env.BASE_URL + "zino.svg"}
alt="Zino"
className="h-5 w-auto"
/>
</div>
{/* Right */}
<div className="flex items-center gap-1.5">
{/* Theme toggle */}
<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-slate-400 dark:text-zinc-500 hover:text-violet-600 dark:hover:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-950/40 transition-colors"
>
{theme === "dark" ? <Sun size={16} /> : <Moon size={16} />}
</button>
{/* Avatar */}
<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-violet-200 dark:hover:ring-violet-800 transition-all"
style={{ background: "linear-gradient(135deg, #7c3aed, #d946ef)" }}
>
<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-slate-900/10 dark:shadow-black/40 border border-slate-200 dark:border-zinc-700/70 overflow-hidden z-50">
<div className="px-4 py-3.5 border-b border-slate-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"
style={{ background: "linear-gradient(135deg, #7c3aed, #d946ef)" }}>
<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-slate-900 dark:text-zinc-100 truncate">{user?.name}</div>
<div className="text-[11px] text-slate-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 capitalize"
style={{ background: "rgba(124,58,237,0.1)", color: "#7c3aed", border: "1px solid rgba(124,58,237,0.2)" }}>
{roleName}
</span>
</div>
)}
</div>
<div className="py-1">
<button className="w-full text-left px-4 py-2.5 text-[13px] text-slate-600 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-800 flex items-center gap-2.5 transition-colors">
<Settings size={14} className="text-slate-400 dark:text-zinc-500" /> Settings
</button>
<div className="h-px bg-slate-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-500 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, #faf9ff 0%, #f5f3ff 100%)",
}}
>
{/* Dark mode override via class */}
<div className="flex flex-col h-full dark:bg-zinc-950 border-r border-violet-100 dark:border-zinc-800/80">
{/* App identity strip */}
<div className="px-5 pt-5 pb-4 border-b border-violet-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"
style={{ background: "linear-gradient(135deg, #7c3aed, #a855f7)" }}
>
<FileText size={13} className="text-white" />
</div>
<div>
<div className="text-[13px] font-bold text-slate-800 dark:text-zinc-100 leading-none">Invoice Portal</div>
<div className="text-[10px] text-slate-400 dark:text-zinc-600 mt-0.5">P2P · Procure-to-Pay</div>
</div>
</div>
</div>
{/* Nav section */}
<div className="flex-1 overflow-y-auto px-3 py-3">
<div className="text-[10px] font-bold text-slate-400 dark:text-zinc-600 uppercase tracking-widest px-2 mb-2">
Navigation
</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-violet-100 dark:bg-violet-950/50 text-violet-700 dark:text-violet-300"
: "text-slate-500 dark:text-zinc-400 hover:bg-white dark:hover:bg-zinc-800/70 hover:text-slate-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-violet-200/70 dark:bg-violet-900/60 text-violet-600 dark:text-violet-400"
: "bg-white dark:bg-zinc-800 text-slate-400 dark:text-zinc-500 shadow-sm group-hover:text-violet-600 dark:group-hover:text-violet-400"
}`}
>
{ICON_MAP[item.icon] ?? <FileText size={16} />}
</span>
<span className="truncate">{item.label}</span>
</button>
);
})}
{EXTRA_NAV_ITEMS.map((item) => {
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-violet-100 dark:bg-violet-950/50 text-violet-700 dark:text-violet-300"
: "text-slate-500 dark:text-zinc-400 hover:bg-white dark:hover:bg-zinc-800/70 hover:text-slate-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-violet-200/70 dark:bg-violet-900/60 text-violet-600 dark:text-violet-400"
: "bg-white dark:bg-zinc-800 text-slate-400 dark:text-zinc-500 shadow-sm group-hover:text-violet-600 dark:group-hover:text-violet-400"
}`}
>
{item.icon}
</span>
<span className="truncate">{item.label}</span>
</button>
);
})}
</nav>
</div>
{/* Bottom — user card */}
<div className="px-3 py-3 border-t border-violet-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"
style={{ background: "linear-gradient(135deg, #7c3aed, #d946ef)" }}
>
{initials}
</div>
<div className="min-w-0 flex-1">
<div className="text-[12px] font-semibold text-slate-700 dark:text-zinc-200 truncate leading-none">{user?.name}</div>
<div className="text-[10px] text-slate-400 dark:text-zinc-500 truncate mt-0.5 capitalize">{roleName}</div>
</div>
<LogOut size={13} className="text-slate-300 dark:text-zinc-600 group-hover:text-red-400 transition-colors shrink-0" />
</div>
</div>
</div>
</aside>
</>
);
}

View File

@ -0,0 +1,257 @@
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";
import { Skeleton } from "./Skeleton";
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);
// Entrance animation
useEffect(() => { requestAnimationFrame(() => setVisible(true)); }, []);
useEffect(() => {
getFormScreen(activityId, instanceId)
.then((res: any) => {
const f: FormField[] = res.fields ?? res.form_json?.fields ?? [];
setFields(f);
setGridConfig(res.grid_config ?? []);
setFormTitle(res.activity_name || title || "Form");
const pre: Record<string, string> = {};
f.forEach((field) => { if (field.value != null) pre[field.id] = String(field.value); });
setFormData(pre);
})
.catch((err: any) => setError(err.message))
.finally(() => setLoading(false));
}, [activityId, instanceId, title]);
const animateClose = (callback: () => void) => {
setVisible(false);
setTimeout(callback, 200);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setTouched(new Set(fields.map((f) => f.id)));
const missing = fields.filter((f) => f.mandatory && !formData[f.id]?.trim());
if (missing.length > 0) {
setError(`Please fill in: ${missing.map((f) => f.name).join(", ")}`);
return;
}
setSubmitting(true); setError(null);
try {
const data: Record<string, unknown> = {};
for (const f of fields) { const v = formData[f.id] ?? ""; data[f.id] = f.data_type === "number" ? (v ? Number(v) : 0) : v; }
instanceId ? await performActivity(WORKFLOW_ID, instanceId, activityId, data) : await startWorkflow(WORKFLOW_ID, activityId, data);
setSuccess(true);
setTimeout(() => animateClose(onSuccess), 800);
} catch (err: any) { setError(err.message || "Failed"); }
finally { setSubmitting(false); }
};
const sorted = [...fields].sort((a, b) => {
const ga = gridConfig.find((g) => g.i === a.uid), gb = gridConfig.find((g) => g.i === b.uid);
if (!ga || !gb) return 0;
return ga.y !== gb.y ? ga.y - gb.y : ga.x - gb.x;
});
const rows: FormField[][] = []; let cy = -1;
for (const f of sorted) { const g = gridConfig.find((gi) => gi.i === f.uid); const y = g?.y ?? rows.length; if (y !== cy) { rows.push([]); cy = y; } rows[rows.length - 1].push(f); }
const isMissing = (f: FormField) => f.mandatory && touched.has(f.id) && !formData[f.id]?.trim();
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={() => animateClose(onClose)}>
{/* Backdrop */}
<div className={`absolute inset-0 bg-slate-900/50 dark:bg-black/60 backdrop-blur-[2px] transition-opacity duration-200 ${visible ? "opacity-100" : "opacity-0"}`} />
{/* Modal */}
<div
ref={modalRef}
className={`relative bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl shadow-slate-900/10 dark:shadow-black/50 border border-slate-200/0 dark:border-zinc-700/60 w-full max-w-xl mx-4 max-h-[90vh] flex flex-col transition-all duration-200 ${visible ? "opacity-100 scale-100 translate-y-0" : "opacity-0 scale-95 translate-y-2"}`}
onClick={(e) => e.stopPropagation()}
>
{/* ── Success overlay ──────────────────────────────────────────── */}
{success && (
<div className="absolute inset-0 z-10 bg-white/95 dark:bg-zinc-900/95 rounded-2xl flex flex-col items-center justify-center gap-3">
<div className="w-12 h-12 rounded-full bg-emerald-50 dark:bg-emerald-950/40 flex items-center justify-center">
<CheckCircle2 size={24} className="text-emerald-500" />
</div>
<div className="text-[14px] font-semibold text-slate-800 dark:text-zinc-100">Submitted successfully</div>
</div>
)}
{/* ── Header ───────────────────────────────────────────────────── */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-100 dark:border-zinc-800">
<div>
<h2 className="text-[15px] font-semibold text-slate-900 dark:text-zinc-100">{formTitle}</h2>
<p className="text-[12px] text-slate-400 dark:text-zinc-500 mt-0.5">
{instanceId ? "Update the details below" : "Fill in the details to submit"}
</p>
</div>
<button
onClick={() => animateClose(onClose)}
className="w-8 h-8 flex items-center justify-center text-slate-400 dark:text-zinc-500 hover:text-slate-600 dark:hover:text-zinc-200 hover:bg-slate-100 dark:hover:bg-zinc-800 rounded-lg transition-colors"
>
<X size={16} />
</button>
</div>
{/* ── Body ─────────────────────────────────────────────────────── */}
<div className="flex-1 overflow-y-auto px-6 py-5">
{loading ? (
<div className="space-y-5">
{[0, 1, 2, 3].map((r) => (
<div key={r} className="grid grid-cols-2 gap-4">
<div><Skeleton className="h-3 w-20 mb-2" /><Skeleton className="h-10 w-full rounded-lg" /></div>
<div><Skeleton className="h-3 w-24 mb-2" /><Skeleton className="h-10 w-full rounded-lg" /></div>
</div>
))}
</div>
) : (
<form id="act-form" onSubmit={handleSubmit} className="space-y-5">
{rows.map((row, ri) => (
<div key={ri} className={`grid gap-4 ${row.length > 1 ? "grid-cols-2" : "grid-cols-1"}`}>
{row.map((f) => (
<div key={f.id}>
<label className="block text-[12px] font-medium text-slate-500 dark:text-zinc-400 mb-1.5">
{f.name}
{f.mandatory && <span className="text-red-400 ml-0.5">*</span>}
</label>
{renderInput(f, formData[f.id] ?? "", (v) => {
setFormData((p) => ({ ...p, [f.id]: v }));
setTouched((t) => new Set(t).add(f.id));
if (error) setError(null);
}, isMissing(f))}
{isMissing(f) && (
<p className="text-[11px] text-red-500 mt-1 flex items-center gap-1">
<AlertCircle size={11} /> Required
</p>
)}
</div>
))}
</div>
))}
</form>
)}
{error && (
<div className="mt-4 flex items-start gap-2.5 text-[13px] text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 rounded-xl px-4 py-3 border border-red-100 dark:border-red-900">
<AlertCircle size={15} className="shrink-0 mt-0.5" />
<span>{error}</span>
</div>
)}
</div>
{/* ── Footer ───────────────────────────────────────────────────── */}
{!loading && (
<div className="px-6 py-4 border-t border-slate-100 dark:border-zinc-800 flex items-center justify-between">
<div className="text-[11px] text-slate-400 dark:text-zinc-500">
<span className="text-red-400">*</span> Required
</div>
<div className="flex gap-2.5">
<button
type="button"
onClick={() => animateClose(onClose)}
className="h-9 px-4 text-[13px] font-medium text-slate-500 dark:text-zinc-400 hover:text-slate-700 dark:hover:text-zinc-200 hover:bg-slate-50 dark:hover:bg-zinc-800 rounded-xl transition-colors"
>
Cancel
</button>
<button
type="submit"
form="act-form"
disabled={submitting}
className="h-9 px-5 text-[13px] font-semibold text-white disabled:opacity-50 rounded-xl transition-all flex items-center gap-1.5 shadow-lg shadow-violet-500/25 hover:shadow-violet-500/40 hover:scale-[1.02] active:scale-[0.98]"
style={{ background: "linear-gradient(135deg, #7c3aed, #a855f7)" }}
>
{submitting ? (
<><div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Submitting</>
) : (
<>{instanceId ? "Update" : "Submit"} <ArrowRight size={14} /></>
)}
</button>
</div>
</div>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Input renderer
// ---------------------------------------------------------------------------
function renderInput(f: FormField, value: string, onChange: (v: string) => void, hasError: boolean) {
const base = `w-full h-10 border rounded-xl px-3.5 text-[13px] bg-slate-50/50 dark:bg-zinc-800/70 focus:bg-white dark:focus:bg-zinc-800 text-slate-800 dark:text-zinc-100 focus:outline-none focus:ring-2 transition placeholder:text-slate-400 dark:placeholder:text-zinc-600 ${
hasError
? "border-red-300 dark:border-red-700 focus:ring-red-500/20 focus:border-red-400"
: "border-slate-200 dark:border-zinc-700 focus:ring-violet-500/20 focus:border-violet-400 dark:focus:border-violet-500"
}`;
// Select / radio with options
const opts = f.properties?.options;
if (opts && opts.length > 0) {
return (
<div className="relative">
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={`${base} appearance-none pr-9 cursor-pointer`}
>
<option value="">Select {f.name.toLowerCase()}</option>
{opts.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-slate-400 pointer-events-none" />
</div>
);
}
switch (f.data_type) {
case "longtext":
return (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
rows={3}
className={`${base} h-auto py-2.5 rounded-xl`}
placeholder={`Enter ${f.name.toLowerCase()}`}
/>
);
case "number":
return <input type="number" step="any" value={value} onChange={(e) => onChange(e.target.value)} className={base} placeholder="0" />;
case "date":
return <input type="date" value={value} onChange={(e) => onChange(e.target.value)} className={base} />;
case "email":
return <input type="email" value={value} onChange={(e) => onChange(e.target.value)} className={base} placeholder={`Enter ${f.name.toLowerCase()}`} />;
case "phone":
return <input type="tel" value={value} onChange={(e) => onChange(e.target.value)} className={base} placeholder={`Enter ${f.name.toLowerCase()}`} />;
default:
return <input type="text" value={value} onChange={(e) => onChange(e.target.value)} className={base} placeholder={`Enter ${f.name.toLowerCase()}`} />;
}
}

View File

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

View File

@ -0,0 +1,498 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { useNavigate } from "react-router-dom";
import {
ChevronLeft, ChevronRight, Search, ArrowUp, ArrowDown,
X, CheckCircle2, XCircle, PauseCircle, MessageSquare,
Inbox, MoreHorizontal, ArrowRight, RefreshCw,
} from "lucide-react";
import { getRVScreen, getRecordView, type RecordViewField, type SearchQuery } from "../api/viewService";
import { TableSkeleton } from "./Skeleton";
import FormModal from "./FormModal";
import { STATE_IDS, ACTIVITY_IDS } from "../config";
// ---------------------------------------------------------------------------
// Row actions by state
// ---------------------------------------------------------------------------
const ROW_ACTIONS: Record<string, Array<{ activityId: string; label: string; icon: React.ReactNode }>> = {
[STATE_IDS.AWAITING_CLARIFICATION]: [
{ activityId: ACTIVITY_IDS.PROVIDE_CLARIFICATION, label: "Clarify", icon: <MessageSquare size={13} /> },
],
[STATE_IDS.FINANCE_REVIEW]: [
{ activityId: ACTIVITY_IDS.APPROVE_PAYMENT, label: "Approve", icon: <CheckCircle2 size={13} /> },
{ activityId: ACTIVITY_IDS.REJECT_PAYMENT, label: "Reject", icon: <XCircle size={13} /> },
{ activityId: ACTIVITY_IDS.HOLD_PAYMENT, label: "Hold", icon: <PauseCircle size={13} /> },
],
};
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
interface Props {
viewId: number | string;
onRowClick?: (instanceId: string) => void;
toolbarAction?: React.ReactNode;
}
export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: Props) {
const navigate = useNavigate();
const [rvTemplateId, setRvTemplateId] = useState<string | null>(null);
const [fields, setFields] = useState<RecordViewField[]>([]);
const [rows, setRows] = useState<Record<string, unknown>[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 10, total_count: 0, total_pages: 0 });
const [query, setQuery] = useState<SearchQuery>({ page: 1, limit: 10, sort_dir: "desc" });
const [searchInput, setSearchInput] = useState("");
const [loading, setLoading] = useState(true);
const [dataLoading, setDataLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formModal, setFormModal] = useState<{ activityId: string; instanceId: string; title: string } | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setLoading(true);
getRVScreen(viewId)
.then((rv) => setRvTemplateId(rv.rv_template_uid))
.catch((err) => { setError(err.message); setLoading(false); });
}, [viewId]);
const fetchData = useCallback(async () => {
if (!rvTemplateId) return;
if (!loading) setDataLoading(true);
setError(null);
try {
const res = await getRecordView(rvTemplateId, String(viewId), query);
setFields(res.config.fields.filter((f) => f.field_key !== ""));
const dataRows = Array.isArray(res.data) ? res.data : [];
setRows(dataRows);
if (res.pagination) {
setPagination(res.pagination);
} else {
// RDBMS views return all rows without a pagination envelope
setPagination((p) => ({ ...p, total_count: dataRows.length, total_pages: Math.ceil(dataRows.length / p.limit) || 1 }));
}
} catch (err: any) { setError(err.message); }
finally { setLoading(false); setDataLoading(false); }
}, [rvTemplateId, viewId, query]);
useEffect(() => { fetchData(); }, [fetchData]);
const handleSort = (key: string) => setQuery((q) => ({
...q, page: 1, sort_by: key,
sort_dir: q.sort_by === key && q.sort_dir === "asc" ? "desc" : "asc",
}));
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setQuery((q) => ({ ...q, page: 1, search: searchInput }));
};
const clearSearch = () => {
setSearchInput("");
setQuery((q) => ({ ...q, page: 1, search: "" }));
};
const handlePage = (p: number) => setQuery((q) => ({ ...q, page: p }));
const handleRowClick = (row: Record<string, unknown>) => {
const id = String(row.instance_id ?? row._pk_value ?? "");
if (!id) return;
onRowClick ? onRowClick(id) : navigate(`/detail/${id}`);
};
if (loading) return <TableSkeleton rows={6} cols={fields.length || 6} />;
if (error) {
return (
<div className="rounded-2xl border border-red-100 dark:border-red-900/50 bg-red-50/50 dark:bg-red-950/20 p-10 text-center">
<p className="text-sm text-red-600 dark:text-red-400 mb-2">{error}</p>
<button onClick={fetchData} className="text-xs text-violet-600 dark:text-violet-400 hover:text-violet-800 font-semibold">Try again</button>
</div>
);
}
const primaryFieldKey = fields.find(
(f) => f.field_key !== "current_state_name" && f.field_key !== "instance_id" && f.data_type === "text"
)?.field_key;
const { page, total_count, total_pages, limit } = pagination;
const from = total_count > 0 ? (page - 1) * limit + 1 : 0;
const to = Math.min(page * limit, total_count);
const pages = buildPageRange(page, total_pages);
return (
<div className="rounded-2xl border border-slate-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-slate-100 dark:border-zinc-800">
{/* Left — count + search chip + primary action */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
<span className="text-[15px] font-bold text-slate-800 dark:text-zinc-100 tabular-nums leading-none">{total_count}</span>
<span className="text-[13px] text-slate-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-slate-200 dark:border-zinc-700">
<span className="text-[11px] text-slate-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(124,58,237,0.08)", borderColor: "rgba(124,58,237,0.2)", color: "#7c3aed" }}
>
{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-slate-200 dark:border-zinc-700">
{toolbarAction}
</div>
)}
</div>
{/* Right — utility controls: refresh + search */}
<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-slate-400 dark:text-zinc-500 hover:text-slate-600 dark:hover:text-zinc-300 hover:bg-slate-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 -translate-y-1/2 text-slate-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-slate-200 dark:border-zinc-700 rounded-lg bg-slate-50 dark:bg-zinc-800 text-slate-800 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-violet-500/20 focus:border-violet-400 dark:focus:border-violet-500 focus:bg-white dark:focus:bg-zinc-800 transition placeholder:text-slate-400 dark:placeholder:text-zinc-600"
/>
{searchInput && (
<button
type="button"
onClick={clearSearch}
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-300 dark:text-zinc-600 hover:text-slate-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-violet-200 dark:border-violet-900 border-t-violet-600 rounded-full animate-spin" />
</div>
)}
<table className="w-full">
{/* ── Head ── */}
<thead>
<tr className="bg-slate-50/80 dark:bg-zinc-800/40 border-b border-slate-200/80 dark:border-zinc-700/80">
{fields.map((f, i) => {
const isSorted = query.sort_by === f.field_key;
return (
<th
key={f.field_key}
onClick={() => handleSort(f.field_key)}
className={`py-2.5 text-left text-[12px] font-semibold cursor-pointer select-none whitespace-nowrap transition-colors group/th
${i === 0 ? "pl-5 pr-4" : "px-4"}
${isSorted
? "text-violet-600 dark:text-violet-400"
: "text-slate-500 dark:text-zinc-400 hover:text-slate-700 dark:hover:text-zinc-200"
}
`}
>
<span className="inline-flex items-center gap-1">
{f.output_label}
{isSorted
? (query.sort_dir === "asc"
? <ArrowUp size={11} className="text-violet-500" />
: <ArrowDown size={11} className="text-violet-500" />)
: <ArrowDown size={10} className="text-slate-300 dark:text-zinc-700 opacity-0 group-hover/th:opacity-100 transition-opacity" />
}
</span>
</th>
);
})}
{/* Actions header — fixed width, no label */}
<th className="py-2.5 pr-4 w-16" />
</tr>
</thead>
{/* ── Body ── */}
<tbody className="divide-y divide-slate-100/80 dark:divide-zinc-800/60">
{rows.length === 0 ? (
<tr>
<td colSpan={fields.length + 1} className="py-20 text-center">
<div className="flex flex-col items-center gap-3">
<div className="w-14 h-14 rounded-2xl flex items-center justify-center"
style={{ background: "linear-gradient(135deg,#f5f3ff,#ede9fe)" }}>
<Inbox size={22} className="text-violet-400" />
</div>
<div>
<div className="text-[14px] font-semibold text-slate-600 dark:text-zinc-300">No records found</div>
<div className="text-[12px] text-slate-400 dark:text-zinc-600 mt-1">
{query.search ? "Try a different search term" : "Submit a new invoice to get started"}
</div>
</div>
{query.search && (
<button onClick={clearSearch} className="text-[12px] font-semibold text-violet-600 dark:text-violet-400 hover:text-violet-800 transition-colors">
Clear search
</button>
)}
</div>
</td>
</tr>
) : rows.map((row, i) => {
const stateName = String(row.current_state_name ?? "");
const stateId = Object.entries(STATE_IDS).find(([_, v]) => {
const name = v.replace(/^p2p-state-/, "").replace(/-/g, " ");
return stateName.toLowerCase() === name;
})?.[1];
const actions = stateId ? ROW_ACTIONS[stateId] : undefined;
return (
<tr
key={String(row.instance_id ?? i)}
onClick={() => handleRowClick(row)}
className="group/row cursor-pointer hover:bg-slate-50/80 dark:hover:bg-zinc-800/40 transition-colors"
>
{fields.map((f, fi) => {
const isPrimary = f.field_key === primaryFieldKey;
const isFirst = fi === 0;
return (
<td
key={f.field_key}
className={`py-3.5 whitespace-nowrap text-[13px]
${isFirst
? "pl-5 pr-4 border-l-2 border-l-transparent group-hover/row:border-l-violet-500 transition-colors"
: "px-4"
}
${isPrimary
? "font-semibold text-slate-800 dark:text-zinc-100"
: "text-slate-500 dark:text-zinc-400"
}
`}
>
{renderCell(row[f.field_key], f.data_type, f.field_key)}
</td>
);
})}
{/* Actions — hidden until hover */}
<td className="pr-4 py-3 w-16 text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1 opacity-0 group-hover/row:opacity-100 transition-opacity">
{actions && actions.length > 0 && (
<RowMenu
actions={actions}
onAction={(activityId, label) =>
setFormModal({ activityId, instanceId: String(row.instance_id), title: label })
}
/>
)}
<button
onClick={() => handleRowClick(row)}
className="w-7 h-7 rounded-lg flex items-center justify-center text-slate-400 dark:text-zinc-500 hover:text-violet-600 dark:hover:text-violet-400 hover:bg-violet-100 dark:hover:bg-violet-950/50 transition-colors"
>
<ArrowRight size={14} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* ── Pagination ────────────────────────────────────────────────────── */}
{total_count > 0 && (
<div className="px-5 py-3 border-t border-slate-100 dark:border-zinc-800 flex items-center justify-between gap-4">
{/* Left */}
<div className="flex items-center gap-3 text-[12px] text-slate-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-slate-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-slate-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-slate-200 dark:border-zinc-700 tabular-nums">
{from}{to} of {total_count}
</span>
</div>
{/* Right — page numbers */}
{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-slate-400 dark:text-zinc-500 hover:text-violet-600 dark:hover:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-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-slate-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-violet-600 text-white"
: "text-slate-500 dark:text-zinc-400 hover:bg-violet-50 dark:hover:bg-violet-950/30 hover:text-violet-600 dark:hover:text-violet-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-slate-400 dark:text-zinc-500 hover:text-violet-600 dark:hover:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-950/30 disabled:opacity-30 disabled:pointer-events-none transition-colors"
>
<ChevronRight size={15} />
</button>
</div>
)}
</div>
)}
{formModal && (
<FormModal
activityId={formModal.activityId}
instanceId={formModal.instanceId}
title={formModal.title}
onClose={() => setFormModal(null)}
onSuccess={() => { setFormModal(null); fetchData(); }}
/>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Row menu (three-dot dropdown)
// ---------------------------------------------------------------------------
interface RowMenuProps {
actions: Array<{ activityId: string; label: string; icon: React.ReactNode }>;
onAction: (activityId: string, label: string) => void;
}
function RowMenu({ actions, onAction }: RowMenuProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open]);
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="w-7 h-7 rounded-lg flex items-center justify-center text-slate-400 dark:text-zinc-500 hover:text-slate-600 dark:hover:text-zinc-300 hover:bg-slate-100 dark:hover:bg-zinc-800 transition-colors"
>
<MoreHorizontal size={15} />
</button>
{open && (
<div className="absolute right-0 top-full mt-1 z-50 w-40 bg-white dark:bg-zinc-900 rounded-xl shadow-xl shadow-slate-900/10 dark:shadow-black/40 border border-slate-200 dark:border-zinc-700/70 py-1 overflow-hidden">
{actions.map((a) => (
<button
key={a.activityId}
onClick={() => { setOpen(false); onAction(a.activityId, a.label); }}
className="w-full text-left px-3.5 py-2 text-[13px] text-slate-600 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-800 flex items-center gap-2.5 transition-colors"
>
<span className="text-slate-400 dark:text-zinc-500">{a.icon}</span>
{a.label}
</button>
))}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function buildPageRange(current: number, total: number): Array<number | ""> {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
if (current <= 4) return [1, 2, 3, 4, 5, "…", total];
if (current >= total - 3) return [1, "…", total - 4, total - 3, total - 2, total - 1, total];
return [1, "…", current - 1, current, current + 1, "…", total];
}
function renderCell(value: unknown, dataType: string, fieldKey: string): React.ReactNode {
if (value == null || value === "") return <span className="text-slate-300 dark:text-zinc-700"></span>;
if (fieldKey === "current_state_name") return <StatusBadge value={String(value)} />;
if (dataType === "number") {
const n = Number(value);
if (fieldKey.includes("amount") || fieldKey.includes("Amount") || fieldKey.includes("tax") || fieldKey.includes("price"))
return <span className="tabular-nums font-medium text-slate-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-slate-400 dark:text-zinc-500 bg-slate-100 dark:bg-zinc-800 px-1.5 py-0.5 rounded-md">{String(value).slice(0, 8)}</span>;
}
return String(value);
}
// ---------------------------------------------------------------------------
// Status badge
// ---------------------------------------------------------------------------
const STATUS: Record<string, { bg: string; text: string; dot: string; darkBg: string; darkText: string; darkDot: string }> = {
"Invoice Submitted": { bg: "bg-sky-50 border-sky-200", text: "text-sky-700", dot: "bg-sky-500", darkBg: "dark:bg-sky-950/40 dark:border-sky-800/60", darkText: "dark:text-sky-300", darkDot: "dark:bg-sky-400" },
"AI Analysis": { bg: "bg-violet-50 border-violet-200", text: "text-violet-700", dot: "bg-violet-500", darkBg: "dark:bg-violet-950/40 dark:border-violet-800/60", darkText: "dark:text-violet-300", darkDot: "dark:bg-violet-400" },
"Awaiting Clarification": { bg: "bg-amber-50 border-amber-200", text: "text-amber-700", dot: "bg-amber-500", darkBg: "dark:bg-amber-950/40 dark:border-amber-800/60", darkText: "dark:text-amber-300", darkDot: "dark:bg-amber-400" },
"Finance Review": { bg: "bg-indigo-50 border-indigo-200", text: "text-indigo-700", dot: "bg-indigo-500", darkBg: "dark:bg-indigo-950/40 dark:border-indigo-800/60", darkText: "dark:text-indigo-300", darkDot: "dark:bg-indigo-400" },
"Approved": { bg: "bg-emerald-50 border-emerald-200", text: "text-emerald-700", dot: "bg-emerald-500", darkBg: "dark:bg-emerald-950/40 dark:border-emerald-800/60", darkText: "dark:text-emerald-300", darkDot: "dark:bg-emerald-400" },
"Rejected": { bg: "bg-red-50 border-red-200", text: "text-red-700", dot: "bg-red-500", darkBg: "dark:bg-red-950/40 dark:border-red-800/60", darkText: "dark:text-red-300", darkDot: "dark:bg-red-400" },
"On Hold": { bg: "bg-slate-100 border-slate-200", text: "text-slate-600", dot: "bg-slate-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-slate-100 border-slate-200", text: "text-slate-600", dot: "bg-slate-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 === "AI Analysis" ? "animate-pulse" : ""}`} />
{value}
</span>
);
}

View File

@ -0,0 +1,82 @@
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; }
export default function ScreenRenderer({ layout, instanceId, onRefresh, onRowClick }: Props) {
const [formModal, setFormModal] = useState<{ activityId: string; instanceId?: string; title?: string } | null>(null);
// Collect button elements to inject into the next record view's toolbar
const buttons: ReactNode[] = [];
const elements: { type: "rv" | "dv"; viewId: number | string; style?: Record<string, string> }[] = [];
// Walk the layout tree (sections may nest children)
const walk = (nodes: any[]): void => {
for (const el of nodes) {
visit(el);
if (Array.isArray(el.children) && el.children.length > 0) walk(el.children);
}
};
const visit = (el: any) => {
const cfg = el.config || {};
if (cfg.type === "button" || cfg.job_template) {
const aid = cfg.params?.activity_id_init || cfg.params?.activity_id;
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] shadow-md shadow-violet-500/20"
style={{ background: "linear-gradient(135deg, #7c3aed, #a855f7)" }}
>
<Plus size={13} strokeWidth={2.5} />{label}
</button>
);
} else if (cfg.type === "recordsview") {
const vid = cfg.view_uuid ?? cfg.view_id;
if (vid) elements.push({ type: "rv", viewId: vid, style: el.style });
} else if (cfg.type === "detailsview") {
const vid = cfg.view_uuid ?? cfg.view_id;
if (vid) elements.push({ type: "dv", viewId: vid, style: el.style });
}
};
walk(layout as any[]);
// Drop absolute-positioning that the studio editor adds; render in normal flow.
const flowStyle = (s?: Record<string, string>) => {
if (!s) return undefined;
const { position, top, left, right, bottom, width, height, ...rest } = s;
return rest;
};
return (
<>
<div className="space-y-4">
{elements.map((el, i) => {
if (el.type === "rv") {
return (
<div key={i} style={flowStyle(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={flowStyle(el.style)}><DetailViewPanel viewId={el.viewId} instanceId={instanceId || ""} /></div>;
}
return null;
})}
</div>
{formModal && <FormModal activityId={formModal.activityId} instanceId={formModal.instanceId} title={formModal.title} onClose={() => setFormModal(null)} onSuccess={() => { setFormModal(null); onRefresh?.(); }} />}
</>
);
}

View File

@ -0,0 +1,73 @@
export function Skeleton({ className = "" }: { className?: string }) {
return <div className={`animate-pulse bg-slate-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-slate-200/80 dark:border-zinc-700/60 bg-white dark:bg-zinc-900 overflow-hidden shadow-sm">
{/* Toolbar skeleton */}
<div className="px-5 py-3.5 border-b border-slate-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>
{/* Header */}
<div className="px-4 py-2.5 border-b border-slate-100 dark:border-zinc-800 bg-slate-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>
{/* Rows */}
{Array.from({ length: rows }).map((_, ri) => (
<div key={ri} className="px-4 py-4 border-b border-slate-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">
{/* Status bar */}
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 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" />
<Skeleton className="h-4 w-24 mt-1" />
</div>
{/* Detail card */}
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 overflow-hidden">
<div className="px-5 py-3 border-b border-slate-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-slate-50 dark:border-zinc-800/60">
<Skeleton className="h-3 w-20 mb-2" />
<Skeleton className="h-4 w-32" />
</div>
))}
</div>
</div>
</div>
);
}
export function StatsSkeleton() {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-5">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 p-4">
<div className="flex items-start justify-between mb-3.5">
<Skeleton className="h-10 w-10 rounded-xl" />
<Skeleton className="h-8 w-12" />
</div>
<Skeleton className="h-3 w-24 mb-3" />
<Skeleton className="h-1 w-full rounded-full" />
</div>
))}
</div>
);
}

74
src/config.ts Normal file
View File

@ -0,0 +1,74 @@
// P2P Invoice System (dev) — App 169, Org 47
// Mirrors the studio app 20 build but with dev's UUID-based UIDs.
export const ORG_ID = "47";
export const APP_ID = "169";
// Core-service expects the workflow UUID under `workflow_uuid`.
export const WORKFLOW_ID = "0588b645-75f7-40e5-8251-e0761e4be632";
export const ACTIVITY_IDS = {
SUBMIT_INVOICE: "5c060892-e3f3-4881-89d2-b44abfea47b5", // INIT — vendor submits
ANALYZE_INVOICE: "p2p-act-analyze-invoice", // AI analysis — not present in dev workflow
REQUEST_CLARIFICATION: "9be78287-edc9-4cad-bcb5-2d751951e268", // AI asks department
PROVIDE_CLARIFICATION: "2633809d-7353-41f6-a5f1-08cede154ac0", // Department responds
SUBMIT_RECOMMENDATION: "ce4715f4-41ee-4a96-b54c-5c0eb1c07a4e", // AI recommends
APPROVE_PAYMENT: "48e36c69-ae33-48a5-9253-b749dac41ff1", // Finance approves
REJECT_PAYMENT: "3d835170-9c46-47f5-8e63-c58f367a6373", // Finance rejects
HOLD_PAYMENT: "c1571b59-6ee7-4ec5-bf26-0c5869a19537", // Finance holds
} as const;
export const RECORD_VIEW_IDS = {
ALL_INVOICES: "03f87d40-91c5-4d3e-89e4-7b44377ca5e0",
PENDING_CLARIFICATIONS: "282f3dd5-8d58-4956-a985-f46b814034f1",
VENDOR_INVOICES: "0b031076-4968-4717-908b-ea52cc3bb2bd",
FINANCE_REVIEW_QUEUE: "174a852f-37f9-49d5-ad8d-d3a6d4135f6a",
VENDOR_DATA_RDBMS: "f939a74a-c0f9-43fc-960d-018082b9b7be",
} as const;
export const DETAIL_VIEW_IDS = {
CLARIFICATION_DETAIL: "055fb42c-3aa7-450c-958e-ce7878322892",
FINANCE_REVIEW_DETAIL: "731f7c3f-bbd7-4df3-b3ae-5cbd61b29625",
INVOICE_DETAIL: "8b572cd4-d603-4140-9697-451d2c45c27f",
VENDOR_DETAIL_RDBMS: "0a53322e-f130-48c0-886c-3885a3d7892a",
} as const;
// Workflow states (matched by name)
export const STATE_IDS = {
INVOICE_SUBMITTED: "b406096d-b0a8-4dad-b6b1-f64e380395e3",
AI_ANALYSIS: "ee026e0d-0199-49b2-b737-1ee6b43156f8",
AWAITING_CLARIFICATION: "4ea58ab0-7f3c-4e35-b198-7026564ec017",
FINANCE_REVIEW: "180ee05d-564e-41bc-97e6-b6132a094157",
APPROVED: "b1db76d4-5cbc-4a39-894b-9b844e0e89b5",
REJECTED: "da3751fb-6dc2-4bfa-88fa-918799b4ccf7",
ON_HOLD: "2587a2e5-4236-4262-a371-6ca0e461a8c8",
} as const;
// Roles (unchanged from studio)
export const ROLES = {
VENDOR: "p2p_vendor",
AI_FINANCE: "p2p_ai_finance",
WAREHOUSE: "p2p_warehouse",
PROCUREMENT: "p2p_procurement",
AP_TEAM: "p2p_ap_team",
FINANCE_REVIEWER: "p2p_finance_reviewer",
} as const;
// tbl_appconfig_screens.source_uuid for the 7 P2P screens.
export const SCREEN_UUIDS = {
MY_INVOICES: "723dd1c6-027c-4e1f-b8ad-c5e8f29699f1", // "P2P-My Invoices"
PENDING_CLARIFICATIONS: "77ae8a02-818c-434f-a00b-a8610b9bd7e8", // "P2P-Pending Clarifications"
FINANCE_REVIEW: "637bfef3-e719-4ac3-a3a2-19a384acd932", // "P2P-Finance Review"
ALL_INVOICES: "019d28e0-d48b-4dbf-9285-29ce214b32d0", // "P2P-All Invoices"
INVOICE_DETAIL: "84dee127-0c23-4885-98da-971f46ae60fa", // "P2P-Invoice Detail"
CLARIFICATION_DETAIL: "09af6d6a-3c2a-445c-b126-8a7b732af3a8", // "P2P-Clarification Detail"
FINANCE_REVIEW_DETAIL: "60d9da55-af9c-4cc1-a51a-27a21b5db85e", // "P2P-Finance Review Detail"
} as const;
// tbl_appconfig_rv_screens.source_uuid for the RDBMS-backed Vendor Data screen.
export const RDBMS_RV_SCREEN_UUID = "5cb361ca-7f83-4312-aa34-fecc553963b7"; // "Vendor Data" rv_screen
// tbl_appconfig_dv_screens.source_uuid for the RDBMS-backed Vendor Detail screen.
export const RDBMS_DV_SCREEN_UUID = "d3f870ee-ead6-4e69-a051-c65db1a1ef11"; // "Vendor Detail View" dv_screen
// rv_screen.source_uuid used by DashboardStats counts ("Admin - All Invoices").
export const ALL_INVOICES_RV_SCREEN_UUID = "979047dd-e88f-4458-b356-35a8c014859b";

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

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

View File

@ -0,0 +1,41 @@
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("p2p_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("p2p_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);

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

@ -0,0 +1,100 @@
import { useState, useCallback } from "react";
import type { User } from "../zino-sdk/types";
const TOKEN_KEY = "p2p_token";
const USER_KEY = "p2p_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));
// Also set on zino SDK so workflow/view calls get the token
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,
};
}

47
src/index.css Normal file
View File

@ -0,0 +1,47 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply antialiased text-slate-800 dark:text-zinc-100 bg-white dark:bg-zinc-950;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
transition: background-color 0.2s ease, color 0.2s ease;
}
* {
transition-property: background-color, border-color, color;
transition-duration: 150ms;
transition-timing-function: ease;
}
/* Override transition for animations so they still work */
.animate-spin, .animate-pulse {
transition: none;
}
}
@layer utilities {
.tabular-nums {
font-variant-numeric: tabular-nums;
}
.glow-violet {
box-shadow: 0 0 20px rgba(139, 92, 246, 0.35);
}
.glow-fuchsia {
box-shadow: 0 0 20px rgba(217, 70, 239, 0.25);
}
.gradient-brand {
background: linear-gradient(135deg, #7c3aed, #a855f7, #d946ef);
}
.gradient-text {
background: linear-gradient(135deg, #7c3aed, #d946ef);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
}

24
src/main.tsx Normal file
View File

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

30
src/pages/Home.tsx Normal file
View File

@ -0,0 +1,30 @@
import { useAuthContext } from "../hooks/AuthContext";
import { LogOut } from "lucide-react";
export default function Home() {
const { user, logout } = useAuthContext();
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white border-b border-gray-200">
<div className="max-w-5xl mx-auto px-4 py-3 flex items-center justify-between">
<h1 className="text-lg font-semibold text-gray-900">P2P</h1>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-600">{user?.name || user?.email}</span>
<button
onClick={logout}
className="text-gray-400 hover:text-gray-600 transition-colors"
title="Sign out"
>
<LogOut size={18} />
</button>
</div>
</div>
</header>
<main className="max-w-5xl mx-auto px-4 py-8">
<p className="text-gray-500">Welcome. App pages will go here.</p>
</main>
</div>
);
}

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

@ -0,0 +1,152 @@
import { useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { useAuthContext } from "../hooks/AuthContext";
import { ShieldCheck, Zap, BarChart3, Bot, ArrowRight } from "lucide-react";
export default function Login() {
const { login, loading, error, clearError } = useAuthContext();
const navigate = useNavigate();
const location = useLocation();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [orgId] = useState("47");
const from = (location.state as any)?.from?.pathname || "/";
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearError();
try {
await login({ email, password, orgId: orgId || undefined });
navigate(from, { replace: true });
} catch {}
};
const inputCls = "w-full h-11 border border-slate-200 dark:border-zinc-700 rounded-lg px-3.5 text-[13px] bg-slate-50 dark:bg-zinc-800/60 text-slate-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-violet-500/20 focus:border-violet-400 dark:focus:border-violet-500 focus:bg-white dark:focus:bg-zinc-800 transition placeholder:text-slate-400 dark:placeholder:text-zinc-600";
return (
<div className="min-h-screen flex dark:bg-zinc-950">
{/* ── Left — branding ─────────────────────────────────────────── */}
<div className="hidden lg:flex lg:w-[45%] items-center justify-center p-16 relative overflow-hidden"
style={{ background: "linear-gradient(145deg, #1e1040 0%, #2d1567 40%, #1a0f3a 70%, #0f0a2e 100%)" }}>
{/* Orbs */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-32 -left-32 w-[500px] h-[500px] rounded-full opacity-30"
style={{ background: "radial-gradient(circle, #7c3aed 0%, transparent 70%)" }} />
<div className="absolute -bottom-20 -right-20 w-96 h-96 rounded-full opacity-20"
style={{ background: "radial-gradient(circle, #d946ef 0%, transparent 70%)" }} />
<div className="absolute inset-0 opacity-[0.04]"
style={{ backgroundImage: "linear-gradient(rgba(255,255,255,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.5) 1px, transparent 1px)", backgroundSize: "40px 40px" }} />
</div>
<div className="absolute top-10 left-16 z-10">
<img src={import.meta.env.BASE_URL + "zino.svg"} alt="Zino" className="h-5 w-auto brightness-0 invert" />
</div>
<div className="relative z-10 max-w-lg">
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium mb-8 border"
style={{ background: "rgba(124,58,237,0.2)", borderColor: "rgba(167,139,250,0.3)", color: "#c4b5fd" }}>
<ShieldCheck size={13} />
Enterprise-grade procurement automation
</div>
<h1 className="text-[2.6rem] font-bold text-white leading-[1.1] tracking-tight mb-8">
Procure-to-Pay
<br />
<span className="text-transparent bg-clip-text"
style={{ backgroundImage: "linear-gradient(135deg, #c4b5fd, #f0abfc, #67e8f9)" }}>
Invoice Intelligence
</span>
</h1>
<div className="space-y-4">
{[
{ icon: <Zap size={15} />, title: "Instant Validation", desc: "AI matches invoices against POs and GRNs in seconds" },
{ icon: <Bot size={15} />, title: "Smart Routing", desc: "Exceptions auto-route to the right department" },
{ icon: <BarChart3 size={15} />, title: "Full Audit Trail", desc: "Complete visibility from submission to payment" },
].map((item) => (
<div key={item.title} className="flex items-start gap-3">
<div className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{ background: "rgba(124,58,237,0.3)", border: "1px solid rgba(167,139,250,0.25)", color: "#c4b5fd" }}>
{item.icon}
</div>
<div>
<div className="text-[13px] font-semibold text-white">{item.title}</div>
<div className="text-[12px] mt-0.5" style={{ color: "rgba(167,139,250,0.65)" }}>{item.desc}</div>
</div>
</div>
))}
</div>
</div>
</div>
{/* ── Right — login form ───────────────────────────────────────── */}
<div className="flex-1 flex items-center justify-center bg-white dark:bg-zinc-950 px-8">
<div className="w-full max-w-[340px]">
{/* Logo — visible on mobile only */}
<img src={import.meta.env.BASE_URL + "zino.svg"} alt="Zino"
className="h-5 w-auto mb-10 lg:hidden dark:invert" />
{/* Heading */}
<div className="mb-8">
<h2 className="text-[1.6rem] font-bold text-slate-900 dark:text-zinc-50 tracking-tight leading-none mb-2">
Sign in
</h2>
<p className="text-[13px] text-slate-400 dark:text-zinc-500">
Enter your credentials to access the portal
</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-[12px] font-medium text-slate-600 dark:text-zinc-400 mb-1.5 uppercase tracking-wide">
Email
</label>
<input
type="email" required autoComplete="email" autoFocus
value={email} onChange={(e) => { setEmail(e.target.value); clearError(); }}
className={inputCls} placeholder="you@company.com"
/>
</div>
<div>
<label className="block text-[12px] font-medium text-slate-600 dark:text-zinc-400 mb-1.5 uppercase tracking-wide">
Password
</label>
<input
type="password" required autoComplete="current-password"
value={password} onChange={(e) => { setPassword(e.target.value); clearError(); }}
className={inputCls} placeholder="••••••••"
/>
</div>
{error && (
<p className="text-[12px] text-red-500 dark:text-red-400 bg-red-50 dark:bg-red-950/30 rounded-lg px-3 py-2.5 border border-red-100 dark:border-red-900/60">
{error}
</p>
)}
<button
type="submit" disabled={loading}
className="w-full h-11 mt-1 disabled:opacity-50 text-white text-[13px] font-semibold rounded-lg transition-all flex items-center justify-center gap-2 hover:opacity-90 active:scale-[0.99]"
style={{ background: "linear-gradient(135deg, #7c3aed, #a855f7)" }}
>
{loading ? "Signing in…" : <>Sign in <ArrowRight size={14} /></>}
</button>
</form>
{/* Footer */}
<div className="flex items-center justify-center gap-1.5 mt-10">
<span className="text-[11px] text-slate-400 dark:text-zinc-500">Powered by</span>
<img src={import.meta.env.BASE_URL + "zino.svg"} alt="Zino"
className="h-3.5 w-auto dark:invert" />
</div>
</div>
</div>
</div>
);
}

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

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

13
tailwind.config.js Normal file
View File

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

19
tsconfig.app.json Normal file
View File

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

7
tsconfig.json Normal file
View File

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

15
tsconfig.node.json Normal file
View File

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

33
vite.config.ts Normal file
View File

@ -0,0 +1,33 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
const devTarget = 'https://dev.getzino.in'
const proxy = (target: string) => ({ target, changeOrigin: true, secure: false })
export default defineConfig({
plugins: [react()],
base: process.env.VITE_BASE_URL ?? '/p2p-dev/',
server: {
port: 3006,
proxy: {
// user-service
'/usr': proxy(devTarget),
'/login': proxy(devTarget),
'/apps': proxy(devTarget),
'/agents': proxy(devTarget),
// core-service
'/start': proxy(devTarget),
'/activity': proxy(devTarget),
'/instance': proxy(devTarget),
'/form': proxy(devTarget),
'/pa': proxy(devTarget),
// view-service (legacy)
'/recordview': proxy(devTarget),
'/detailview': proxy(devTarget),
'/audit': proxy(devTarget),
'/api-docs': proxy(devTarget),
// view-service (app-scoped)
'/app/': proxy(devTarget),
},
},
})