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