initial commit: p2p-dev — custom frontend wired to dev env (app 169)
This commit is contained in:
commit
dea13acd2b
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.local
|
||||
.DS_Store
|
||||
*-local-command-caveatcaveat-*.txt
|
||||
36
frontgen_projects_dev_to_studio.sql
Normal file
36
frontgen_projects_dev_to_studio.sql
Normal 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
16
index.html
Normal file
@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/zino.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&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
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
164
p2p_demo_schema.sql
Normal 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
2745
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal 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
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
9
public/zino-logo.svg
Normal file
9
public/zino-logo.svg
Normal 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
9
public/zino.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 27 KiB |
37
src/App.tsx
Normal file
37
src/App.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { ProtectedRoute } from './components/ProtectedRoute'
|
||||
import Login from './pages/Login'
|
||||
import AppShell from './components/AppShell'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/screen/:screenId/detail/:instanceId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppShell />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/screen/:screenId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppShell />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppShell />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
422
src/api/viewService.ts
Normal file
422
src/api/viewService.ts
Normal file
@ -0,0 +1,422 @@
|
||||
import { APP_ID } from "../config";
|
||||
|
||||
const TOKEN_KEY = "zino_token";
|
||||
|
||||
function baseUrl(): string {
|
||||
return (import.meta.env.VITE_ZINO_API_URL || "").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function headers(): Record<string, string> {
|
||||
const h: Record<string, string> = { "Content-Type": "application/json" };
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
if (token) h["Authorization"] = `Bearer ${token}`;
|
||||
return h;
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(`${baseUrl()}${path}`, {
|
||||
method,
|
||||
headers: headers(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem("zino_user");
|
||||
window.location.href = `${import.meta.env.BASE_URL}login`;
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText;
|
||||
try { const e = await res.json(); if (e.error) msg = e.error; } catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface HeaderConfig {
|
||||
slots: { left: string[]; right: string[]; center: string[] };
|
||||
layout: { type: string; height: string; position: string; full_width: boolean };
|
||||
components: {
|
||||
nav?: { items: NavItem[] };
|
||||
logo?: { text?: string; image_url?: string; display_type: string };
|
||||
profile?: { shape: string; show_name: boolean };
|
||||
};
|
||||
}
|
||||
|
||||
export interface NavItem {
|
||||
id: string;
|
||||
icon: string;
|
||||
type: string;
|
||||
label: string;
|
||||
screen_uuid: string;
|
||||
}
|
||||
|
||||
export async function getHeaderConfig(deviceType = "desktop"): Promise<HeaderConfig> {
|
||||
const raw = await request<any>("GET", `/app/${APP_ID}/view/header/${deviceType}`);
|
||||
return raw.config ?? raw;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Screens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ScreenListItem {
|
||||
id: number;
|
||||
source_screen_id: number;
|
||||
screen_name: string;
|
||||
device: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ScreenLayout {
|
||||
id: number;
|
||||
source_screen_id: number;
|
||||
screen_name: string;
|
||||
layout: LayoutElement[];
|
||||
}
|
||||
|
||||
export interface LayoutElement {
|
||||
type: string;
|
||||
style?: Record<string, string>;
|
||||
config: Record<string, any>;
|
||||
classes?: string[];
|
||||
}
|
||||
|
||||
export function getScreens(deviceType = "desktop"): Promise<ScreenListItem[]> {
|
||||
return request("GET", `/app/${APP_ID}/view/screens?device_type=${deviceType}`);
|
||||
}
|
||||
|
||||
export function getScreen(idOrUuid: number | string): Promise<ScreenLayout> {
|
||||
return request("GET", `/app/${APP_ID}/view/screens/${idOrUuid}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RV Screens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RVScreen {
|
||||
id: number;
|
||||
source_screen_id: number;
|
||||
screen_name: string;
|
||||
rv_template_uid: string;
|
||||
layout?: any;
|
||||
}
|
||||
|
||||
// Accepts either the numeric source_screen_id OR the source_uuid — both
|
||||
// are accepted by the view-service's rv-screens endpoint.
|
||||
export function getRVScreen(idOrUuid: number | string): Promise<RVScreen> {
|
||||
return request("GET", `/app/${APP_ID}/view/rv-screens/${idOrUuid}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Record View (POST-based)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SearchQuery {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sort_by?: string;
|
||||
sort_dir?: "asc" | "desc";
|
||||
search?: string;
|
||||
filters?: Array<{ field_key: string; value: string; data_type?: string }>;
|
||||
}
|
||||
|
||||
export interface RecordViewField {
|
||||
field_key: string;
|
||||
output_label: string;
|
||||
data_type: string;
|
||||
is_filter: boolean;
|
||||
is_search: boolean;
|
||||
type?: string;
|
||||
activity_id?: string;
|
||||
}
|
||||
|
||||
export interface TileValue {
|
||||
tile_uid: string;
|
||||
key: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export interface ChartDataRow {
|
||||
dimension: unknown;
|
||||
series?: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export interface ChartDataResponse {
|
||||
chart_uid: string;
|
||||
key: string;
|
||||
rows: ChartDataRow[];
|
||||
}
|
||||
|
||||
export interface RecordViewResponse {
|
||||
config: { fields: RecordViewField[] };
|
||||
data: Record<string, unknown>[];
|
||||
pagination?: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total_count: number;
|
||||
total_pages: number;
|
||||
};
|
||||
tile_values?: TileValue[];
|
||||
chart_data?: ChartDataResponse[];
|
||||
}
|
||||
|
||||
export function getRecordView(
|
||||
rvTemplateUid: string,
|
||||
rvScreenId: string | number,
|
||||
query: SearchQuery = {},
|
||||
): Promise<RecordViewResponse> {
|
||||
return request("POST", `/app/${APP_ID}/view/recordview`, {
|
||||
rv_template_uid: rvTemplateUid,
|
||||
rv_screen_id: String(rvScreenId),
|
||||
search_query: {
|
||||
page: query.page || 1,
|
||||
limit: query.limit || 50,
|
||||
sort_by: query.sort_by || "",
|
||||
sort_dir: query.sort_dir || "desc",
|
||||
search: query.search || "",
|
||||
filters: query.filters || [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getRecordViewLegacy(
|
||||
rvUid: string,
|
||||
query: SearchQuery = {},
|
||||
): Promise<RecordViewResponse> {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set("rv_id", rvUid);
|
||||
if (query.page) qs.set("page", String(query.page));
|
||||
if (query.limit) qs.set("limit", String(query.limit));
|
||||
if (query.sort_by) qs.set("sort_by", query.sort_by);
|
||||
if (query.sort_dir) qs.set("sort_dir", query.sort_dir);
|
||||
if (query.search) qs.set("search", query.search);
|
||||
if (query.filters) {
|
||||
for (const f of query.filters) {
|
||||
qs.set(`filter.${f.field_key}`, f.value);
|
||||
}
|
||||
}
|
||||
return request("GET", `/recordview?${qs.toString()}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DV Screens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DVScreen {
|
||||
id: number;
|
||||
source_screen_id: number;
|
||||
screen_name: string;
|
||||
dv_template_uid: string;
|
||||
layout?: any;
|
||||
}
|
||||
|
||||
export function getDVScreen(idOrUuid: number | string): Promise<DVScreen> {
|
||||
return request("GET", `/app/${APP_ID}/view/dv-screens/${idOrUuid}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail View
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DetailViewResponse {
|
||||
config: { fields: Array<{ field_key: string; output_label: string; data_type: string }> };
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function getDetailView(dvSourceIdOrUid: number | string, instanceId: string): Promise<DetailViewResponse> {
|
||||
return request(
|
||||
"GET",
|
||||
`/app/${APP_ID}/view/detailview/${encodeURIComponent(String(dvSourceIdOrUid))}?instance_id=${encodeURIComponent(instanceId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getDetailViewLegacy(dvUid: string, instanceId: string): Promise<DetailViewResponse> {
|
||||
return request(
|
||||
"GET",
|
||||
`/detailview?dv_id=${encodeURIComponent(dvUid)}&instance_id=${encodeURIComponent(instanceId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Form Screens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FormScreenField {
|
||||
id: string;
|
||||
uid: string;
|
||||
name: string;
|
||||
type: string;
|
||||
data_type: string;
|
||||
mandatory: boolean;
|
||||
value?: any;
|
||||
properties?: {
|
||||
options?: Array<{ label: string; value: string }>;
|
||||
country_code?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FormScreenResponse {
|
||||
id: number;
|
||||
activity_uid: string;
|
||||
activity_name: string;
|
||||
device_type: string;
|
||||
fields: FormScreenField[];
|
||||
grid_config: Array<{ i: string; x: number; y: number; w: number; h: number }>;
|
||||
layout: any[];
|
||||
}
|
||||
|
||||
export function getFormScreen(
|
||||
activityId: string,
|
||||
instanceId?: string,
|
||||
deviceType = "desktop",
|
||||
): Promise<FormScreenResponse> {
|
||||
const numericInstanceId = instanceId ? Number(instanceId) : undefined;
|
||||
return request("POST", `/app/${APP_ID}/view/form-screens`, {
|
||||
activity_id: activityId,
|
||||
device_type: deviceType,
|
||||
...(numericInstanceId && !Number.isNaN(numericInstanceId)
|
||||
? { instance_id: numericInstanceId }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workflow Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface WorkflowResponse {
|
||||
success: boolean;
|
||||
status_code?: number;
|
||||
message?: string;
|
||||
data?: Record<string, unknown>;
|
||||
instance_id?: string;
|
||||
}
|
||||
|
||||
// Core-service expects the workflow UUID as `workflow_uuid` — the numeric
|
||||
// `workflow_id` field was removed when the clone-portable identifier rolled out.
|
||||
export function startWorkflow(
|
||||
workflowUuid: string,
|
||||
activityId: string,
|
||||
data?: Record<string, unknown>,
|
||||
): Promise<WorkflowResponse> {
|
||||
return request("POST", `/app/${APP_ID}/start`, {
|
||||
workflow_uuid: workflowUuid,
|
||||
activity_id: activityId,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function performActivity(
|
||||
workflowUuid: string,
|
||||
instanceId: string,
|
||||
activityId: string,
|
||||
data?: Record<string, unknown>,
|
||||
): Promise<WorkflowResponse> {
|
||||
const numericInstanceId = Number(instanceId);
|
||||
return request("POST", `/app/${APP_ID}/activity`, {
|
||||
workflow_uuid: workflowUuid,
|
||||
instance_id: Number.isNaN(numericInstanceId) ? instanceId : numericInstanceId,
|
||||
activity_id: activityId,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function submitForm(
|
||||
workflowUuid: string,
|
||||
activityId: string,
|
||||
formData: Record<string, unknown>,
|
||||
instanceId?: string,
|
||||
deviceType = "desktop",
|
||||
): Promise<WorkflowResponse> {
|
||||
const numericInstanceId = instanceId ? Number(instanceId) : undefined;
|
||||
return request("POST", `/app/${APP_ID}/form/submit`, {
|
||||
workflow_uuid: workflowUuid,
|
||||
activity_id: activityId,
|
||||
device_type: deviceType,
|
||||
form_data: formData,
|
||||
...(numericInstanceId && !Number.isNaN(numericInstanceId)
|
||||
? { instance_id: numericInstanceId }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Instance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface InstanceResponse {
|
||||
instance_id: string;
|
||||
workflow_id: string;
|
||||
current_state_id: string;
|
||||
current_state_name: string;
|
||||
data: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export function getInstance(workflowUuid: string, instanceId: string): Promise<InstanceResponse> {
|
||||
return request("POST", `/app/${APP_ID}/instance`, {
|
||||
workflow_uuid: workflowUuid,
|
||||
instance_id: Number(instanceId),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AuditEntry {
|
||||
id: number;
|
||||
user_id: string;
|
||||
user_roles: string[];
|
||||
activity_id: string;
|
||||
data: Record<string, unknown>;
|
||||
execution_state: string;
|
||||
created_at: string;
|
||||
// Optional human-readable summary line. Populated by useInstanceMeta when
|
||||
// the audit is synthesised from instance.data._activities.
|
||||
context?: string;
|
||||
}
|
||||
|
||||
export function getAuditLog(instanceId: string): Promise<AuditEntry[]> {
|
||||
return request("GET", `/app/${APP_ID}/view/audit?instance_id=${encodeURIComponent(instanceId)}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RDBMS lookup records — used by the demo calendar to pull rows from the
|
||||
// `mahindra_demo` schema via the lookup-field config on the INIT activity.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RdbmsLookupResponse {
|
||||
records: Record<string, unknown>[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface RdbmsLookupQuery {
|
||||
workflowId: number;
|
||||
activityId: string;
|
||||
fieldId: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export function fetchRdbmsLookupRecords(templateUuid: string, q: RdbmsLookupQuery): Promise<RdbmsLookupResponse> {
|
||||
return request("POST", `/app/${APP_ID}/rdbms-templates/${encodeURIComponent(templateUuid)}/records`, {
|
||||
workflow_id: q.workflowId,
|
||||
activity_id: q.activityId,
|
||||
field_id: q.fieldId,
|
||||
limit: q.limit ?? 1000,
|
||||
offset: q.offset ?? 0,
|
||||
search: q.search ?? "",
|
||||
});
|
||||
}
|
||||
302
src/components/AppShell.tsx
Normal file
302
src/components/AppShell.tsx
Normal 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()); }
|
||||
84
src/components/DashboardStats.tsx
Normal file
84
src/components/DashboardStats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
363
src/components/DetailViewPanel.tsx
Normal file
363
src/components/DetailViewPanel.tsx
Normal 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; }
|
||||
}
|
||||
251
src/components/DynamicHeader.tsx
Normal file
251
src/components/DynamicHeader.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
257
src/components/FormModal.tsx
Normal file
257
src/components/FormModal.tsx
Normal 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()}`} />;
|
||||
}
|
||||
}
|
||||
13
src/components/ProtectedRoute.tsx
Normal file
13
src/components/ProtectedRoute.tsx
Normal 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}</>;
|
||||
}
|
||||
498
src/components/RecordViewTable.tsx
Normal file
498
src/components/RecordViewTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
src/components/ScreenRenderer.tsx
Normal file
82
src/components/ScreenRenderer.tsx
Normal 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?.(); }} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
73
src/components/Skeleton.tsx
Normal file
73
src/components/Skeleton.tsx
Normal 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
74
src/config.ts
Normal 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
19
src/hooks/AuthContext.tsx
Normal 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;
|
||||
}
|
||||
41
src/hooks/ThemeContext.tsx
Normal file
41
src/hooks/ThemeContext.tsx
Normal 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
100
src/hooks/useAuth.ts
Normal 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
47
src/index.css
Normal 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
24
src/main.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { ZinoProvider } from './zino-sdk'
|
||||
import { AuthProvider } from './hooks/AuthContext'
|
||||
import { ThemeProvider } from './hooks/ThemeContext'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
const apiUrl = import.meta.env.VITE_ZINO_API_URL || ''
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<ZinoProvider baseUrl={apiUrl}>
|
||||
<BrowserRouter basename={import.meta.env.BASE_URL}>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</ZinoProvider>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
30
src/pages/Home.tsx
Normal file
30
src/pages/Home.tsx
Normal 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
152
src/pages/Login.tsx
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
175
src/zino-sdk/DynamicForm.tsx
Normal file
175
src/zino-sdk/DynamicForm.tsx
Normal file
@ -0,0 +1,175 @@
|
||||
import React, { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useZino } from "./provider";
|
||||
import type { FormField } from "./types";
|
||||
|
||||
export interface DynamicFormProps {
|
||||
/** Workflow definition ID. */
|
||||
workflowId: string;
|
||||
/** Activity UID — the form schema is fetched for this activity. */
|
||||
activityId: string;
|
||||
/** Instance ID — omit for INIT activities (start workflow). */
|
||||
instanceId?: string;
|
||||
/** Called after successful submission with the result. */
|
||||
onSuccess?: (result: unknown) => void;
|
||||
/** Called on submission error. */
|
||||
onError?: (error: unknown) => void;
|
||||
/** Optional CSS class for the form wrapper. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a dynamic form based on the activity's form schema.
|
||||
*
|
||||
* Fetches the form config from the core-service, renders fields by type,
|
||||
* and submits via performActivity or startWorkflow.
|
||||
*
|
||||
* ```tsx
|
||||
* <DynamicForm
|
||||
* workflowId={WORKFLOW_ID}
|
||||
* activityId={ACTIVITY_IDS.ASSIGN_TICKET}
|
||||
* instanceId={instanceId}
|
||||
* onSuccess={() => { closeModal(); }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function DynamicForm({
|
||||
workflowId,
|
||||
activityId,
|
||||
instanceId,
|
||||
onSuccess,
|
||||
onError,
|
||||
className,
|
||||
}: DynamicFormProps) {
|
||||
const { workflows } = useZino();
|
||||
const queryClient = useQueryClient();
|
||||
const [formData, setFormData] = useState<Record<string, string>>({});
|
||||
|
||||
// Fetch form schema
|
||||
const { data: form, isLoading: formLoading, error: formError } = useQuery({
|
||||
queryKey: ["form", workflowId, activityId, instanceId],
|
||||
queryFn: () => workflows.getForm(workflowId, activityId, "desktop", instanceId),
|
||||
});
|
||||
|
||||
// Submit mutation
|
||||
const submit = useMutation({
|
||||
mutationFn: async (data: Record<string, string>) => {
|
||||
if (instanceId) {
|
||||
return workflows.performActivity(workflowId, instanceId, activityId, data);
|
||||
} else {
|
||||
return workflows.startWorkflow(workflowId, activityId, data);
|
||||
}
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["recordview"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["instance"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["detailview"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["audit"] });
|
||||
onSuccess?.(result);
|
||||
},
|
||||
onError: (err) => {
|
||||
onError?.(err);
|
||||
},
|
||||
});
|
||||
|
||||
const handleChange = (fieldId: string, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [fieldId]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Map form field IDs to their mapped_activity_field_id
|
||||
const mapped: Record<string, string> = {};
|
||||
for (const field of form?.form_json?.fields ?? []) {
|
||||
const key = field.mapped_activity_field_id || field.id;
|
||||
mapped[key] = formData[field.id] ?? "";
|
||||
}
|
||||
submit.mutate(mapped);
|
||||
};
|
||||
|
||||
if (formLoading) {
|
||||
return <div className="flex justify-center py-8"><div className="animate-spin h-6 w-6 border-2 border-blue-500 border-t-transparent rounded-full" /></div>;
|
||||
}
|
||||
|
||||
if (formError) {
|
||||
return <div className="text-red-600 text-sm py-4">Failed to load form: {(formError as any)?.message ?? "Unknown error"}</div>;
|
||||
}
|
||||
|
||||
const fields: FormField[] = form?.form_json?.fields ?? [];
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={className}>
|
||||
{form?.form_json?.title && (
|
||||
<h3 className="text-lg font-semibold mb-4">{form.form_json.title}</h3>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{fields.map((field) => (
|
||||
<div key={field.id}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{field.label}
|
||||
</label>
|
||||
{renderField(field, formData[field.id] ?? "", (v) => handleChange(field.id, v))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{submit.error && (
|
||||
<div className="mt-3 text-sm text-red-600">
|
||||
{(submit.error as any)?.message ?? "Submission failed"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submit.isPending}
|
||||
className="mt-6 w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium py-2.5 px-4 rounded-lg transition-colors"
|
||||
>
|
||||
{submit.isPending ? "Submitting..." : "Submit"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function renderField(
|
||||
field: FormField,
|
||||
value: string,
|
||||
onChange: (value: string) => void,
|
||||
) {
|
||||
const baseClass =
|
||||
"w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent";
|
||||
|
||||
switch (field.type) {
|
||||
case "paragraph":
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={4}
|
||||
className={baseClass}
|
||||
placeholder={field.label}
|
||||
/>
|
||||
);
|
||||
case "number":
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={baseClass}
|
||||
placeholder={field.label}
|
||||
/>
|
||||
);
|
||||
case "text":
|
||||
default:
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={baseClass}
|
||||
placeholder={field.label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
64
src/zino-sdk/auth.ts
Normal file
64
src/zino-sdk/auth.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import type { ZinoClient } from "./client";
|
||||
import type { LoginResponse, User } from "./types";
|
||||
import { mockDelay, MOCK_USER } from "./mock";
|
||||
|
||||
/**
|
||||
* Authentication methods — login, logout, and JWT decoding.
|
||||
* Maps to user-service endpoints.
|
||||
*/
|
||||
export class AuthService {
|
||||
private client: ZinoClient;
|
||||
constructor(client: ZinoClient) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
async login(
|
||||
email: string,
|
||||
password: string,
|
||||
orgId?: string,
|
||||
): Promise<LoginResponse> {
|
||||
if (this.client.isMock) {
|
||||
await mockDelay();
|
||||
const res: LoginResponse = {
|
||||
token: "mock-jwt-token-" + Date.now(),
|
||||
user: { ...MOCK_USER },
|
||||
};
|
||||
this.client.setToken(res.token);
|
||||
return res;
|
||||
}
|
||||
|
||||
const res = await this.client.request<LoginResponse>("POST", "/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
86
src/zino-sdk/client.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import type { ApiError, ZinoClientConfig } from "./types";
|
||||
|
||||
const TOKEN_KEY = "zino_token";
|
||||
|
||||
/**
|
||||
* Core HTTP client for the Zino API.
|
||||
*
|
||||
* Persists JWT in localStorage so sessions survive page refresh.
|
||||
*/
|
||||
export class ZinoClient {
|
||||
readonly baseUrl: string;
|
||||
private token: string | null = null;
|
||||
private onAuthError?: () => void;
|
||||
|
||||
constructor(config: ZinoClientConfig) {
|
||||
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
||||
this.onAuthError = config.onAuthError;
|
||||
// Restore token from localStorage on init
|
||||
if (typeof window !== "undefined") {
|
||||
this.token = localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
get isMock(): boolean {
|
||||
return this.baseUrl === "mock";
|
||||
}
|
||||
|
||||
setToken(token: string | null): void {
|
||||
this.token = token;
|
||||
if (typeof window !== "undefined") {
|
||||
if (token) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
} else {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
return this.token;
|
||||
}
|
||||
|
||||
async request<T>(
|
||||
method: "GET" | "POST" | "PUT" | "DELETE",
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
headers["Authorization"] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
this.token = null;
|
||||
this.onAuthError?.();
|
||||
const err: ApiError = { status: 401, message: "Unauthorized" };
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
try {
|
||||
const errBody = (await res.json()) as { error?: string };
|
||||
if (errBody.error) message = errBody.error;
|
||||
} catch {
|
||||
// body wasn't JSON
|
||||
}
|
||||
const err: ApiError = { status: res.status, message };
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
}
|
||||
61
src/zino-sdk/index.ts
Normal file
61
src/zino-sdk/index.ts
Normal file
@ -0,0 +1,61 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zino Service SDK — Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Core client
|
||||
export { ZinoClient } from "./client";
|
||||
|
||||
// Service modules
|
||||
export { AuthService } from "./auth";
|
||||
export { WorkflowService } from "./workflow";
|
||||
export { ViewService } from "./views";
|
||||
|
||||
// React bindings
|
||||
export { ZinoProvider, useZino, queryClient } from "./provider";
|
||||
|
||||
// Components
|
||||
export { DynamicForm } from "./DynamicForm";
|
||||
|
||||
// Re-export TanStack Query hooks for convenience
|
||||
export { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
// Types
|
||||
export type {
|
||||
User,
|
||||
RoleAssignment,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
App,
|
||||
WorkflowDefinition,
|
||||
State,
|
||||
Transition,
|
||||
Activity,
|
||||
ActivityDataField,
|
||||
AllowedRole,
|
||||
WorkflowDataField,
|
||||
GridColumn,
|
||||
DataType,
|
||||
StageGate,
|
||||
StageGateRuleSet,
|
||||
SGRuleOutcome,
|
||||
WorkflowInstance,
|
||||
ActivityLogEntry,
|
||||
FormFieldType,
|
||||
FormField,
|
||||
FormConfig,
|
||||
ActivityForm,
|
||||
ViewField,
|
||||
ViewFieldType,
|
||||
ActionConfig,
|
||||
Column,
|
||||
SortDir,
|
||||
TabularViewParams,
|
||||
TabularViewResponse,
|
||||
ActivityEntry,
|
||||
InstanceReport,
|
||||
DetailViewResponse,
|
||||
ReportSection,
|
||||
StateTransition,
|
||||
ZinoClientConfig,
|
||||
ApiError,
|
||||
} from "./types";
|
||||
243
src/zino-sdk/mock.ts
Normal file
243
src/zino-sdk/mock.ts
Normal file
@ -0,0 +1,243 @@
|
||||
import type {
|
||||
ActivityForm,
|
||||
ActivityLogEntry,
|
||||
Column,
|
||||
InstanceReport,
|
||||
ReportSection,
|
||||
StateTransition,
|
||||
TabularViewParams,
|
||||
TabularViewResponse,
|
||||
User,
|
||||
WorkflowDefinition,
|
||||
WorkflowInstance,
|
||||
} from "./types";
|
||||
|
||||
export function mockDelay(): Promise<void> {
|
||||
const ms = 200 + Math.random() * 200;
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function randomId(): string {
|
||||
return Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
|
||||
function isoNow(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function daysAgo(n: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - n);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
export const MOCK_USER: User = {
|
||||
id: "usr-001",
|
||||
org_id: "org-acme",
|
||||
name: "Jane Doe",
|
||||
email: "jane@acme.corp",
|
||||
roles: ["admin", "reviewer"],
|
||||
groups: ["engineering"],
|
||||
role_assignments: [
|
||||
{ role_id: "admin", positions: ["manager"] },
|
||||
{ role_id: "reviewer" },
|
||||
],
|
||||
};
|
||||
|
||||
export const MOCK_WORKFLOW_DEF: WorkflowDefinition = {
|
||||
workflow_id: "wf-support-ticket",
|
||||
version: 1,
|
||||
name: "Support Ticket",
|
||||
states: [
|
||||
{ uid: "s-new", name: "New", type: "initial", allowed_activities: ["a-init"] },
|
||||
{ uid: "s-open", name: "Open", allowed_activities: ["a-assign", "a-close"] },
|
||||
{ uid: "s-in-progress", name: "In Progress", allowed_activities: ["a-resolve", "a-escalate"] },
|
||||
{ uid: "s-resolved", name: "Resolved", allowed_activities: ["a-reopen", "a-close"] },
|
||||
{ uid: "s-closed", name: "Closed", type: "terminal", allowed_activities: [] },
|
||||
],
|
||||
activities: [
|
||||
{ uid: "a-init", name: "Create Ticket", type: "INIT", data_fields: [
|
||||
{ id: "f-title", name: "title", type: "local", data_type: "text", mandatory: true },
|
||||
{ id: "f-desc", name: "description", type: "local", data_type: "longtext", mandatory: false },
|
||||
{ id: "f-priority", name: "priority", type: "local", data_type: "text", mandatory: true },
|
||||
] },
|
||||
{ uid: "a-assign", name: "Assign Agent", type: "USER", data_fields: [
|
||||
{ id: "f-agent", name: "assigned_to", type: "local", data_type: "text", mandatory: true },
|
||||
] },
|
||||
{ uid: "a-resolve", name: "Resolve", type: "USER", data_fields: [
|
||||
{ id: "f-resolution", name: "resolution_notes", type: "local", data_type: "longtext", mandatory: true },
|
||||
] },
|
||||
{ uid: "a-escalate", name: "Escalate", type: "USER" },
|
||||
{ uid: "a-reopen", name: "Reopen", type: "USER" },
|
||||
{ uid: "a-close", name: "Close", type: "USER" },
|
||||
],
|
||||
transitions: [
|
||||
{ from_state_id: "s-new", by_activity_id: "a-init", transition_type: "direct", to_state_id: "s-open" },
|
||||
{ from_state_id: "s-open", by_activity_id: "a-assign", transition_type: "direct", to_state_id: "s-in-progress" },
|
||||
{ from_state_id: "s-in-progress", by_activity_id: "a-resolve", transition_type: "direct", to_state_id: "s-resolved" },
|
||||
{ from_state_id: "s-in-progress", by_activity_id: "a-escalate", transition_type: "direct", to_state_id: "s-open" },
|
||||
{ from_state_id: "s-resolved", by_activity_id: "a-reopen", transition_type: "direct", to_state_id: "s-open" },
|
||||
{ from_state_id: "s-resolved", by_activity_id: "a-close", transition_type: "direct", to_state_id: "s-closed" },
|
||||
{ from_state_id: "s-open", by_activity_id: "a-close", transition_type: "direct", to_state_id: "s-closed" },
|
||||
],
|
||||
workflow_data_fields: [
|
||||
{ id: "wdf-title", uid: "wdf-title", name: "title", data_type: "text" },
|
||||
{ id: "wdf-desc", uid: "wdf-desc", name: "description", data_type: "longtext" },
|
||||
{ id: "wdf-priority", uid: "wdf-priority", name: "priority", data_type: "text" },
|
||||
],
|
||||
};
|
||||
|
||||
const NAMES = ["Alice Johnson", "Bob Ramirez", "Chandra Patel", "Dina Sokolov", "Emeka Obi"];
|
||||
const PRIORITIES = ["High", "Medium", "Low"];
|
||||
const STATUSES = ["Open", "In Progress", "Resolved", "Closed"];
|
||||
const TITLES = [
|
||||
"Login page not loading",
|
||||
"Payment gateway timeout",
|
||||
"Dashboard chart misaligned",
|
||||
"Export CSV returns empty file",
|
||||
"Password reset email delayed",
|
||||
"Mobile sidebar overlaps content",
|
||||
"Search returns stale results",
|
||||
"Role permissions not syncing",
|
||||
];
|
||||
|
||||
export function mockWorkflowInstance(workflowId: string, instanceId?: string): WorkflowInstance {
|
||||
return {
|
||||
instance_id: instanceId ?? `inst-${randomId()}`,
|
||||
workflow_id: workflowId,
|
||||
workflow_version: 1,
|
||||
current_state_id: "s-open",
|
||||
data: {
|
||||
title: TITLES[Math.floor(Math.random() * TITLES.length)],
|
||||
priority: PRIORITIES[Math.floor(Math.random() * PRIORITIES.length)],
|
||||
description: "Detailed description of the reported issue.",
|
||||
},
|
||||
created_at: daysAgo(3),
|
||||
updated_at: isoNow(),
|
||||
};
|
||||
}
|
||||
|
||||
export function mockActivityForm(workflowId: string, activityId: string): ActivityForm {
|
||||
return {
|
||||
id: `form-${randomId()}`,
|
||||
workflow_id: workflowId,
|
||||
activity_id: activityId,
|
||||
form_id: `form-${activityId}`,
|
||||
device_type: "desktop",
|
||||
form_json: {
|
||||
title: activityId === "a-init" ? "Create Ticket" : "Update Ticket",
|
||||
fields: [
|
||||
{ id: "f1", label: "Title", type: "text", mapped_activity_field_id: "f-title" },
|
||||
{ id: "f2", label: "Description", type: "paragraph", mapped_activity_field_id: "f-desc" },
|
||||
{ id: "f3", label: "Priority", type: "text", mapped_activity_field_id: "f-priority" },
|
||||
],
|
||||
},
|
||||
created_at: daysAgo(30),
|
||||
};
|
||||
}
|
||||
|
||||
export function mockTabularResponse(params: TabularViewParams): TabularViewResponse {
|
||||
const pageSize = params.pageSize ?? 10;
|
||||
const page = params.page ?? 1;
|
||||
const total = 47;
|
||||
|
||||
const columns: Column[] = [
|
||||
{ key: "instance_id", label: "ID", data_type: "text", sortable: true, filterable: false, searchable: true },
|
||||
{ key: "title", label: "Title", data_type: "text", sortable: true, filterable: false, searchable: true },
|
||||
{ key: "priority", label: "Priority", data_type: "text", sortable: true, filterable: true, searchable: false },
|
||||
{ key: "status", label: "Status", data_type: "text", sortable: true, filterable: true, searchable: false },
|
||||
{ key: "assigned_to", label: "Assigned To", data_type: "text", sortable: true, filterable: true, searchable: true },
|
||||
{ key: "created_at", label: "Created", data_type: "text", sortable: true, filterable: false, searchable: false },
|
||||
];
|
||||
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
const count = Math.min(pageSize, total - (page - 1) * pageSize);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const idx = (page - 1) * pageSize + i;
|
||||
rows.push({
|
||||
instance_id: `TICKET-${1000 + idx}`,
|
||||
title: TITLES[idx % TITLES.length],
|
||||
priority: PRIORITIES[idx % PRIORITIES.length],
|
||||
status: STATUSES[idx % STATUSES.length],
|
||||
assigned_to: NAMES[idx % NAMES.length],
|
||||
created_at: daysAgo(total - idx),
|
||||
});
|
||||
}
|
||||
|
||||
return { rows, total, page, columns };
|
||||
}
|
||||
|
||||
export function mockInstanceReport(instanceId: string): {
|
||||
report: InstanceReport;
|
||||
sections: ReportSection[];
|
||||
history: StateTransition[];
|
||||
} {
|
||||
const report: InstanceReport = {
|
||||
workflow_id: "wf-support-ticket",
|
||||
instance_id: instanceId,
|
||||
workflow_version: 1,
|
||||
current_state_id: "s-in-progress",
|
||||
current_state_name: "In Progress",
|
||||
global_data: {
|
||||
title: "Payment gateway timeout",
|
||||
description: "Users report intermittent 504 errors during checkout.",
|
||||
priority: "High",
|
||||
assigned_to: "Bob Ramirez",
|
||||
},
|
||||
activities: [
|
||||
{ activity_id: "a-init", data: { title: "Payment gateway timeout" }, activity_performed_at: daysAgo(5), performed_by_id: "usr-001" },
|
||||
{ activity_id: "a-assign", data: { assigned_to: "Bob Ramirez" }, activity_performed_at: daysAgo(4), performed_by_id: "usr-001" },
|
||||
],
|
||||
created_at: daysAgo(5),
|
||||
updated_at: daysAgo(4),
|
||||
};
|
||||
|
||||
const sections: ReportSection[] = [
|
||||
{
|
||||
title: "Instance Details",
|
||||
fields: [
|
||||
{ label: "Instance ID", value: instanceId },
|
||||
{ label: "Workflow", value: "Support Ticket" },
|
||||
{ label: "Current State", value: "In Progress" },
|
||||
{ label: "Created", value: report.created_at },
|
||||
{ label: "Updated", value: report.updated_at },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Data",
|
||||
fields: Object.entries(report.global_data).map(([k, v]) => ({ label: k, value: v })),
|
||||
},
|
||||
];
|
||||
|
||||
const history: StateTransition[] = [
|
||||
{ from_state: "New", to_state: "Open", activity_name: "Create Ticket", performed_by: "Jane Doe", timestamp: daysAgo(5), data: {} },
|
||||
{ from_state: "Open", to_state: "In Progress", activity_name: "Assign Agent", performed_by: "Jane Doe", timestamp: daysAgo(4), data: { assigned_to: "Bob Ramirez" } },
|
||||
];
|
||||
|
||||
return { report, sections, history };
|
||||
}
|
||||
|
||||
export function mockAuditLog(): ActivityLogEntry[] {
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
user_id: "usr-001",
|
||||
user_roles: ["admin"],
|
||||
user_groups: ["engineering"],
|
||||
activity_id: "a-init",
|
||||
data: { title: "Payment gateway timeout", priority: "High" },
|
||||
execution_state: "s-open",
|
||||
created_at: daysAgo(5),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
user_id: "usr-001",
|
||||
user_roles: ["admin"],
|
||||
user_groups: ["engineering"],
|
||||
activity_id: "a-assign",
|
||||
data: { assigned_to: "Bob Ramirez" },
|
||||
execution_state: "s-in-progress",
|
||||
created_at: daysAgo(4),
|
||||
},
|
||||
];
|
||||
}
|
||||
143
src/zino-sdk/provider.tsx
Normal file
143
src/zino-sdk/provider.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ZinoClient } from "./client";
|
||||
import { AuthService } from "./auth";
|
||||
import { WorkflowService } from "./workflow";
|
||||
import { ViewService } from "./views";
|
||||
import type { User } from "./types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ZinoContextValue {
|
||||
client: ZinoClient;
|
||||
auth: AuthService;
|
||||
workflows: WorkflowService;
|
||||
views: ViewService;
|
||||
user: User | null;
|
||||
setUser: (user: User | null) => void;
|
||||
}
|
||||
|
||||
const ZinoContext = createContext<ZinoContextValue | null>(null);
|
||||
|
||||
const MOCK_SESSION_KEY = "zino_mock_session";
|
||||
|
||||
// Shared QueryClient — sensible defaults for generated apps.
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000, // 30s before refetch
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ZinoProviderProps {
|
||||
baseUrl: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap your app with `<ZinoProvider>` to get access to Zino services
|
||||
* and TanStack Query throughout the component tree.
|
||||
*
|
||||
* ```tsx
|
||||
* <ZinoProvider baseUrl="http://localhost:8091">
|
||||
* <App />
|
||||
* </ZinoProvider>
|
||||
* ```
|
||||
*/
|
||||
export function ZinoProvider({ baseUrl, children }: ZinoProviderProps) {
|
||||
const [user, setUserState] = useState<User | null>(null);
|
||||
|
||||
const viteMock = import.meta.env.VITE_ZINO_MOCK === "true";
|
||||
const resolvedUrl = baseUrl === "mock" || viteMock ? "mock" : baseUrl;
|
||||
const isMock = resolvedUrl === "mock";
|
||||
|
||||
const setUser = useMemo(() => (u: User | null) => {
|
||||
setUserState(u);
|
||||
if (isMock) {
|
||||
if (u) {
|
||||
sessionStorage.setItem(MOCK_SESSION_KEY, "1");
|
||||
} else {
|
||||
sessionStorage.removeItem(MOCK_SESSION_KEY);
|
||||
}
|
||||
}
|
||||
}, [isMock]);
|
||||
|
||||
// Restore session on mount — from localStorage token or mock sessionStorage.
|
||||
useEffect(() => {
|
||||
if (isMock && sessionStorage.getItem(MOCK_SESSION_KEY)) {
|
||||
const authSvc = new AuthService(new ZinoClient({ baseUrl: "mock" }));
|
||||
const mockUser = authSvc.getCurrentUser();
|
||||
if (mockUser) setUserState(mockUser);
|
||||
} else {
|
||||
// Real mode — check if a token exists in localStorage
|
||||
const client = new ZinoClient({ baseUrl: resolvedUrl });
|
||||
if (client.getToken()) {
|
||||
const authSvc = new AuthService(client);
|
||||
const restored = authSvc.getCurrentUser();
|
||||
if (restored) setUserState(restored);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo<ZinoContextValue>(() => {
|
||||
const client = new ZinoClient({
|
||||
baseUrl: resolvedUrl,
|
||||
onAuthError: () => setUser(null),
|
||||
});
|
||||
|
||||
return {
|
||||
client,
|
||||
auth: new AuthService(client),
|
||||
workflows: new WorkflowService(client),
|
||||
views: new ViewService(client),
|
||||
user,
|
||||
setUser,
|
||||
};
|
||||
}, [resolvedUrl, setUser]);
|
||||
|
||||
const ctx = useMemo(
|
||||
() => ({ ...value, user, setUser }),
|
||||
[value, user, setUser],
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ZinoContext.Provider value={ctx}>{children}</ZinoContext.Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Access Zino services. Use with TanStack Query:
|
||||
*
|
||||
* ```tsx
|
||||
* const { views } = useZino();
|
||||
* const { data } = useQuery({
|
||||
* queryKey: ['recordview', rvId],
|
||||
* queryFn: () => views.getTabularView(rvId, { page: 1 }),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useZino(): ZinoContextValue {
|
||||
const ctx = useContext(ZinoContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useZino must be used within a <ZinoProvider>");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** Re-export queryClient for direct invalidation in mutations. */
|
||||
export { queryClient };
|
||||
285
src/zino-sdk/types.ts
Normal file
285
src/zino-sdk/types.ts
Normal file
@ -0,0 +1,285 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zino Service SDK — TypeScript Interfaces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---- Auth & User ----
|
||||
|
||||
export interface RoleAssignment {
|
||||
role_id: string;
|
||||
positions?: string[];
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
org_id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
groups: string[];
|
||||
role_assignments: RoleAssignment[];
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
org_id?: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface App {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
org_id: string;
|
||||
}
|
||||
|
||||
// ---- Workflow Definition ----
|
||||
|
||||
export interface AllowedRole {
|
||||
role: string;
|
||||
positions?: string;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
uid: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
allowed_activities: string[];
|
||||
}
|
||||
|
||||
export interface Transition {
|
||||
from_state_id: string;
|
||||
by_activity_id?: string;
|
||||
by_outcome?: string;
|
||||
transition_type: string;
|
||||
to_state_id?: string;
|
||||
stage_gate_id?: string;
|
||||
}
|
||||
|
||||
export type DataType = "text" | "longtext" | "number" | "grid";
|
||||
|
||||
export interface GridColumn {
|
||||
id: string;
|
||||
name: string;
|
||||
data_type: DataType;
|
||||
}
|
||||
|
||||
export interface WorkflowDataField {
|
||||
id: string;
|
||||
uid: string;
|
||||
name: string;
|
||||
data_type: DataType;
|
||||
columns?: GridColumn[];
|
||||
}
|
||||
|
||||
export interface ActivityDataField {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "local" | "mapped";
|
||||
data_type?: DataType;
|
||||
mandatory: boolean;
|
||||
mapped_workflow_field?: string;
|
||||
columns?: GridColumn[];
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
uid: string;
|
||||
name: string;
|
||||
type: string;
|
||||
allowed_roles?: AllowedRole[];
|
||||
allowed_groups?: string[];
|
||||
dynamic_rbac_allowed?: boolean;
|
||||
only_roles_positions?: boolean;
|
||||
data_fields?: ActivityDataField[];
|
||||
trigger_uid?: string;
|
||||
}
|
||||
|
||||
export interface StageGate {
|
||||
id: string;
|
||||
rules_id: string;
|
||||
}
|
||||
|
||||
export interface SGRuleOutcome {
|
||||
state_id: string;
|
||||
sg_rule_config?: {
|
||||
condition_field?: string;
|
||||
condition_operator?: string;
|
||||
condition_value?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StageGateRuleSet {
|
||||
id: string;
|
||||
default_state: string;
|
||||
other_states: SGRuleOutcome[];
|
||||
}
|
||||
|
||||
export interface WorkflowDefinition {
|
||||
workflow_id: string;
|
||||
version: number;
|
||||
name: string;
|
||||
states: State[];
|
||||
activities: Activity[];
|
||||
transitions: Transition[];
|
||||
workflow_data_fields: WorkflowDataField[];
|
||||
stage_gates?: StageGate[];
|
||||
sg_rules?: StageGateRuleSet[];
|
||||
}
|
||||
|
||||
// ---- Workflow Instances ----
|
||||
|
||||
export interface WorkflowInstance {
|
||||
instance_id: string;
|
||||
workflow_id: string;
|
||||
workflow_version?: number;
|
||||
current_state_id: string;
|
||||
current_state_name?: string;
|
||||
data: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ActivityLogEntry {
|
||||
id: number;
|
||||
user_id: string;
|
||||
user_roles: string[];
|
||||
user_groups: string[];
|
||||
activity_id: string;
|
||||
data: Record<string, unknown>;
|
||||
execution_state: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ---- Forms ----
|
||||
|
||||
export type FormFieldType = "text" | "paragraph" | "number";
|
||||
|
||||
export interface FormField {
|
||||
id: string;
|
||||
label: string;
|
||||
type: FormFieldType;
|
||||
mapped_activity_field_id: string;
|
||||
}
|
||||
|
||||
export interface FormConfig {
|
||||
title: string;
|
||||
fields: FormField[];
|
||||
}
|
||||
|
||||
export interface ActivityForm {
|
||||
id: string;
|
||||
workflow_id: string;
|
||||
activity_id: string;
|
||||
form_id: string;
|
||||
device_type: string;
|
||||
form_json: FormConfig;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ---- Views ----
|
||||
|
||||
export interface ActionConfig {
|
||||
label: string;
|
||||
activity_uid: string;
|
||||
workflow_id?: string;
|
||||
}
|
||||
|
||||
export type ViewFieldType =
|
||||
| "global"
|
||||
| "local"
|
||||
| "system-global"
|
||||
| "system-local"
|
||||
| "action";
|
||||
|
||||
export interface ViewField {
|
||||
type: ViewFieldType;
|
||||
field_key: string;
|
||||
output_label: string;
|
||||
activity_id?: string;
|
||||
action?: ActionConfig;
|
||||
data_type: string;
|
||||
is_filter: boolean;
|
||||
is_search: boolean;
|
||||
}
|
||||
|
||||
export interface Column {
|
||||
key: string;
|
||||
label: string;
|
||||
data_type: string;
|
||||
sortable: boolean;
|
||||
filterable: boolean;
|
||||
searchable: boolean;
|
||||
}
|
||||
|
||||
export type SortDir = "asc" | "desc";
|
||||
|
||||
export interface TabularViewParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortDir?: SortDir;
|
||||
filters?: Record<string, string>;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface TabularViewResponse {
|
||||
rows: Record<string, unknown>[];
|
||||
total: number;
|
||||
totalPages?: number;
|
||||
page: number;
|
||||
columns: Column[];
|
||||
}
|
||||
|
||||
export interface ActivityEntry {
|
||||
activity_id: string;
|
||||
data: Record<string, unknown>;
|
||||
activity_performed_at: string;
|
||||
performed_by_id: string;
|
||||
}
|
||||
|
||||
export interface InstanceReport {
|
||||
workflow_id: string;
|
||||
instance_id: string;
|
||||
workflow_version: number;
|
||||
current_state_id: string;
|
||||
current_state_name: string;
|
||||
global_data: Record<string, unknown>;
|
||||
activities: ActivityEntry[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface StateTransition {
|
||||
from_state: string;
|
||||
to_state: string;
|
||||
activity_name: string;
|
||||
performed_by: string;
|
||||
timestamp: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ReportSection {
|
||||
title: string;
|
||||
fields: { label: string; value: unknown }[];
|
||||
}
|
||||
|
||||
export interface DetailViewResponse {
|
||||
data: Record<string, unknown>;
|
||||
sections: ReportSection[];
|
||||
}
|
||||
|
||||
// ---- SDK-internal ----
|
||||
|
||||
export interface ZinoClientConfig {
|
||||
baseUrl: string;
|
||||
onAuthError?: () => void;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
status: number;
|
||||
message: string;
|
||||
}
|
||||
197
src/zino-sdk/views.ts
Normal file
197
src/zino-sdk/views.ts
Normal file
@ -0,0 +1,197 @@
|
||||
import type { ZinoClient } from "./client";
|
||||
import type {
|
||||
ActivityLogEntry,
|
||||
Column,
|
||||
DetailViewResponse,
|
||||
InstanceReport,
|
||||
ReportSection,
|
||||
StateTransition,
|
||||
TabularViewParams,
|
||||
TabularViewResponse,
|
||||
} from "./types";
|
||||
import {
|
||||
mockDelay,
|
||||
mockTabularResponse,
|
||||
mockInstanceReport,
|
||||
mockAuditLog,
|
||||
} from "./mock";
|
||||
|
||||
/**
|
||||
* View and reporting methods — tabular record views, detail views,
|
||||
* and audit trails. Maps to view-service endpoints.
|
||||
*/
|
||||
export class ViewService {
|
||||
private client: ZinoClient;
|
||||
constructor(client: ZinoClient) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a paginated, sortable, filterable record view.
|
||||
* Maps to `GET /recordview?rv_id=…`.
|
||||
*/
|
||||
async getTabularView(
|
||||
viewId: string,
|
||||
params: TabularViewParams = {},
|
||||
): Promise<TabularViewResponse> {
|
||||
if (this.client.isMock) {
|
||||
await mockDelay();
|
||||
return mockTabularResponse(params);
|
||||
}
|
||||
|
||||
const qs = new URLSearchParams();
|
||||
qs.set("rv_id", viewId);
|
||||
if (params.page) qs.set("page", String(params.page));
|
||||
if (params.pageSize) qs.set("limit", String(params.pageSize));
|
||||
if (params.sortBy) qs.set("sort_by", params.sortBy);
|
||||
if (params.sortDir) qs.set("sort_dir", params.sortDir);
|
||||
if (params.search) qs.set("search", params.search);
|
||||
if (params.filters) {
|
||||
for (const [k, v] of Object.entries(params.filters)) {
|
||||
qs.set(`filter.${k}`, v);
|
||||
}
|
||||
}
|
||||
|
||||
const raw = await this.client.request<{
|
||||
config: { fields: Array<{ field_key: string; output_label: string; data_type: string; is_filter: boolean; is_search: boolean }> };
|
||||
data: Record<string, unknown>[];
|
||||
pagination?: { page: number; limit: number; total_count: number; total_pages: number };
|
||||
}>("GET", `/recordview?${qs.toString()}`);
|
||||
|
||||
const columns: Column[] = raw.config.fields
|
||||
.filter((f) => f.field_key !== "")
|
||||
.map((f) => ({
|
||||
key: f.field_key,
|
||||
label: f.output_label,
|
||||
data_type: f.data_type,
|
||||
sortable: true,
|
||||
filterable: f.is_filter,
|
||||
searchable: f.is_search,
|
||||
}));
|
||||
|
||||
const rows = Array.isArray(raw.data) ? raw.data : [];
|
||||
|
||||
return {
|
||||
rows,
|
||||
total: raw.pagination?.total_count ?? rows.length,
|
||||
totalPages: raw.pagination?.total_pages,
|
||||
page: raw.pagination?.page ?? params.page ?? 1,
|
||||
columns,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a detail view for a specific workflow instance.
|
||||
* Maps to `GET /detailview?dv_id=…&instance_id=…`.
|
||||
*/
|
||||
async getDetailView(viewId: string, instanceId: string): Promise<DetailViewResponse> {
|
||||
if (this.client.isMock) {
|
||||
await mockDelay();
|
||||
const data: Record<string, unknown> = {
|
||||
instance_id: instanceId,
|
||||
view_id: viewId,
|
||||
status: "Open",
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
return {
|
||||
data,
|
||||
sections: [
|
||||
{
|
||||
title: "Details",
|
||||
fields: Object.entries(data).map(([k, v]) => ({ label: k, value: v })),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const raw = await this.client.request<{
|
||||
config: { fields: Array<{ field_key: string; output_label: string; data_type: string }> };
|
||||
data: Record<string, unknown>;
|
||||
}>("GET", `/detailview?dv_id=${encodeURIComponent(viewId)}&instance_id=${encodeURIComponent(instanceId)}`);
|
||||
|
||||
const data = raw.data ?? {};
|
||||
const fields = (raw.config?.fields ?? []).filter((f) => f.field_key !== "");
|
||||
|
||||
const sections: ReportSection[] = [
|
||||
{
|
||||
title: "Details",
|
||||
fields: fields.length > 0
|
||||
? fields.map((f) => ({ label: f.output_label, value: data[f.field_key] }))
|
||||
: Object.entries(data).map(([k, v]) => ({ label: k, value: v })),
|
||||
},
|
||||
];
|
||||
|
||||
return { data, sections };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a full instance report — global data, activity history,
|
||||
* and state transitions.
|
||||
*/
|
||||
async getInstanceReport(
|
||||
workflowId: string,
|
||||
instanceId: string,
|
||||
): Promise<{ report: InstanceReport; sections: ReportSection[]; history: StateTransition[] }> {
|
||||
if (this.client.isMock) {
|
||||
await mockDelay();
|
||||
return mockInstanceReport(instanceId);
|
||||
}
|
||||
|
||||
const [instance, audit] = await Promise.all([
|
||||
this.client.request<InstanceReport>("POST", "/instance", {
|
||||
workflow_id: workflowId,
|
||||
instance_id: instanceId,
|
||||
}),
|
||||
this.client.request<ActivityLogEntry[]>(
|
||||
"GET",
|
||||
`/audit?instance_id=${encodeURIComponent(instanceId)}`,
|
||||
),
|
||||
]);
|
||||
|
||||
const sections: ReportSection[] = [
|
||||
{
|
||||
title: "Instance Details",
|
||||
fields: [
|
||||
{ label: "Instance ID", value: instance.instance_id },
|
||||
{ label: "Workflow", value: instance.workflow_id },
|
||||
{ label: "Current State", value: instance.current_state_name },
|
||||
{ label: "Created", value: instance.created_at },
|
||||
{ label: "Updated", value: instance.updated_at },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Data",
|
||||
fields: Object.entries((instance as any).data ?? instance.global_data ?? {}).map(([k, v]) => ({
|
||||
label: k,
|
||||
value: v,
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
const history: StateTransition[] = audit.map((entry) => ({
|
||||
from_state: "",
|
||||
to_state: entry.execution_state,
|
||||
activity_name: entry.activity_id,
|
||||
performed_by: entry.user_id,
|
||||
timestamp: entry.created_at,
|
||||
data: entry.data,
|
||||
}));
|
||||
|
||||
return { report: instance, sections, history };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the raw audit log for an instance.
|
||||
*/
|
||||
async getAuditLog(instanceId: string): Promise<ActivityLogEntry[]> {
|
||||
if (this.client.isMock) {
|
||||
await mockDelay();
|
||||
return mockAuditLog();
|
||||
}
|
||||
|
||||
return this.client.request<ActivityLogEntry[]>(
|
||||
"GET",
|
||||
`/audit?instance_id=${encodeURIComponent(instanceId)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
223
src/zino-sdk/workflow.ts
Normal file
223
src/zino-sdk/workflow.ts
Normal file
@ -0,0 +1,223 @@
|
||||
import type { ZinoClient } from "./client";
|
||||
import type {
|
||||
Activity,
|
||||
ActivityForm,
|
||||
WorkflowDefinition,
|
||||
WorkflowInstance,
|
||||
} from "./types";
|
||||
import {
|
||||
mockDelay,
|
||||
MOCK_WORKFLOW_DEF,
|
||||
mockWorkflowInstance,
|
||||
mockActivityForm,
|
||||
} from "./mock";
|
||||
|
||||
/**
|
||||
* Workflow execution methods — start instances, perform activities,
|
||||
* fetch definitions, forms, and instance state.
|
||||
* Maps to core-service and view-service endpoints.
|
||||
*/
|
||||
export class WorkflowService {
|
||||
private client: ZinoClient;
|
||||
constructor(client: ZinoClient) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new workflow instance.
|
||||
* Core-service returns a wrapped response; we extract instance_id.
|
||||
*/
|
||||
async startWorkflow(
|
||||
workflowId: string,
|
||||
activityId: string,
|
||||
data?: Record<string, unknown>,
|
||||
): Promise<WorkflowInstance> {
|
||||
if (this.client.isMock) {
|
||||
await mockDelay();
|
||||
return mockWorkflowInstance(workflowId);
|
||||
}
|
||||
|
||||
const raw = await this.client.request<{
|
||||
success: boolean;
|
||||
instance_id?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}>("POST", "/start", {
|
||||
workflow_id: workflowId,
|
||||
activity_id: activityId,
|
||||
data,
|
||||
});
|
||||
|
||||
return {
|
||||
instance_id: raw.instance_id ?? "",
|
||||
workflow_id: workflowId,
|
||||
current_state_id: "",
|
||||
current_state_name: "",
|
||||
data: raw.data ?? {},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
} as WorkflowInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an activity on a running workflow instance.
|
||||
* Core-service returns a wrapped response; we extract instance_id.
|
||||
*/
|
||||
async performActivity(
|
||||
workflowId: string,
|
||||
instanceId: string,
|
||||
activityId: string,
|
||||
data?: Record<string, unknown>,
|
||||
): Promise<WorkflowInstance> {
|
||||
if (this.client.isMock) {
|
||||
await mockDelay();
|
||||
return mockWorkflowInstance(workflowId, instanceId);
|
||||
}
|
||||
|
||||
const raw = await this.client.request<{
|
||||
success: boolean;
|
||||
instance_id?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}>("POST", "/activity", {
|
||||
workflow_id: workflowId,
|
||||
instance_id: instanceId,
|
||||
activity_id: activityId,
|
||||
data,
|
||||
});
|
||||
|
||||
return {
|
||||
instance_id: raw.instance_id ?? instanceId,
|
||||
workflow_id: workflowId,
|
||||
current_state_id: "",
|
||||
current_state_name: "",
|
||||
data: raw.data ?? {},
|
||||
created_at: "",
|
||||
updated_at: new Date().toISOString(),
|
||||
} as WorkflowInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch instance state and compute available activities.
|
||||
* Uses /api-docs to get the activity list (no states endpoint exists),
|
||||
* so all non-INIT activities are returned — the server enforces real constraints.
|
||||
*/
|
||||
async getInstanceState(
|
||||
workflowId: string,
|
||||
instanceId: string,
|
||||
): Promise<{
|
||||
instance: WorkflowInstance;
|
||||
availableActivities: Activity[];
|
||||
}> {
|
||||
if (this.client.isMock) {
|
||||
await mockDelay();
|
||||
return {
|
||||
instance: mockWorkflowInstance(workflowId, instanceId),
|
||||
availableActivities: MOCK_WORKFLOW_DEF.activities.filter(
|
||||
(a) => a.type !== "INIT",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const [apiDocs, instance] = await Promise.all([
|
||||
this.client.request<Array<{
|
||||
activity_uid: string;
|
||||
activity_name: string;
|
||||
type: string;
|
||||
}>>("GET", `/api-docs?workflow_id=${encodeURIComponent(workflowId)}`),
|
||||
this.client.request<WorkflowInstance>("POST", "/instance", {
|
||||
workflow_id: workflowId,
|
||||
instance_id: instanceId,
|
||||
}),
|
||||
]);
|
||||
|
||||
// api-docs returns activity documentation; filter out INIT activities
|
||||
const availableActivities: Activity[] = (apiDocs ?? [])
|
||||
.filter((a) => a.type !== "INIT")
|
||||
.map((a) => ({
|
||||
uid: a.activity_uid,
|
||||
name: a.activity_name,
|
||||
type: a.type,
|
||||
}));
|
||||
|
||||
return { instance, availableActivities };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the workflow definition.
|
||||
* Note: /api-docs returns activity documentation (not full definition with states).
|
||||
* We build a partial WorkflowDefinition from it — states/transitions will be empty.
|
||||
*/
|
||||
async getWorkflowDefinition(
|
||||
workflowId: string,
|
||||
): Promise<WorkflowDefinition> {
|
||||
if (this.client.isMock) {
|
||||
await mockDelay();
|
||||
return { ...MOCK_WORKFLOW_DEF, workflow_id: workflowId };
|
||||
}
|
||||
|
||||
const apiDocs = await this.client.request<Array<{
|
||||
activity_uid: string;
|
||||
activity_name: string;
|
||||
type: string;
|
||||
}>>("GET", `/api-docs?workflow_id=${encodeURIComponent(workflowId)}`);
|
||||
|
||||
return {
|
||||
workflow_id: workflowId,
|
||||
version: 1,
|
||||
name: workflowId,
|
||||
states: [],
|
||||
activities: (apiDocs ?? []).map((a) => ({
|
||||
uid: a.activity_uid,
|
||||
name: a.activity_name,
|
||||
type: a.type,
|
||||
})),
|
||||
transitions: [],
|
||||
workflow_data_fields: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the form schema for a specific activity.
|
||||
*/
|
||||
async getForm(
|
||||
workflowId: string,
|
||||
activityId: string,
|
||||
deviceType: string = "desktop",
|
||||
instanceId?: string,
|
||||
): Promise<ActivityForm> {
|
||||
if (this.client.isMock) {
|
||||
await mockDelay();
|
||||
return mockActivityForm(workflowId, activityId);
|
||||
}
|
||||
|
||||
return this.client.request<ActivityForm>("POST", "/form", {
|
||||
workflow_id: workflowId,
|
||||
activity_id: activityId,
|
||||
device_type: deviceType,
|
||||
...(instanceId ? { instance_id: instanceId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit form data for an activity.
|
||||
*/
|
||||
async submitForm(
|
||||
workflowId: string,
|
||||
activityId: string,
|
||||
formData: Record<string, unknown>,
|
||||
deviceType: string = "desktop",
|
||||
instanceId?: string,
|
||||
): Promise<WorkflowInstance> {
|
||||
if (this.client.isMock) {
|
||||
await mockDelay();
|
||||
return mockWorkflowInstance(workflowId, instanceId);
|
||||
}
|
||||
|
||||
return this.client.request<WorkflowInstance>("POST", "/form/submit", {
|
||||
workflow_id: workflowId,
|
||||
activity_id: activityId,
|
||||
device_type: deviceType,
|
||||
form_data: formData,
|
||||
...(instanceId ? { instance_id: instanceId } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
13
tailwind.config.js
Normal file
13
tailwind.config.js
Normal 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
19
tsconfig.app.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
15
tsconfig.node.json
Normal file
15
tsconfig.node.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
33
vite.config.ts
Normal file
33
vite.config.ts
Normal 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),
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user