feat: seed from p2p reference repo

This commit is contained in:
Bhanu Prakash Sai Potteri 2026-08-19 12:42:50 +05:30
commit bd48e12c23
89 changed files with 20104 additions and 0 deletions

6
.gitignore vendored Normal file
View File

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

25
components.json Normal file
View File

@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

View File

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

16
index.html Normal file
View File

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

405
migration_dev_to_studio.sql Normal file

File diff suppressed because one or more lines are too long

164
p2p_demo_schema.sql Normal file
View File

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

7975
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "p2p-studio",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc -b",
"preview": "vite preview"
},
"dependencies": {
"@fontsource-variable/geist": "^5.3.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.60.5",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"lucide-react": "^0.441.0",
"next-themes": "^0.4.6",
"nuqs": "^2.9.2",
"radix-ui": "^1.6.7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0",
"recharts": "^3.10.1",
"shadcn": "^4.16.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@types/node": "^26.1.2",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"tailwindcss": "^4.3.3",
"typescript": "~5.5.4",
"vite": "^5.4.2"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

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

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

After

Width:  |  Height:  |  Size: 480 B

9
public/zino.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 27 KiB

47
seed.sql Normal file

File diff suppressed because one or more lines are too long

37
src/App.tsx Normal file
View File

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

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

@ -0,0 +1,661 @@
import { APP_ID } from "../config";
const TOKEN_KEY = "p2p_token";
function baseUrl(): string {
return (import.meta.env.VITE_ZINO_API_URL || "https://studio.getzino.in").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;
}
// Shared response handling — 401 bounces to login, error bodies are unwrapped
// from { error }. Used by both the JSON and multipart request paths.
async function parseResponse<T>(res: Response): Promise<T> {
if (res.status === 401) {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem("p2p_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>;
}
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,
});
return parseResponse<T>(res);
}
// Multipart POST for the field-scoped core-service endpoints (/upload,
// /ocr-extract). Content-Type is deliberately omitted so the browser sets the
// multipart boundary itself.
async function postFile<T>(path: string, file: File, ctx: FieldFileContext): Promise<T> {
const form = new FormData();
form.append("file", file);
form.append("workflow_uuid", ctx.workflowUuid);
form.append("activity_id", ctx.activityId);
form.append("field_id", ctx.fieldId);
if (ctx.columnId) form.append("column_id", ctx.columnId);
if (ctx.versionUuid) form.append("version_uuid", ctx.versionUuid);
if (ctx.instanceId) form.append("instance_id", String(ctx.instanceId));
const token = localStorage.getItem(TOKEN_KEY);
const res = await fetch(`${baseUrl()}${path}`, {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
return parseResponse<T>(res);
}
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
// One key the vision model must return, as configured in studio's OCR modal.
export interface OcrExtractionField {
key: string;
label: string;
data_type?: string;
description?: string;
}
// extraction_key → the form field that receives the extracted value.
export interface OcrFieldMapping {
extraction_key: string;
target_field: string;
}
// Studio's ocr_config block. `prompt` / `extraction_fields` are resolved
// server-side by /ocr-extract and are present here only for picker UX; the
// client genuinely needs `field_mappings` to know where to write the results.
export interface OcrConfig {
prompt?: string;
template?: string;
allowed_types?: string[] | string;
max_size_mb?: number | string;
placeholder?: string;
extraction_fields?: OcrExtractionField[];
field_mappings?: OcrFieldMapping[];
}
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;
placeholder?: string;
multiple?: boolean;
ocr_config?: OcrConfig;
// Some studio configs put the OCR keys flat on properties instead of
// nested under ocr_config — mirrored from FFOcrField's resolveOcrConfig.
allowed_types?: string[] | string;
max_size_mb?: number | string;
extraction_fields?: OcrExtractionField[];
field_mappings?: OcrFieldMapping[];
};
}
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[];
// Identifiers the field-scoped core endpoints (/upload, /ocr-extract) need
// so they can resolve the field's config + RBAC server-side.
workflow_uuid?: string;
version_uuid?: string;
}
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 }
: {}),
});
}
// ---------------------------------------------------------------------------
// Field files & OCR (core-service)
// ---------------------------------------------------------------------------
// Identifiers sent alongside every field-scoped file upload. The backend uses
// these to resolve the field's deployed allowed_types / max_size_mb / ocr_config
// and to run activity RBAC — anything the client sends about that config is
// ignored, so this trio is mandatory.
export interface FieldFileContext {
workflowUuid: string;
activityId: string;
fieldId: string;
columnId?: string;
versionUuid?: string;
instanceId?: string;
}
// What /upload returns and what gets stored in instance data. `uuid` is the
// stable app-scoped handle; blob_path is kept for the legacy serve fallback.
export interface FileMeta {
uuid: string;
blob_path: string;
original_name: string;
mime_type: string;
size_bytes: number;
}
export interface OcrExtractResponse {
// Keyed by the studio-configured extraction_fields[].key. A key whose value
// wasn't found in the document comes back as null.
extracted: Record<string, unknown>;
raw?: string;
// Set when the model's reply wasn't parseable JSON — `raw` still holds the text.
parse_error?: string;
}
// Stores a form-field file and returns the metadata to submit in its place.
export function uploadFieldFile(file: File, ctx: FieldFileContext): Promise<FileMeta> {
return postFile<FileMeta>(`/app/${APP_ID}/upload`, file, ctx);
}
// Runs vision OCR over an uploaded document. The prompt and extraction schema
// live in the deployed ocr_config, so only the file + identifiers go over the wire.
export function extractOcr(file: File, ctx: FieldFileContext): Promise<OcrExtractResponse> {
return postFile<OcrExtractResponse>(`/app/${APP_ID}/ocr-extract`, file, ctx);
}
// ---------------------------------------------------------------------------
// 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[];
user_groups?: string[];
activity_id: string;
data: Record<string, unknown>;
/** State UUID the instance landed in after this activity. */
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)}`);
}
// ---------------------------------------------------------------------------
// AI Employee monitoring (ai-employee-service)
// ---------------------------------------------------------------------------
// These are the same endpoints the studio builder's "AI Employee → Logs" tab
// uses. They are NOT app-scoped: the ingress maps studio.getzino.in/monitor
// straight to ai-employee-service:8090, so the path has no /app/{id} prefix.
// The app's own JWT is accepted.
/** One reasoning pass the employee took, as stored in aiemployee.tbl_ai_decisions. */
export interface AIDecision {
id: number;
ai_user_id: string;
instance_id: string;
/** The activity the employee SUBMITTED (its chosen next step), not the one it read. */
activity_id: string;
/** State the instance was in when the employee looked at it. */
instance_state?: string;
status: "submitted" | "failed" | "error" | "escalated" | "submitting" | string;
confidence?: number;
model?: string;
created_at: string;
/** Opening plan the model wrote before acting. */
plan?: string;
/** Why it chose this action. */
reasoning?: string;
/** Self-check pass. */
reflexion?: {
concerns?: string;
should_escalate?: boolean;
adjusted_confidence?: number;
} | null;
knowledge_sources?: string[] | null;
/** The payload it submitted with the activity. */
data?: Record<string, unknown>;
error_message?: string;
cost?: number;
llm_calls?: number;
duration_ms?: number;
}
export interface AIEscalation {
id: number;
instance_id: string;
recommended_action?: string;
reasoning?: string;
reason?: string;
status?: string;
created_at: string;
}
export interface AIInstanceLog {
instance_id: string;
decisions: AIDecision[];
escalations: AIEscalation[];
}
/** One step inside a run — a tool call, an LLM call, a context gather, etc. */
export interface AITraceEvent {
seq: number;
parent_seq?: number;
type: string;
name: string;
status: string;
started_at: string;
ended_at?: string;
duration_ms?: number;
input?: unknown;
output?: unknown;
tokens_in?: number;
tokens_out?: number;
cost?: number;
error?: string;
decision_id?: number;
}
/** One task the employee processed end-to-end. */
export interface AITrace {
trace_id: string;
events: AITraceEvent[];
}
export interface AIInstanceTrace {
instance_id: string;
traces: AITrace[];
}
export function getAIInstanceLog(instanceId: string): Promise<AIInstanceLog> {
return request("GET", `/monitor/instances/${encodeURIComponent(instanceId)}/log`);
}
export function getAIInstanceTrace(instanceId: string): Promise<AIInstanceTrace> {
return request("GET", `/monitor/instances/${encodeURIComponent(instanceId)}/trace`);
}
// ---------------------------------------------------------------------------
// Field-file download
// ---------------------------------------------------------------------------
/**
* Fetches a stored form-field file (e.g. the uploaded invoice PDF) as a Blob.
* `blobPath` comes from the FileMeta the upload returned and is echoed back in
* the activity's submitted data. Returns a Blob so the caller can either build
* an object URL for inline preview or trigger a download the endpoint sets
* Content-Disposition: attachment, so a plain link would always download.
*/
export async function downloadFieldFile(blobPath: string, originalName?: string): Promise<Blob> {
const params = new URLSearchParams({ path: blobPath });
if (originalName) params.set("name", originalName);
const token = localStorage.getItem(TOKEN_KEY);
const res = await fetch(`${baseUrl()}/app/${APP_ID}/download?${params.toString()}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) {
if (res.status === 401) {
localStorage.removeItem(TOKEN_KEY);
window.location.href = `${import.meta.env.BASE_URL}login`;
}
throw new Error(`Could not load the document (${res.status})`);
}
return res.blob();
}
// ---------------------------------------------------------------------------
// 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 ?? "",
});
}

View File

@ -0,0 +1,367 @@
// What the AI employee actually did on this instance.
//
// Sourced from /monitor/instances/{id}/log (decisions + escalations) and
// /trace (per-run step waterfall). Both are best-effort: an instance the
// employee never touched has no rows, and ai-employee-service is a separate
// deployment that can be down without this page breaking — hence `unavailable`
// rendering as a note rather than an error.
//
// Presentation is on our theme tokens; the decision↔trace linking, ordering and
// token/cost roll-up are the logic that came with the endpoints.
import { useMemo, useState } from "react";
import {
AlertTriangle,
ChevronDown,
Clock,
Coins,
Cpu,
Hash,
Sparkles,
TriangleAlert,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { AIDecision, AIEscalation, AITrace, AITraceEvent } from "@/api/viewService";
import { activityLabel, fmtDuration } from "@/lib/workflowMeta";
import { formatDate, formatTime } from "@/lib/format";
const STATUS_TONE: Record<string, string> = {
submitted: "bg-state-approved/12 text-state-approved",
escalated: "bg-state-clarification/12 text-state-clarification",
submitting: "bg-state-review/12 text-state-review",
failed: "bg-state-rejected/12 text-state-rejected",
error: "bg-state-rejected/12 text-state-rejected",
};
/** Same banding as the assessment block: 90 auto-accept, 60 re-check. */
const confidenceTone = (pct: number) =>
pct >= 90 ? "text-state-approved" : pct >= 60 ? "text-state-clarification" : "text-state-rejected";
interface Props {
decisions: AIDecision[];
escalations: AIEscalation[];
traces: AITrace[];
unavailable?: boolean;
}
export default function AiActivity({ decisions, escalations, traces, unavailable }: Props) {
// Each trace carries the decision_id it belongs to on its events, so the
// waterfall can be attached to the decision it explains.
const traceByDecision = useMemo(() => {
const m = new Map<number, AITrace>();
for (const t of traces) {
const id = t.events.find((e) => e.decision_id != null)?.decision_id;
if (id != null && !m.has(id)) m.set(id, t);
}
return m;
}, [traces]);
const ordered = useMemo(
() =>
[...decisions].sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
),
[decisions],
);
const totals = useMemo(() => {
let tokens = 0;
let cost = 0;
let ms = 0;
for (const t of traces)
for (const e of t.events) {
tokens += (e.tokens_in ?? 0) + (e.tokens_out ?? 0);
cost += e.cost ?? 0;
// Only top-level steps, or nested spans would be counted twice.
if (e.parent_seq == null) ms += e.duration_ms ?? 0;
}
return { tokens, cost, ms };
}, [traces]);
if (unavailable) {
return (
<p className="flex items-start gap-2 text-sm text-muted-foreground">
<TriangleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
The AI monitoring service isnt reachable, so decisions and traces cant be shown.
</p>
);
}
if (ordered.length === 0 && escalations.length === 0) {
return (
<p className="text-sm text-muted-foreground">The AI employee hasnt acted on this invoice.</p>
);
}
const models = [...new Set(ordered.map((d) => d.model).filter(Boolean))];
return (
<div>
{/* Run summary — the cost of the automation, before the detail of it. */}
{(ordered.length > 0 || totals.tokens > 0) && (
<dl className="mb-4 flex flex-wrap items-center gap-x-5 gap-y-1.5 text-2xs text-muted-foreground">
<Stat icon={Sparkles}>
{ordered.length} {ordered.length === 1 ? "decision" : "decisions"}
</Stat>
{totals.ms > 0 && <Stat icon={Clock}>{fmtDuration(totals.ms)}</Stat>}
{totals.tokens > 0 && (
<Stat icon={Hash}>{totals.tokens.toLocaleString("en-IN")} tokens</Stat>
)}
{totals.cost > 0 && <Stat icon={Coins}>${totals.cost.toFixed(4)}</Stat>}
{models.length > 0 && <Stat icon={Cpu}>{models.join(", ")}</Stat>}
</dl>
)}
<ol>
{ordered.map((d, i) => (
<Decision
key={d.id}
decision={d}
trace={traceByDecision.get(d.id)}
index={i + 1}
last={i === ordered.length - 1 && escalations.length === 0}
/>
))}
</ol>
{escalations.length > 0 && (
<div className="space-y-2.5">
{escalations.map((e) => (
<div
key={e.id}
className="border-l-2 border-state-clarification/50 pl-3"
>
<p className="flex flex-wrap items-center gap-1.5 text-base font-semibold text-state-clarification">
<AlertTriangle className="size-3.5 shrink-0" aria-hidden />
Escalated to a human
{e.recommended_action && (
<span className="text-2xs font-bold uppercase tracking-wider">
· {e.recommended_action}
</span>
)}
</p>
{(e.reasoning || e.reason) && (
<p className="mt-0.5 text-sm leading-relaxed text-muted-foreground">
{e.reasoning || e.reason}
</p>
)}
</div>
))}
</div>
)}
</div>
);
}
function Stat({ icon: Icon, children }: { icon: typeof Hash; children: React.ReactNode }) {
return (
<dd className="flex items-center gap-1 tabular-nums">
<Icon className="size-2.5 shrink-0 text-primary" aria-hidden />
{children}
</dd>
);
}
function Decision({
decision: d,
trace,
index,
last,
}: {
decision: AIDecision;
trace?: AITrace;
index: number;
last: boolean;
}) {
const [open, setOpen] = useState(false);
// The self-check can revise the model's own confidence down; show the number
// it ended up acting on, and say so when it changed.
const raw = d.confidence != null ? d.confidence * 100 : null;
const adjusted =
d.reflexion?.adjusted_confidence != null ? d.reflexion.adjusted_confidence * 100 : null;
const pct = adjusted ?? raw;
const time = formatTime(d.created_at);
const hasDetail = !!(d.plan || d.reasoning || d.reflexion?.concerns || trace);
return (
<li className={cn("relative flex gap-3", last ? "pb-0" : "pb-4")}>
{/* Connector — makes a multi-run sequence read as one process. */}
{!last && (
<span aria-hidden className="absolute bottom-0 left-3.5 top-8 w-px bg-primary/15" />
)}
<span className="relative flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/12 text-2xs font-bold tabular-nums text-primary">
{index}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-base font-semibold text-foreground">
{activityLabel(d.activity_id)}
</span>
<span
className={cn(
"rounded-full px-1.5 py-0.5 text-2xs font-bold uppercase tracking-wider",
STATUS_TONE[d.status] ?? "bg-muted text-muted-foreground",
)}
>
{d.status}
</span>
{pct != null && (
<span className={cn("text-sm font-bold tabular-nums", confidenceTone(pct))}>
{Math.round(pct)}%
{adjusted != null && raw != null && Math.round(adjusted) !== Math.round(raw) && (
<span className="ml-1 font-normal text-muted-foreground">
(self-checked from {Math.round(raw)}%)
</span>
)}
</span>
)}
{d.reflexion?.should_escalate && (
<span className="rounded-full bg-state-clarification/12 px-1.5 py-0.5 text-2xs font-bold uppercase tracking-wider text-state-clarification">
flagged for escalation
</span>
)}
</div>
<p className="mt-0.5 text-2xs tabular-nums text-muted-foreground/70">
{formatDate(d.created_at)}
{time && ` · ${time}`}
{d.duration_ms != null && ` · ${fmtDuration(d.duration_ms)}`}
{d.llm_calls != null && d.llm_calls > 0 && ` · ${d.llm_calls} LLM calls`}
</p>
{d.error_message && (
<p className="mt-1.5 border-l-2 border-destructive/50 pl-2.5 text-sm text-destructive">
{d.error_message}
</p>
)}
{hasDetail && (
<>
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="mt-1.5 flex items-center gap-1 rounded text-xs font-semibold text-primary hover:underline"
>
<ChevronDown
className={cn("size-3 transition-transform", open && "rotate-180")}
aria-hidden
/>
{open ? "Hide" : "Show"} the models work
</button>
{open && (
<div className="mt-2.5 space-y-3">
{d.plan && <Passage label="Plan" text={d.plan} />}
{d.reasoning && <Passage label="Reasoning" text={d.reasoning} />}
{d.reflexion?.concerns && (
<Passage
label="Self-check"
text={d.reflexion.concerns}
tone={d.reflexion.should_escalate ? "warn" : undefined}
/>
)}
{d.knowledge_sources && d.knowledge_sources.length > 0 && (
<div>
<Label>Knowledge used</Label>
<ul className="mt-1 flex flex-wrap gap-1.5">
{d.knowledge_sources.map((s) => (
<li
key={s}
className="rounded-md bg-muted px-1.5 py-0.5 text-2xs text-muted-foreground"
>
{s}
</li>
))}
</ul>
</div>
)}
{trace && <Waterfall trace={trace} />}
</div>
)}
</>
)}
</div>
</li>
);
}
function Label({ children }: { children: React.ReactNode }) {
return (
<p className="text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
{children}
</p>
);
}
function Passage({ label, text, tone }: { label: string; text: string; tone?: "warn" }) {
return (
<div
className={cn(
"border-l-2 pl-2.5",
tone === "warn" ? "border-state-clarification/50" : "border-primary/20",
)}
>
<p
className={cn(
"text-2xs font-semibold uppercase tracking-wider",
tone === "warn" ? "text-state-clarification" : "text-muted-foreground",
)}
>
{label}
</p>
<p className="mt-0.5 whitespace-pre-line text-sm leading-relaxed text-foreground/90">
{text.replace(/[ \t]{2,}/g, " ")}
</p>
</div>
);
}
/** Step timings for one run, as bars proportional to the slowest step. */
function Waterfall({ trace }: { trace: AITrace }) {
const events = [...trace.events].sort((a, b) => a.seq - b.seq);
const max = Math.max(...events.map((e) => e.duration_ms ?? 0), 1);
return (
<div>
<Label>Run trace</Label>
<ul className="mt-1.5 space-y-1">
{events.map((e) => (
<TraceRow key={e.seq} event={e} max={max} />
))}
</ul>
</div>
);
}
function TraceRow({ event: e, max }: { event: AITraceEvent; max: number }) {
const failed = !!e.error || e.status === "error" || e.status === "failed";
const tokens = (e.tokens_in ?? 0) + (e.tokens_out ?? 0);
return (
<li className="flex items-center gap-2 text-2xs">
<span
className={cn("min-w-0 flex-1 truncate", failed ? "text-destructive" : "text-muted-foreground")}
style={{ paddingLeft: e.parent_seq != null ? 12 : 0 }}
title={e.error || e.name || e.type}
>
{e.name || e.type}
</span>
{tokens > 0 && (
<span className="w-20 shrink-0 text-right tabular-nums text-muted-foreground/60">
{tokens.toLocaleString("en-IN")} tok
</span>
)}
<span className="h-1.5 w-20 shrink-0 overflow-hidden rounded-full bg-muted">
<span
className={cn("block h-full rounded-full", failed ? "bg-state-rejected" : "bg-primary/60")}
style={{ width: `${Math.max(((e.duration_ms ?? 0) / max) * 100, 2)}%` }}
/>
</span>
<span className="w-11 shrink-0 text-right tabular-nums text-muted-foreground">
{fmtDuration(e.duration_ms)}
</span>
</li>
);
}

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

@ -0,0 +1,344 @@
import { Suspense, lazy, useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { ChevronRight, Loader2, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import DynamicHeader from "./DynamicHeader";
import DashboardStats from "./DashboardStats";
import ScreenRenderer from "./ScreenRenderer";
import DetailViewPanel from "./DetailViewPanel";
import RecordViewTable from "./RecordViewTable";
import FormModal from "./FormModal";
import CommandPalette from "./CommandPalette";
import StatusBadge from "./StatusBadge";
import { TableSkeleton } from "./Skeleton";
import { getScreen, getInstance, type LayoutElement } from "@/api/viewService";
import { useHeaderConfig } from "@/hooks/useHeaderConfig";
import { useSidebar } from "@/hooks/useSidebar";
import { actionsForStateId, stateById } from "@/lib/workflow";
import {
RDBMS_DV_SCREEN_UUID,
RDBMS_RV_SCREEN_UUID,
SCREEN_UUIDS,
STATE_IDS,
WORKFLOW_ID,
} from "@/config";
// recharts is heavy — keep it out of the initial bundle so the queue paints
// first, then swap the charts in over a same-size placeholder.
const DashboardCharts = lazy(() => import("./DashboardCharts"));
/** rv_screens rendered directly rather than through a screen layout. */
const RDBMS_SCREEN_LABELS: Record<string, string> = {
[RDBMS_RV_SCREEN_UUID]: "Purchase Orders",
};
const RDBMS_DETAIL_SCREENS: Record<string, string> = {
[RDBMS_RV_SCREEN_UUID]: RDBMS_DV_SCREEN_UUID,
};
/** List screen → detail screen. */
const SCREEN_TO_DETAIL: 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 detailScreenFor = (screenId: string | null) =>
(screenId ? SCREEN_TO_DETAIL[screenId] ?? RDBMS_DETAIL_SCREENS[screenId] : null) ??
SCREEN_UUIDS.INVOICE_DETAIL;
export default function AppShell() {
const navigate = useNavigate();
const params = useParams();
const screenIdParam = params.screenId ?? null;
const instanceIdParam = params.instanceId ?? null;
const sidebar = useSidebar();
const { navItems, isSuccess: navReady } = useHeaderConfig();
const [activeScreenId, setActiveScreenId] = useState<string | null>(screenIdParam);
const [detailTitle, setDetailTitle] = useState<string | null>(null);
const [commandOpen, setCommandOpen] = useState(false);
const [formModal, setFormModal] = useState<{
activityId: string;
instanceId: string;
title: string;
} | null>(null);
const isRdbms = !!activeScreenId && activeScreenId === RDBMS_RV_SCREEN_UUID;
const detailViewId = instanceIdParam ? detailScreenFor(screenIdParam ?? activeScreenId) : null;
// Land on the first nav item when no screen is in the URL.
useEffect(() => {
if (!screenIdParam && !instanceIdParam && navReady && navItems.length > 0) {
handleNavigate(navItems[0].screen_uuid);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navReady, navItems.length, screenIdParam, instanceIdParam]);
// Keep local screen state in step with browser back/forward.
useEffect(() => {
if (screenIdParam && screenIdParam !== activeScreenId) setActiveScreenId(screenIdParam);
if (!instanceIdParam) setDetailTitle(null);
}, [screenIdParam, instanceIdParam, activeScreenId]);
// ── Screen layout ─────────────────────────────────────────────────────────
const screenQuery = useQuery({
queryKey: ["screen", activeScreenId],
enabled: !!activeScreenId && !isRdbms && !instanceIdParam,
staleTime: 5 * 60_000,
queryFn: async () => (await getScreen(activeScreenId!)).layout ?? ([] as LayoutElement[]),
});
const detailScreenQuery = useQuery({
queryKey: ["screen", detailViewId],
enabled: !!detailViewId,
staleTime: 5 * 60_000,
queryFn: async () => (await getScreen(detailViewId!)).layout ?? ([] as LayoutElement[]),
});
// ── Instance state ────────────────────────────────────────────────────────
// The old code faked "wait for the AI" with a setTimeout(2500) that bumped a
// refresh key twice. This polls while the instance is mid-analysis and stops
// as soon as it settles.
const instanceQuery = useQuery({
queryKey: ["instance", instanceIdParam],
enabled: !!instanceIdParam,
queryFn: () => getInstance(WORKFLOW_ID, instanceIdParam!),
refetchInterval: (q) => {
const stateId = (q.state.data as any)?.current_state_id;
return stateId === STATE_IDS.AI_ANALYSIS ? 3000 : false;
},
});
const instanceStateId: string | null =
(instanceQuery.data as any)?.current_state_id ?? null;
const instanceState = stateById(instanceStateId);
const analysing = instanceStateId === STATE_IDS.AI_ANALYSIS;
const actions = actionsForStateId(instanceStateId);
const handleNavigate = useCallback(
(id: string) => {
setActiveScreenId(id);
setDetailTitle(null);
navigate(`/screen/${id}`);
},
[navigate],
);
const handleRowClick = useCallback(
(instanceId: string) => navigate(`/screen/${activeScreenId}/detail/${instanceId}`),
[activeScreenId, navigate],
);
const handleBack = () => navigate(`/screen/${activeScreenId}`);
const screenLabel =
navItems.find((n) => n.screen_uuid === activeScreenId)?.label ??
(activeScreenId ? RDBMS_SCREEN_LABELS[activeScreenId] : undefined) ??
"Invoices";
if (!navReady && !screenIdParam && !instanceIdParam) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-3">
<Loader2 className="size-5 animate-spin text-primary" aria-hidden />
<span className="text-sm text-muted-foreground">Loading</span>
</div>
</div>
);
}
const showDetail = !!instanceIdParam && !!detailViewId;
return (
<div className="min-h-screen bg-background">
{/* Must be the first focusable element, and must move focus (not just
scroll) to satisfy WCAG 2.4.1. */}
<a href="#main" className="skip-link">
Skip to content
</a>
<DynamicHeader
activeScreenId={activeScreenId}
onNavigate={handleNavigate}
sidebar={sidebar}
/>
<main
id="main"
tabIndex={-1}
className={cn(
"px-4 py-5 transition-[margin] duration-200 sm:px-6",
// No margin on mobile — the sidebar is an overlay there.
sidebar.isMobile ? "ml-0" : sidebar.open ? "ml-60" : "ml-14",
)}
>
<div className="mx-auto w-full max-w-[1400px]">
{showDetail ? (
<div className="space-y-4">
{/* Breadcrumb shows the invoice number once the detail view
reports it, instead of the raw instance UUID. */}
<div className="flex flex-wrap items-center justify-between gap-3">
<nav aria-label="Breadcrumb" className="flex min-w-0 items-center gap-1 text-base">
<button
onClick={handleBack}
className="rounded font-medium text-muted-foreground transition-colors hover:text-primary"
>
{screenLabel}
</button>
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground/50" aria-hidden />
<span className="truncate font-medium text-foreground" aria-current="page">
{detailTitle ?? (
<span className="font-mono text-sm text-muted-foreground">
{instanceIdParam.slice(0, 8)}
</span>
)}
</span>
</nav>
<div className="flex items-center gap-2">
{analysing && (
<span className="flex items-center gap-1.5 rounded-full border border-state-analysis/25 bg-state-analysis/10 px-2.5 py-1 text-xs font-semibold text-state-analysis">
<Sparkles className="size-3 animate-pulse" aria-hidden />
AI analysing
</span>
)}
{instanceState && !analysing && actions.length === 0 && (
<StatusBadge value={instanceState.name} />
)}
{actions.map((a) => (
<Button
key={a.activityId}
size="sm"
variant={
a.style === "danger"
? "destructive"
: a.style === "ghost"
? "outline"
: "default"
}
className="h-8 gap-1.5"
onClick={() =>
setFormModal({
activityId: a.activityId,
instanceId: instanceIdParam,
title: a.label,
})
}
>
<a.icon className="size-3.5" aria-hidden />
{a.label}
</Button>
))}
</div>
</div>
{detailScreenQuery.data && detailScreenQuery.data.length > 0 ? (
<ScreenRenderer
layout={detailScreenQuery.data}
instanceId={instanceIdParam}
onTitle={setDetailTitle}
/>
) : (
<DetailViewPanel
viewId={detailViewId}
instanceId={instanceIdParam}
onTitle={setDetailTitle}
/>
)}
</div>
) : isRdbms ? (
<RecordViewTable
key={`rdbms-${activeScreenId}`}
viewId={activeScreenId!}
onRowClick={handleRowClick}
/>
) : screenQuery.isPending ? (
<>
<StatsPlaceholder />
<TableSkeleton rows={6} cols={6} />
</>
) : screenQuery.data && screenQuery.data.length > 0 ? (
<>
<DashboardStats />
<Suspense fallback={<ChartsPlaceholder />}>
<DashboardCharts />
</Suspense>
<ScreenRenderer
key={`screen-${activeScreenId}`}
layout={screenQuery.data}
onRowClick={handleRowClick}
/>
</>
) : (
<div className="py-20 text-center text-base text-muted-foreground">
Select a screen from the navigation.
</div>
)}
</div>
</main>
<CommandPalette
open={commandOpen}
onOpenChange={setCommandOpen}
onNavigate={handleNavigate}
/>
{formModal && (
<FormModal
activityId={formModal.activityId}
instanceId={formModal.instanceId}
title={formModal.title}
onClose={() => setFormModal(null)}
onSuccess={() => {
setFormModal(null);
instanceQuery.refetch();
}}
/>
)}
</div>
);
}
function StatsPlaceholder() {
return (
<div className="mb-3 grid grid-cols-2 gap-2.5 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="flex items-start gap-2.5 rounded-xl border border-border bg-card px-3.5 py-3.5"
>
<Skeleton className="size-9 rounded-lg" />
<div className="flex-1">
<Skeleton className="h-2.5 w-20" />
<Skeleton className="mt-1.5 h-5 w-12" />
<Skeleton className="mt-1.5 h-2.5 w-16" />
</div>
</div>
))}
</div>
);
}
/**
* Same height as the real charts row, so the lazy chunk arriving doesn't shove
* the table down the page. Kept local rather than imported from
* DashboardCharts, which would pull recharts back into the main bundle.
*/
function ChartsPlaceholder() {
return (
<div className="mb-3 grid gap-3 lg:grid-cols-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-xl border border-border bg-card p-4">
<Skeleton className="h-3.5 w-28" />
<Skeleton className="mt-1.5 h-2.5 w-40" />
<Skeleton className="mt-4 h-[190px] w-full rounded-lg" />
</div>
))}
</div>
);
}

View File

@ -0,0 +1,137 @@
// The invoice's history as a timeline.
//
// Built from GET /app/{id}/view/audit, which returns one row per performed
// activity with the actor (user_id + roles), the resulting state, the timestamp
// and the submitted payload. A status pill can't explain how an invoice reached
// its state; this can — including which steps the AI took versus a person, and
// how long each hop waited.
//
// Presentation is on our theme tokens; the actor resolution, per-activity
// headline and gap arithmetic are the logic that came with the endpoint.
import {
CheckCircle2,
FileUp,
Gavel,
HelpCircle,
MessageSquareReply,
PauseCircle,
Sparkles,
User,
XCircle,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { AuditEntry } from "@/api/viewService";
import { ACTIVITY_IDS } from "@/config";
import { activityLabel, fmtGap, resolveActor } from "@/lib/workflowMeta";
import { formatCurrency, formatDate, formatTime } from "@/lib/format";
import { stateById } from "@/lib/workflow";
import StatusBadge from "./StatusBadge";
const ACTIVITY_ICON: Record<string, LucideIcon> = {
[ACTIVITY_IDS.SUBMIT_INVOICE]: FileUp,
[ACTIVITY_IDS.REQUEST_CLARIFICATION]: HelpCircle,
[ACTIVITY_IDS.PROVIDE_CLARIFICATION]: MessageSquareReply,
[ACTIVITY_IDS.SUBMIT_RECOMMENDATION]: Gavel,
[ACTIVITY_IDS.APPROVE_PAYMENT]: CheckCircle2,
[ACTIVITY_IDS.REJECT_PAYMENT]: XCircle,
[ACTIVITY_IDS.HOLD_PAYMENT]: PauseCircle,
};
/**
* Per-activity headline: the one or two values that matter at a glance, so the
* timeline reads as a story instead of a list of generic event names.
*/
function summarise(e: AuditEntry): string | null {
const d = (e.data ?? {}) as Record<string, any>;
const money = (v: unknown) => (v != null ? formatCurrency(Number(v)) : null);
switch (e.activity_id) {
case ACTIVITY_IDS.SUBMIT_INVOICE:
return (
[d.invoice_number, d.po_number, money(d.total_amount)].filter(Boolean).join(" · ") || null
);
case ACTIVITY_IDS.REQUEST_CLARIFICATION:
return d.target_department
? `Asked ${String(d.target_department).replace(/_/g, " ")}`
: null;
case ACTIVITY_IDS.PROVIDE_CLARIFICATION:
return d.response_text ? String(d.response_text).replace(/[ \t]{2,}/g, " ") : null;
case ACTIVITY_IDS.SUBMIT_RECOMMENDATION:
return (
[
d.recommendation ? String(d.recommendation).toUpperCase() : null,
money(d.recommended_amount),
d.ai_confidence != null ? `${Math.round(Number(d.ai_confidence) * 100)}% confidence` : null,
]
.filter(Boolean)
.join(" · ") || null
);
case ACTIVITY_IDS.APPROVE_PAYMENT:
return money(d.approved_amount);
case ACTIVITY_IDS.REJECT_PAYMENT:
return d.rejection_reason ? String(d.rejection_reason) : null;
case ACTIVITY_IDS.HOLD_PAYMENT:
return d.hold_reason ? String(d.hold_reason) : null;
default:
return null;
}
}
export default function AuditTrail({ audit }: { audit: AuditEntry[] }) {
if (audit.length === 0) {
return <p className="text-sm text-muted-foreground">No activity recorded yet.</p>;
}
return (
<ol className="space-y-4">
{audit.map((e, i) => {
const actor = resolveActor(e.user_roles, e.user_id);
const Icon = ACTIVITY_ICON[e.activity_id] ?? Gavel;
const state = stateById(e.execution_state);
const summary = summarise(e);
const gap = i > 0 ? fmtGap(audit[i - 1].created_at, e.created_at) : "";
const time = formatTime(e.created_at);
return (
<li key={e.id} className="flex gap-3">
<span
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-full",
actor.isAI ? "bg-primary/12 text-primary" : "bg-muted text-muted-foreground",
)}
>
<Icon className="size-3.5" aria-hidden />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span className="text-base font-semibold text-foreground">
{activityLabel(e.activity_id)}
</span>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
{actor.isAI && <Sparkles className="size-2.5 text-primary" aria-hidden />}
{!actor.isAI && <User className="size-2.5" aria-hidden />}
{actor.name}
</span>
{state && <StatusBadge value={state.name} />}
</div>
{summary && (
<p className="mt-0.5 line-clamp-2 text-sm text-muted-foreground">{summary}</p>
)}
<p className="mt-0.5 text-2xs tabular-nums text-muted-foreground/70">
{formatDate(e.created_at)}
{time && ` · ${time}`}
{/* How long this step waited on the one before it. */}
{gap && ` · +${gap}`}
</p>
</div>
</li>
);
})}
</ol>
);
}

View File

@ -0,0 +1,153 @@
import { useEffect } from "react";
import { useQueryStates } from "nuqs";
import { FileText, ListFilter, Moon, Search, Sun, X } from "lucide-react";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command";
import { useTheme } from "@/hooks/ThemeContext";
import { useHeaderConfig } from "@/hooks/useHeaderConfig";
import { tableParams, tableParamsOptions } from "@/lib/tableParams";
import { WORKFLOW_STATES } from "@/lib/workflow";
import { cn } from "@/lib/utils";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onNavigate: (screenUuid: string) => void;
}
/**
* K palette jump between screens and apply status filters without leaving
* the keyboard. This is a queue-triage app; reaching for the mouse to change a
* filter is the slow path.
*/
export default function CommandPalette({ open, onOpenChange, onNavigate }: Props) {
const { theme, toggleTheme } = useTheme();
const { navItems } = useHeaderConfig();
const [params, setParams] = useQueryStates(tableParams, tableParamsOptions);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
onOpenChange(!open);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onOpenChange]);
const run = (fn: () => void) => {
fn();
onOpenChange(false);
};
const filtersActive = params.status.length > 0 || !!params.q;
return (
<CommandDialog
open={open}
onOpenChange={onOpenChange}
title="Command palette"
description="Jump to a screen or filter the queue"
>
<CommandInput placeholder="Jump to a screen, or filter by status…" />
<CommandList>
<CommandEmpty>No matches.</CommandEmpty>
{navItems.length > 0 && (
<CommandGroup heading="Screens">
{navItems.map((item) => (
<CommandItem
key={item.id}
value={`screen ${item.label}`}
onSelect={() => run(() => onNavigate(item.screen_uuid))}
>
<FileText className="size-3.5" aria-hidden />
{item.label}
</CommandItem>
))}
</CommandGroup>
)}
<CommandSeparator />
<CommandGroup heading="Filter by status">
{WORKFLOW_STATES.map((s) => {
const selected = params.status.includes(s.name);
return (
<CommandItem
key={s.key}
value={`filter ${s.label}`}
onSelect={() =>
run(() =>
setParams({
status: selected
? params.status.filter((x) => x !== s.name)
: [...params.status, s.name],
page: 1,
}),
)
}
>
<span className={cn("size-2 shrink-0 rounded-full", s.tone.solid)} aria-hidden />
{s.label}
{selected && <CommandShortcut>active</CommandShortcut>}
</CommandItem>
);
})}
{filtersActive && (
<CommandItem
value="clear filters"
onSelect={() => run(() => setParams({ status: [], q: "", page: 1 }))}
>
<X className="size-3.5" aria-hidden />
Clear all filters
</CommandItem>
)}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Actions">
<CommandItem
value="focus search"
onSelect={() =>
run(() => {
// Defer so the dialog has released focus first.
requestAnimationFrame(() => {
document
.querySelector<HTMLInputElement>('input[aria-label="Search records"]')
?.focus();
});
})
}
>
<Search className="size-3.5" aria-hidden />
Search records
<CommandShortcut>/</CommandShortcut>
</CommandItem>
<CommandItem value="toggle theme" onSelect={() => run(toggleTheme)}>
{theme === "dark" ? (
<Sun className="size-3.5" aria-hidden />
) : (
<Moon className="size-3.5" aria-hidden />
)}
Switch to {theme === "dark" ? "light" : "dark"} mode
</CommandItem>
<CommandItem value="filters help" disabled>
<ListFilter className="size-3.5" aria-hidden />
Tip: tiles on the dashboard also filter the queue
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
);
}

View File

@ -0,0 +1,314 @@
import {
Bar,
BarChart,
Cell,
LabelList,
Pie,
PieChart,
ResponsiveContainer,
Tooltip as RTooltip,
XAxis,
YAxis,
} from "recharts";
import { useQueryStates } from "nuqs";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import { tableParams, tableParamsOptions } from "@/lib/tableParams";
import { useInvoiceAnalytics, formatCurrency } from "@/hooks/useInvoiceAnalytics";
import { CATEGORY_COLORS } from "@/lib/palette";
const CHART_H = 190;
/**
* Aging ramp: fresh buckets in the calm end of the palette, overdue ones in
* warning hues the chart says "chase these" without a legend.
*/
const AGING_COLORS = [
"var(--chart-5)",
"var(--chart-4)",
"var(--chart-1)",
"var(--state-clarification)",
"var(--state-rejected)",
];
/**
* Tooltip styled off the theme tokens rather than recharts' defaults, which are
* hard-coded light and unreadable in dark mode.
*/
function ChartTooltip({ active, payload, label, valueFormat }: any) {
if (!active || !payload?.length) return null;
return (
<div className="rounded-lg border border-border bg-popover px-3 py-2 text-xs shadow-lg">
{label && <div className="mb-1 font-semibold text-popover-foreground">{label}</div>}
{payload.map((p: any) => (
<div key={p.name} className="flex items-center gap-2 text-muted-foreground">
<span
className="size-2 shrink-0 rounded-full"
style={{ background: p.payload?.color ?? p.color ?? p.fill }}
/>
<span className="text-popover-foreground">
{valueFormat ? valueFormat(p.value, p.payload) : p.value}
</span>
{p.payload?.label && <span>· {p.payload.label}</span>}
</div>
))}
</div>
);
}
function Panel({
title,
subtitle,
badge,
children,
className,
}: {
title: string;
subtitle?: string;
/** Headline figure for the panel, right-aligned against the title. */
badge?: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"rounded-xl border border-border bg-card p-4 transition-colors hover:border-border/80",
className,
)}
>
<div className="mb-3 flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{subtitle && <p className="mt-0.5 text-2xs text-muted-foreground">{subtitle}</p>}
</div>
{badge && (
<span className="shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-2xs font-semibold tabular-nums text-muted-foreground">
{badge}
</span>
)}
</div>
{children}
</div>
);
}
const AXIS_TICK = { fontSize: 10, fill: "var(--muted-foreground)" } as const;
export default function DashboardCharts() {
const { data, isPending, isError } = useInvoiceAnalytics();
const [, setParams] = useQueryStates(tableParams, tableParamsOptions);
if (isError) return null;
if (isPending) return <ChartsSkeleton />;
if (!data) return null;
const hasAging = data.aging.some((b) => b.count > 0);
const hasMix = data.byState.length > 0;
const hasVendors = data.topVendors.length > 0;
if (!hasAging && !hasMix && !hasVendors) return null;
const openTotal = data.aging.reduce((n, b) => n + b.count, 0);
const overdue = data.aging.filter((b) => b.overdue).reduce((n, b) => n + b.count, 0);
const agingData = data.aging.map((b, i) => ({ ...b, color: AGING_COLORS[i] }));
return (
<div className="mb-3 grid animate-fade-in-up gap-3 lg:grid-cols-2">
{/* ── Status mix ──────────────────────────────────────────────────── */}
{hasMix && (
<Panel
title="Invoice Pipeline"
subtitle="Click a slice or a row to filter the queue"
badge={data.capped ? `${data.sampled} sampled` : `${data.total} total`}
>
<div className="flex items-center gap-5">
<div className="relative size-[190px] shrink-0">
<ResponsiveContainer width="100%" height={CHART_H}>
<PieChart>
<Pie
data={data.byState}
dataKey="count"
nameKey="label"
innerRadius={58}
outerRadius={86}
paddingAngle={3}
cornerRadius={4}
strokeWidth={0}
onClick={(slice: any) =>
setParams({ status: [slice?.payload?.name], page: 1 })
}
>
{data.byState.map((s) => (
<Cell key={s.key} fill={s.color} className="cursor-pointer outline-hidden" />
))}
</Pie>
<RTooltip
content={
<ChartTooltip
valueFormat={(v: number, row: any) =>
`${v} ${v === 1 ? "invoice" : "invoices"}${
row?.amount ? ` · ${formatCurrency(row.amount, true)}` : ""
}`
}
/>
}
/>
</PieChart>
</ResponsiveContainer>
{/* Centre label a donut with a hole and nothing in it wastes
the most legible spot in the chart. */}
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span className="text-2xl font-bold tabular-nums leading-none text-foreground">
{data.total}
</span>
<span className="mt-1 text-2xs uppercase tracking-wider text-muted-foreground">
invoices
</span>
</div>
</div>
{/* Legend doubles as a per-state breakdown with counts and money. */}
<ul className="min-w-0 flex-1 space-y-0.5">
{data.byState.map((s) => (
<li key={s.key}>
<button
onClick={() => setParams({ status: [s.name], page: 1 })}
className="flex w-full items-center gap-2 rounded-md px-1.5 py-1 text-left transition-colors hover:bg-muted/60"
>
<span className="size-2.5 shrink-0 rounded-sm" style={{ background: s.color }} />
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{s.label}
</span>
<span className="text-xs font-semibold tabular-nums text-foreground">
{s.count}
</span>
{s.amount > 0 && (
<span className="w-14 shrink-0 text-right text-2xs tabular-nums text-muted-foreground">
{formatCurrency(s.amount, true)}
</span>
)}
</button>
</li>
))}
</ul>
</div>
</Panel>
)}
{/* ── Aging ───────────────────────────────────────────────────────── */}
{hasAging && (
<Panel
title="Open Invoice Aging"
subtitle={
overdue > 0
? `${overdue} of ${openTotal} open past 14 days`
: "Open invoices by days since submission"
}
badge={`${openTotal} open`}
>
<ResponsiveContainer width="100%" height={CHART_H}>
<BarChart data={agingData} margin={{ top: 16, right: 4, bottom: 0, left: -22 }}>
<XAxis dataKey="label" tickLine={false} axisLine={false} tick={AXIS_TICK} />
<YAxis allowDecimals={false} tickLine={false} axisLine={false} tick={AXIS_TICK} />
<RTooltip
cursor={{ fill: "var(--accent)", opacity: 0.4 }}
content={
<ChartTooltip
valueFormat={(v: number, row: any) =>
`${v} open${row?.amount ? ` · ${formatCurrency(row.amount, true)}` : ""}`
}
/>
}
/>
<Bar dataKey="count" radius={[6, 6, 0, 0]} maxBarSize={48}>
<LabelList
dataKey="count"
position="top"
fontSize={10}
fill="var(--muted-foreground)"
formatter={(v: number) => (v > 0 ? v : "")}
/>
{agingData.map((b) => (
<Cell key={b.label} fill={b.color} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</Panel>
)}
{/* ── Top vendors ─────────────────────────────────────────────────── */}
{hasVendors && (
<Panel
title="Vendor Spend"
subtitle="Highest billed value across the sampled invoices"
badge={`top ${data.topVendors.length}`}
>
<ResponsiveContainer width="100%" height={CHART_H}>
<BarChart
data={data.topVendors}
margin={{ top: 16, right: 4, bottom: 0, left: -14 }}
barCategoryGap="22%"
>
<XAxis
dataKey="name"
tickLine={false}
axisLine={false}
tick={AXIS_TICK}
interval={0}
// Vendor names are long and the panel is half-width — clip to
// the first word or two and leave the full name to the tooltip.
tickFormatter={(v: string) => (v.length > 12 ? `${v.slice(0, 11)}` : v)}
/>
<YAxis
tickLine={false}
axisLine={false}
tick={AXIS_TICK}
tickFormatter={(v: number) => formatCurrency(v, true)}
/>
<RTooltip
cursor={{ fill: "var(--accent)", opacity: 0.4 }}
content={
<ChartTooltip
valueFormat={(v: number, row: any) =>
`${formatCurrency(v, true)} · ${row?.count} ${
row?.count === 1 ? "invoice" : "invoices"
}`
}
/>
}
/>
<Bar dataKey="amount" radius={[6, 6, 0, 0]} maxBarSize={40}>
<LabelList
dataKey="amount"
position="top"
fontSize={10}
fill="var(--muted-foreground)"
formatter={(v: number) => formatCurrency(v, true)}
/>
{data.topVendors.map((v, i) => (
<Cell key={v.name} fill={CATEGORY_COLORS[i % CATEGORY_COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</Panel>
)}
</div>
);
}
function ChartsSkeleton() {
return (
<div className="mb-3 grid gap-3 lg:grid-cols-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-xl border border-border bg-card p-4">
<Skeleton className="h-3.5 w-28" />
<Skeleton className="mt-1.5 h-2.5 w-40" />
<Skeleton className="mt-4 h-[190px] w-full rounded-lg" />
</div>
))}
</div>
);
}

View File

@ -0,0 +1,218 @@
import { useQueryStates } from "nuqs";
import {
AlertCircle,
CheckCircle2,
Clock,
FileText,
TrendingDown,
TrendingUp,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { tableParams, tableParamsOptions } from "@/lib/tableParams";
import { useInvoiceAnalytics, formatCurrency, type InvoiceAnalytics } from "@/hooks/useInvoiceAnalytics";
import { WORKFLOW_STATES } from "@/lib/workflow";
interface Tile {
key: string;
label: string;
value: string;
/** Secondary line — the money or ratio behind the count. */
sub?: string;
icon: LucideIcon;
/** Tailwind classes for the icon chip. */
tone: string;
/** State names this tile filters the table down to. Empty = clears filters. */
filter: string[];
hint: string;
/** Fraction of all invoices this tile represents. */
share?: number;
/** Week-over-week change, as a fraction. */
delta?: number | null;
}
function buildTiles(a: InvoiceAnalytics): Tile[] {
const state = (key: string) => WORKFLOW_STATES.find((s) => s.key === key)!;
const pct = a.approvalRate === null ? null : Math.round(a.approvalRate * 100);
const share = (n: number) => (a.total > 0 ? n / a.total : 0);
return [
{
key: "total",
label: "Total Invoices",
value: String(a.total),
sub: a.totalAmount > 0 ? formatCurrency(a.totalAmount, true) + " in value" : undefined,
icon: FileText,
tone: "bg-primary/10 text-primary",
filter: [],
hint: "All invoices. Click to clear status filters.",
delta: a.momentum.deltaPct,
},
{
key: "clarification",
label: "Pending Clarification",
value: String(a.counts.clarification),
sub: "with departments",
icon: AlertCircle,
tone: "bg-state-clarification/10 text-state-clarification",
filter: [state("clarification").name],
hint: "Waiting on a department to answer the AI's question.",
share: share(a.counts.clarification),
},
{
key: "review",
label: "Finance Review",
value: String(a.counts.review),
sub: "awaiting decision",
icon: Clock,
tone: "bg-state-review/10 text-state-review",
filter: [state("review").name],
hint: "Queued for a finance reviewer to approve, reject or hold.",
share: share(a.counts.review),
},
{
key: "approved",
label: "Approved",
value: String(a.counts.approved),
sub:
pct !== null
? `${pct}% approval rate`
: a.approvedAmount > 0
? formatCurrency(a.approvedAmount, true)
: undefined,
icon: CheckCircle2,
tone: "bg-state-approved/10 text-state-approved",
filter: [state("approved").name],
hint: "Approved for payment.",
share: share(a.counts.approved),
},
];
}
export default function DashboardStats() {
const { data, isPending, isError } = useInvoiceAnalytics();
const [params, setParams] = useQueryStates(tableParams, tableParamsOptions);
if (isPending) return <StatsSkeletonRow />;
// A failed dashboard shouldn't take the table down with it.
if (isError || !data) return null;
const tiles = buildTiles(data);
const apply = (filter: string[]) => {
const same =
filter.length === params.status.length && filter.every((f) => params.status.includes(f));
// Clicking the active tile again clears it, so tiles behave like toggles.
setParams({ status: same ? [] : filter, page: 1 });
};
return (
<div className="mb-3 grid grid-cols-2 gap-2.5 lg:grid-cols-4">
{tiles.map((tile, i) => {
const Icon = tile.icon;
const active =
tile.filter.length > 0 &&
tile.filter.length === params.status.length &&
tile.filter.every((f) => params.status.includes(f));
const isClear = tile.filter.length === 0 && params.status.length === 0;
const on = active || isClear;
return (
<Tooltip key={tile.key}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => apply(tile.filter)}
aria-pressed={active}
style={{ animationDelay: `${i * 40}ms` }}
className={cn(
"group flex animate-fade-in-up items-start gap-2.5 rounded-xl border",
"bg-card px-3.5 py-3.5 text-left",
"transition-all duration-150 hover:-translate-y-0.5 hover:shadow-md",
on
? "border-primary/50 ring-1 ring-primary/20"
: "border-border hover:border-primary/30",
)}
>
<div
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg",
tile.tone,
)}
>
<Icon className="size-4" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-2xs font-semibold uppercase tracking-wide text-muted-foreground">
{tile.label}
</div>
<div className="mt-0.5 flex items-baseline gap-1.5">
<span className="text-xl font-bold tabular-nums leading-none tracking-tight text-foreground">
{tile.value}
</span>
{tile.delta != null ? (
<DeltaChip delta={tile.delta} />
) : tile.share !== undefined ? (
<span className="text-2xs font-medium tabular-nums text-muted-foreground">
{Math.round(tile.share * 100)}%
</span>
) : null}
</div>
{tile.sub && (
<div className="mt-1 truncate text-2xs text-muted-foreground/80">
{tile.sub}
</div>
)}
</div>
</button>
</TooltipTrigger>
<TooltipContent>
<p>{tile.hint}</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
);
}
function DeltaChip({ delta }: { delta: number }) {
const up = delta >= 0;
const Icon = up ? TrendingUp : TrendingDown;
return (
<span
className={cn(
"inline-flex shrink-0 items-center gap-0.5 rounded px-1 py-px text-2xs font-semibold tabular-nums",
up ? "bg-state-approved/10 text-state-approved" : "bg-state-clarification/10 text-state-clarification",
)}
title="Submissions this week vs last week"
>
<Icon className="size-2.5" aria-hidden />
{up ? "+" : ""}
{Math.round(delta * 100)}%
</span>
);
}
function StatsSkeletonRow() {
return (
<div className="mb-3 grid grid-cols-2 gap-2.5 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="flex items-start gap-2.5 rounded-xl border border-border bg-card px-3.5 py-3.5"
>
<Skeleton className="size-9 rounded-lg" />
<div className="flex-1">
<Skeleton className="h-2.5 w-20" />
<Skeleton className="mt-1.5 h-5 w-12" />
<Skeleton className="mt-1.5 h-2.5 w-16" />
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,983 @@
import { useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Bot,
Building2,
Calendar,
Check,
ChevronDown,
ChevronUp,
CircleSlash,
FileText,
Gavel,
History,
Landmark,
MessageSquare,
Minus,
ReceiptText,
Sparkles,
TriangleAlert,
X,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { getDVScreen, getDetailView } from "@/api/viewService";
import { useInstanceDetail } from "@/hooks/useInstanceDetail";
import { DetailSkeleton } from "./Skeleton";
import StatusBadge from "./StatusBadge";
import WorkflowStepper from "./WorkflowStepper";
import SourceDocument from "./SourceDocument";
import AuditTrail from "./AuditTrail";
import AiActivity from "./AiActivity";
import { colorForName } from "@/lib/palette";
import {
overallTone,
parseEvidence,
parseMatchSummary,
type Tone,
type Verdict,
} from "@/lib/matchSummary";
import { formatCurrency, formatDate, formatLabel, formatRelative, formatValue } from "@/lib/format";
interface Field {
field_key: string;
output_label: string;
data_type: string;
}
interface Props {
viewId: number | string;
instanceId: string;
/** Lets the shell show a real title in the breadcrumb instead of a UUID. */
onTitle?: (title: string) => void;
}
// ---------------------------------------------------------------------------
// Field taxonomy
//
// Verified against GET /app/169/view/detailview across instances in every
// state (submitted, AI analysis, awaiting clarification, financial review,
// approved, rejected, on hold). Keys are matched exactly rather than by
// substring: the old heuristics put `payment_terms` ("Net 30") in the money
// column, where it rendered as "—", and filed the department's and reviewer's
// own words under an "AI generated" heading.
//
// Anything not listed falls through to the Details grid, so a new field the
// server starts serving shows up rather than vanishing.
// ---------------------------------------------------------------------------
/** Rendered by the header/rail directly, not as a field row. */
const HANDLED = new Set([
"instance_id",
"current_state_id",
"current_state_name",
"po_status",
"created_at",
"invoice_number",
"po_number",
"vendor_name",
"vendor_id",
"total_amount",
"tax_amount",
]);
/** The AI's own assessment. */
const AI_KEYS = [
"recommendation",
"ai_confidence",
"match_summary",
"ai_reasoning",
"analysis_so_far",
"recommended_amount",
];
/** The AI's question to a department, and that department's answer. */
const CLARIFY_KEYS = [
"target_department",
"clarification_question",
"clarification_context",
"response_text",
"response_notes",
"confirmed_quantity",
];
/** What a human reviewer decided. */
const DECISION_KEYS = [
"approved_amount",
"rejection_reason",
"reviewer_notes_2",
"hold_reason",
];
const has = (row: Record<string, unknown>, key: string) => {
const v = row[key];
return v != null && v !== "" && !(typeof v === "string" && !v.trim());
};
const text = (row: Record<string, unknown>, key: string) => String(row[key] ?? "").trim();
/** The AI's verdict vocabulary, as served in `recommendation`. */
const VERDICT: Record<string, { label: string; text: string; icon: LucideIcon }> = {
approve: { label: "Recommends approval", text: "text-state-approved", icon: Check },
reject: { label: "Recommends rejection", text: "text-state-rejected", icon: X },
hold: { label: "Recommends hold", text: "text-state-hold", icon: CircleSlash },
};
/** Normalise a 01 or 0100 score to a percentage. `ai_confidence` is 01. */
function toPercent(value: unknown): number | null {
const n = Number(value);
if (!Number.isFinite(n)) return null;
return Math.max(0, Math.min(n > 1 ? n : n * 100, 100));
}
/**
* Confidence banding. 0.9 is the conventional auto-accept cutoff in document
* extraction; below ~60% a human should re-key rather than correct.
*/
function band(pct: number): "high" | "medium" | "low" {
return pct >= 90 ? "high" : pct >= 60 ? "medium" : "low";
}
const BAND_STYLE = {
high: { text: "text-state-approved", bar: "bg-state-approved", label: "High confidence" },
medium: {
text: "text-state-clarification",
bar: "bg-state-clarification",
label: "Medium confidence — worth a glance",
},
low: {
text: "text-state-rejected",
bar: "bg-state-rejected",
label: "Low confidence — verify against the document",
},
} as const;
// `match_summary` and `analysis_so_far` parsing lives in lib/matchSummary.ts.
// That parser handles cases mine did not: entity labels with digits and
// hyphens ("PO-3001:", "GR:"), a `warn` tone for PARTIAL/MISSING/PENDING, and
// "Payments: NONE" as neutral rather than a warning — NONE is the *good*
// outcome for a duplicate check.
// ---------------------------------------------------------------------------
export default function DetailViewPanel({ viewId, instanceId, onTitle }: Props) {
const { data, isPending, isError, error } = useQuery({
queryKey: ["detail-view", String(viewId), instanceId],
enabled: !!instanceId,
queryFn: async () => {
// Prefer the dv_screen's template; fall back to treating viewId as the
// template itself for older configs.
let res;
try {
const dv = await getDVScreen(viewId);
res = await getDetailView(dv.dv_template_uid, instanceId);
} catch {
res = await getDetailView(viewId, instanceId);
}
return {
fields: (res.config?.fields ?? []).filter((f: Field) => f.field_key !== ""),
data: res.data ?? {},
};
},
});
// Audit trail, AI decisions/traces and the uploaded PDF — one pass, shared by
// the three blocks below.
const instance = useInstanceDetail(instanceId);
const titleRef = useRef<string | null>(null);
const fields: Field[] = data?.fields ?? [];
const row = data?.data ?? {};
const invoiceNo = text(row, "invoice_number");
const poNo = text(row, "po_number");
const title = invoiceNo || poNo;
// Report the human-readable title upward for the breadcrumb.
useEffect(() => {
if (title && title !== titleRef.current) {
titleRef.current = title;
onTitle?.(title);
}
}, [title, onTitle]);
if (isPending) return <DetailSkeleton />;
if (isError) {
return (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-8 text-center">
<TriangleAlert className="mx-auto mb-2 size-5 text-destructive" aria-hidden />
<p className="text-base text-destructive">{(error as Error)?.message}</p>
</div>
);
}
if (!fields.length) return null;
const byKey = new Map(fields.map((f) => [f.field_key, f]));
const pick = (keys: string[]) =>
keys.map((k) => byKey.get(k)).filter((f): f is Field => !!f && has(row, f.field_key));
const stateName = text(row, "current_state_name") || text(row, "po_status");
const vendorName = text(row, "vendor_name");
const vendorId = text(row, "vendor_id");
const total = has(row, "total_amount") ? Number(row.total_amount) : null;
const aiFields = pick(AI_KEYS);
const clarifyFields = pick(CLARIFY_KEYS);
const decisionFields = pick(DECISION_KEYS);
// Everything the sections above don't claim — invoice_date, payment_terms,
// the Currency field (served under the opaque key `field_5`), notes, and any
// field the server adds later.
const detailFields = fields.filter(
(f) =>
!HANDLED.has(f.field_key) &&
!AI_KEYS.includes(f.field_key) &&
!CLARIFY_KEYS.includes(f.field_key) &&
!DECISION_KEYS.includes(f.field_key) &&
has(row, f.field_key),
);
return (
<div className="space-y-3">
<WorkflowStepper state={stateName} />
{/* One surface, divided by hairlines. Every section used to be its own
bordered card, which read as a pile of boxes. */}
<div className="overflow-hidden rounded-xl border border-border bg-card">
{/* ══ Header ═════════════════════════════════════════════════════════ */}
<header className="border-b border-border/60 px-6 py-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2.5">
{invoiceNo && (
<h1 className="text-xl font-bold tracking-tight text-foreground">{invoiceNo}</h1>
)}
<StatusBadge value={stateName} size="md" withIcon />
{/* The PO reference used to be dropped entirely it was
classified alongside the invoice number and only the first of
the pair was ever rendered. */}
{poNo && (
<span className="rounded-md border border-border bg-muted px-1.5 py-0.5 text-xs font-semibold tabular-nums text-muted-foreground">
{poNo}
</span>
)}
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
{vendorName && <span className="font-medium text-foreground/80">{vendorName}</span>}
{has(row, "created_at") && (
<span className="flex items-center gap-1">
<Calendar className="size-3" aria-hidden />
{formatDate(text(row, "created_at"))}
{formatRelative(text(row, "created_at")) && (
<span className="text-muted-foreground/70">
· {formatRelative(text(row, "created_at"))}
</span>
)}
</span>
)}
{has(row, "instance_id") && (
<span className="font-mono text-xs">#{text(row, "instance_id")}</span>
)}
</div>
</div>
{total !== null && total > 0 && (
<div className="shrink-0 text-right">
<p className="text-2xs font-medium uppercase tracking-wider text-muted-foreground">
{byKey.get("total_amount")?.output_label ?? "Total"}
</p>
<p className="text-3xl font-bold tabular-nums leading-none text-foreground">
{formatCurrency(total)}
</p>
</div>
)}
</div>
</header>
<div className="grid lg:grid-cols-[minmax(0,1fr)_320px]">
{/* ══ Main column ══════════════════════════════════════════════════ */}
<div className="divide-y divide-border/60">
{aiFields.length > 0 && <AiAssessment row={row} byKey={byKey} />}
{clarifyFields.length > 0 && <ClarificationThread row={row} byKey={byKey} />}
{decisionFields.length > 0 && (
<Decision row={row} fields={decisionFields} state={stateName} />
)}
{/* The document the whole record is about — write-only until now. */}
{(instance.invoicePdf || instance.loading) && (
<Block label="Source Document" icon={FileText}>
{instance.loading && !instance.invoicePdf ? (
<Skeleton className="h-14 w-full rounded-lg" />
) : (
<SourceDocument file={instance.invoicePdf} />
)}
</Block>
)}
<Block label="AI Activity" icon={Bot}>
{instance.loading ? (
<Skeleton className="h-16 w-full rounded-lg" />
) : (
<AiActivity
decisions={instance.decisions}
escalations={instance.escalations}
traces={instance.traces}
unavailable={instance.aiUnavailable}
/>
)}
</Block>
<Block label="History" icon={History}>
{instance.loading ? (
<Skeleton className="h-20 w-full rounded-lg" />
) : (
<AuditTrail audit={instance.audit} />
)}
</Block>
</div>
{/* ══ Rail ═════════════════════════════════════════════════════════ */}
<aside className="divide-y divide-border/60 border-border/60 lg:border-l">
<Financials row={row} byKey={byKey} total={total} />
{/* Single column: the rail is 320px, so a two-up grid would wrap
every label. */}
{detailFields.length > 0 && (
<Block label="Invoice Details" icon={ReceiptText}>
<dl className="space-y-3.5">
{detailFields.map((f) => (
<FieldRow key={f.field_key} field={f} value={row[f.field_key]} />
))}
</dl>
</Block>
)}
{(vendorName || vendorId) && (
<Block label="Vendor" icon={Building2}>
<div className="flex items-center gap-3">
<Avatar name={vendorName || vendorId} />
<div className="min-w-0">
<p className="truncate text-md font-semibold text-foreground">
{vendorName || "—"}
</p>
{vendorId && (
<p className="font-mono text-xs text-muted-foreground">{vendorId}</p>
)}
</div>
</div>
</Block>
)}
</aside>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sections
// ---------------------------------------------------------------------------
/** The AI's assessment: verdict, confidence, 3-way match result, reasoning. */
function AiAssessment({
row,
byKey,
}: {
row: Record<string, unknown>;
byKey: Map<string, Field>;
}) {
const verdict = VERDICT[text(row, "recommendation").toLowerCase()];
const pct = has(row, "ai_confidence") ? toPercent(row.ai_confidence) : null;
const checks = parseMatchSummary(text(row, "match_summary"));
const failed = checks.filter((c) => c.tone === "bad" || c.tone === "warn").length;
const worst = overallTone(checks);
// Failures lead — a mismatch buried below four passes is the one thing a
// reviewer must not skim past.
const rank = (t: Tone) => (t === "bad" ? 0 : t === "warn" ? 1 : 2);
const ordered = [...checks].sort((a, b) => rank(a.tone) - rank(b.tone));
// The evidence the verdict was reached from. Previously dropped outright by
// SKIP_KEYS for looking like a JSON blob, then shown as raw prose.
const evidence = parseEvidence(text(row, "analysis_so_far"));
const recommended = has(row, "recommended_amount") ? Number(row.recommended_amount) : null;
return (
<section className="bg-primary/[0.02] px-6 py-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<SectionLabel icon={Sparkles} className="mb-0">
Automated 3-Way Match
</SectionLabel>
{/* Names the actor rather than restating "AI" a third time. */}
<span className="rounded-full border border-primary/25 bg-primary/10 px-2 py-0.5 text-2xs font-semibold uppercase tracking-wider text-primary">
Zino AI Agent
</span>
</div>
{/* Verdict first: what the agent concluded, at what amount, how sure. */}
{(verdict || pct !== null || recommended !== null) && (
<div className="mb-4 flex flex-wrap items-center gap-x-5 gap-y-2">
{verdict && (
<span className={cn("inline-flex items-center gap-2 text-lg font-bold", verdict.text)}>
<verdict.icon className="size-5" strokeWidth={2.5} aria-hidden />
{verdict.label}
</span>
)}
{recommended !== null && recommended > 0 && (
<span className="text-sm text-muted-foreground">
at{" "}
<span className="text-md font-bold tabular-nums text-foreground">
{formatCurrency(recommended)}
</span>
</span>
)}
{pct !== null && <Confidence pct={pct} />}
</div>
)}
{/* Then the checks that produced it… */}
{checks.length > 0 && (
<SubBlock
title={byKey.get("match_summary")?.output_label ?? "Match Summary"}
badge={
failed > 0 ? (
<span
className={cn(
"rounded-full px-2 py-0.5 text-2xs font-bold uppercase tracking-wider",
worst === "bad"
? "bg-state-rejected/12 text-state-rejected"
: "bg-state-clarification/12 text-state-clarification",
)}
>
{failed} {failed === 1 ? "issue" : "issues"}
</span>
) : (
<span className="rounded-full bg-state-approved/12 px-2 py-0.5 text-2xs font-bold uppercase tracking-wider text-state-approved">
All {checks.length} checks pass
</span>
)
}
>
<ul className="grid gap-x-6 gap-y-2.5 sm:grid-cols-2">
{ordered.map((c) => (
<MatchRow key={c.label} check={c} />
))}
</ul>
</SubBlock>
)}
{/* then the underlying records those checks were run against PO,
goods receipt, contract, payment history and the discrepancy. */}
{evidence.length > 0 && (
<SubBlock title="Evidence gathered">
<dl className="space-y-1.5">
{evidence.map((e) => (
<div
key={e.label}
className={cn(
"flex flex-col gap-x-3 sm:flex-row",
e.isIssue && "rounded-md bg-state-rejected/[0.06] px-2 py-1",
)}
>
<dt
className={cn(
"text-base font-semibold sm:w-40 sm:shrink-0",
e.isIssue ? "text-state-rejected" : "text-foreground",
)}
>
{e.label}
</dt>
<dd
className={cn(
"min-w-0 flex-1 text-base",
e.isIssue ? "text-state-rejected" : "text-muted-foreground",
)}
>
{e.value}
</dd>
</div>
))}
</dl>
</SubBlock>
)}
{/* Reasoning last: it narrates everything above, so it reads as the
conclusion rather than the preamble. */}
{has(row, "ai_reasoning") && (
<SubBlock title={byKey.get("ai_reasoning")?.output_label ?? "AI Reasoning"} icon={Bot}>
<ClampedText text={text(row, "ai_reasoning")} />
</SubBlock>
)}
</section>
);
}
/**
* A labelled part of the AI block. The hairline above each one gives the
* section an internal rhythm without nesting cards inside a card.
*/
function SubBlock({
title,
badge,
icon: Icon,
children,
}: {
title: string;
badge?: React.ReactNode;
icon?: LucideIcon;
children: React.ReactNode;
}) {
return (
<div className="mt-4 border-t border-primary/10 pt-3.5 first:mt-0 first:border-0 first:pt-0">
<div className="mb-2.5 flex flex-wrap items-center gap-2">
{Icon && <Icon className="size-3.5 shrink-0 text-primary" aria-hidden />}
<p className="text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
{title}
</p>
{badge}
</div>
{children}
</div>
);
}
/**
* The clarification exchange, rendered as the conversation it is: the AI asks a
* department a question, the department answers. Previously both halves sat
* under an "AI generated" badge, crediting the AI with the department's reply.
*/
function ClarificationThread({
row,
byKey,
}: {
row: Record<string, unknown>;
byKey: Map<string, Field>;
}) {
const dept = text(row, "target_department");
const answered = has(row, "response_text") || has(row, "response_notes");
const confirmed = has(row, "confirmed_quantity") ? Number(row.confirmed_quantity) : null;
return (
<Block label="Clarification" icon={MessageSquare}>
{/* A timeline, not two cards: the vertical rule carries the exchange. */}
<ol className="space-y-5">
{(has(row, "clarification_question") || has(row, "clarification_context")) && (
<li className="relative flex gap-3 pb-1">
<Bubble icon={Bot} tone="primary" />
<div className="min-w-0 flex-1">
<p className="mb-1 text-2xs font-bold uppercase tracking-wider text-primary">
AI asked{dept && ` · ${formatLabel(dept)}`}
</p>
{has(row, "clarification_question") && (
<ClampedText text={text(row, "clarification_question")} />
)}
{has(row, "clarification_context") && (
<>
<p className="mt-2.5 text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
{byKey.get("clarification_context")?.output_label ?? "Context"}
</p>
<ClampedText text={text(row, "clarification_context")} muted />
</>
)}
</div>
</li>
)}
{answered && (
<li className="flex gap-3">
<Bubble icon={MessageSquare} tone="clarification" />
<div className="min-w-0 flex-1">
<p className="mb-1 text-2xs font-bold uppercase tracking-wider text-state-clarification">
{dept ? `${formatLabel(dept)} replied` : "Reply"}
</p>
{has(row, "response_text") && <ClampedText text={text(row, "response_text")} />}
{has(row, "response_notes") && (
<>
<p className="mt-2.5 text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
{byKey.get("response_notes")?.output_label ?? "Additional Notes"}
</p>
<ClampedText text={text(row, "response_notes")} muted />
</>
)}
{confirmed !== null && confirmed > 0 && (
<p className="mt-2.5 text-xs text-muted-foreground">
{byKey.get("confirmed_quantity")?.output_label ?? "Confirmed Quantity"}:{" "}
<span className="font-semibold tabular-nums text-foreground">
{confirmed.toLocaleString("en-IN")}
</span>
</p>
)}
</div>
</li>
)}
</ol>
</Block>
);
}
/** What the human reviewer decided, and why. */
function Decision({
row,
fields,
state,
}: {
row: Record<string, unknown>;
fields: Field[];
state: string;
}) {
const approved = has(row, "approved_amount") ? Number(row.approved_amount) : null;
// reviewer_notes_2 is served verbatim-identical to rejection_reason on the
// rejected instances, so showing both would just repeat the sentence.
const seen = new Set<string>();
const reasons = fields.filter((f) => {
if (f.field_key === "approved_amount") return false;
const v = text(row, f.field_key);
if (seen.has(v)) return false;
seen.add(v);
return true;
});
return (
<Block label="Reviewer Decision" icon={Gavel}>
{approved !== null && approved > 0 && (
<div className="mb-3 flex items-baseline justify-between">
<span className="text-base font-semibold text-foreground">Approved amount</span>
<span className="text-lg font-bold tabular-nums text-state-approved">
{formatCurrency(approved)}
</span>
</div>
)}
<div className="space-y-3">
{reasons.map((f) => (
<div key={f.field_key}>
<p className="mb-1 text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
{formatLabel(f.output_label)}
</p>
<ClampedText text={text(row, f.field_key)} />
</div>
))}
</div>
{reasons.length === 0 && approved === null && (
<p className="text-sm text-muted-foreground">Decided in state {state}.</p>
)}
</Block>
);
}
/** Money rail: the served amounts, plus recommended-vs-approved when both exist. */
function Financials({
row,
byKey,
total,
}: {
row: Record<string, unknown>;
byKey: Map<string, Field>;
total: number | null;
}) {
const tax = has(row, "tax_amount") ? Number(row.tax_amount) : null;
const recommended = has(row, "recommended_amount") ? Number(row.recommended_amount) : null;
const approved = has(row, "approved_amount") ? Number(row.approved_amount) : null;
if (total === null && tax === null) {
return (
<Block label="Financials" icon={Landmark}>
<p className="text-sm text-muted-foreground">No amounts on this record.</p>
</Block>
);
}
return (
<Block label="Financials" icon={Landmark}>
{tax !== null && (
<AmountRow label={byKey.get("tax_amount")?.output_label ?? "Tax"} value={tax} />
)}
{total !== null && (
<>
<Separator className="my-2" />
<div className="flex items-baseline justify-between">
<span className="text-base font-semibold text-foreground">
{byKey.get("total_amount")?.output_label ?? "Total"}
</span>
<span className="text-lg font-bold tabular-nums text-foreground">
{formatCurrency(total)}
</span>
</div>
</>
)}
{(recommended !== null || approved !== null) && (
<div className="mt-3 space-y-1.5 border-t border-border/60 pt-3">
{recommended !== null && (
<AmountRow
label={byKey.get("recommended_amount")?.output_label ?? "Recommended"}
value={recommended}
hint="AI"
/>
)}
{approved !== null && (
<AmountRow
label={byKey.get("approved_amount")?.output_label ?? "Approved"}
value={approved}
emphasis
/>
)}
</div>
)}
</Block>
);
}
// ---------------------------------------------------------------------------
// Pieces
// ---------------------------------------------------------------------------
/** A titled region of the single surface — no border of its own. */
function Block({
label,
icon,
children,
className,
}: {
label: string;
icon: LucideIcon;
children: React.ReactNode;
className?: string;
}) {
return (
<section className={cn("px-6 py-5", className)}>
<SectionLabel icon={icon}>{label}</SectionLabel>
{children}
</section>
);
}
function SectionLabel({
children,
icon: Icon,
className,
}: {
children: React.ReactNode;
icon?: LucideIcon;
className?: string;
}) {
return (
<p
className={cn(
"mb-3.5 flex items-center gap-2 text-2xs font-bold uppercase tracking-widest text-muted-foreground",
className,
)}
>
{Icon && (
<span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
<Icon className="size-3.5" aria-hidden />
</span>
)}
{children}
</p>
);
}
function FieldRow({ field, value }: { field: Field; value: unknown }) {
return (
<div>
<dt className="text-xs text-muted-foreground">{formatLabel(field.output_label)}</dt>
<dd className="mt-0.5 text-md font-medium text-foreground">
{formatValue(value, field.data_type, field.field_key)}
</dd>
</div>
);
}
const MATCH_TONE: Record<Tone, { text: string; disc: string; icon: LucideIcon }> = {
good: {
text: "text-state-approved",
disc: "bg-state-approved/12 text-state-approved",
icon: Check,
},
bad: { text: "text-state-rejected", disc: "bg-state-rejected/12 text-state-rejected", icon: X },
warn: {
text: "text-state-clarification",
disc: "bg-state-clarification/12 text-state-clarification",
icon: TriangleAlert,
},
neutral: { text: "text-muted-foreground", disc: "bg-muted text-muted-foreground", icon: Minus },
};
/**
* One 3-way-match check. Two lines: verdict on the first so the column of
* MATCH/MISMATCH scans vertically, supporting detail muted beneath it.
*/
function MatchRow({ check }: { check: Verdict }) {
const tone = MATCH_TONE[check.tone];
return (
<li className="flex items-start gap-2.5">
<span
className={cn(
"mt-px flex size-5 shrink-0 items-center justify-center rounded-full",
tone.disc,
)}
>
<tone.icon className="size-3" strokeWidth={3} aria-hidden />
</span>
<div className="min-w-0">
<p className="flex flex-wrap items-baseline gap-x-1.5">
<span className="text-base font-semibold text-foreground">{check.label}</span>
{check.verdict && (
<span
className={cn("text-2xs font-bold uppercase tracking-wider", tone.text)}
>
{check.verdict}
</span>
)}
</p>
{check.detail && (
<p className="mt-0.5 text-xs leading-snug text-muted-foreground">{check.detail}</p>
)}
</div>
</li>
);
}
function Confidence({ pct }: { pct: number }) {
const style = BAND_STYLE[band(pct)];
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex cursor-default items-center gap-2">
<span className="text-2xs uppercase tracking-wider text-muted-foreground">Confidence</span>
<span
className="h-1.5 w-16 overflow-hidden rounded-full bg-muted"
role="meter"
aria-valuenow={Math.round(pct)}
aria-valuemin={0}
aria-valuemax={100}
aria-label="AI confidence"
>
<span
className={cn("block h-full rounded-full", style.bar)}
style={{ width: `${pct}%` }}
/>
</span>
<span className={cn("text-sm font-bold tabular-nums", style.text)}>
{pct.toFixed(0)}%
</span>
</span>
</TooltipTrigger>
<TooltipContent>{style.label}</TooltipContent>
</Tooltip>
);
}
function Bubble({ icon: Icon, tone }: { icon: LucideIcon; tone: "primary" | "clarification" }) {
return (
<span
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-lg",
tone === "primary"
? "bg-primary/15 text-primary"
: "bg-state-clarification/15 text-state-clarification",
)}
>
<Icon className="size-4" aria-hidden />
</span>
);
}
function Avatar({ name }: { name: string }) {
const initials = name
.split(/\s+/)
.filter((w) => /[a-z0-9]/i.test(w))
.slice(0, 2)
.map((w) => w[0]!.toUpperCase())
.join("");
const tint = colorForName(name);
return (
<span
aria-hidden
className="flex size-10 shrink-0 items-center justify-center rounded-full text-sm font-bold"
style={{ background: `color-mix(in oklab, ${tint} 15%, transparent)`, color: tint }}
>
{initials || "?"}
</span>
);
}
function AmountRow({
label,
value,
emphasis,
hint,
}: {
label: string;
value: number;
emphasis?: boolean;
hint?: string;
}) {
return (
<div className="flex items-baseline justify-between gap-2">
<span className="flex items-center gap-1.5 text-base text-muted-foreground">
{formatLabel(label)}
{hint && (
<span className="rounded bg-primary/10 px-1 text-2xs font-bold uppercase text-primary">
{hint}
</span>
)}
</span>
<span
className={cn(
"tabular-nums",
emphasis ? "text-md font-bold text-foreground" : "text-base text-foreground/80",
)}
>
{Number.isFinite(value) ? formatCurrency(value) : "—"}
</span>
</div>
);
}
/** Three lines by default, expandable. */
function ClampedText({ text: value, muted }: { text: string; muted?: boolean }) {
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);
}, [value]);
return (
<div>
<p
ref={ref}
className={cn(
"whitespace-pre-line text-base leading-relaxed",
muted ? "text-muted-foreground" : "text-foreground/90",
!expanded && "line-clamp-3",
)}
>
{/* The served prose is padded with runs of layout whitespace. */}
{value.replace(/[ \t]{2,}/g, " ")}
</p>
{(clamped || expanded) && (
<button
onClick={() => setExpanded((e) => !e)}
aria-expanded={expanded}
className="mt-1.5 flex items-center gap-1 rounded text-xs font-semibold text-primary hover:underline"
>
{expanded ? (
<>
<ChevronUp className="size-3" aria-hidden /> Show less
</>
) : (
<>
<ChevronDown className="size-3" aria-hidden /> Show more
</>
)}
</button>
)}
</div>
);
}

View File

@ -0,0 +1,374 @@
import { useNavigate } from "react-router-dom";
import {
FileText,
HelpCircle,
Landmark,
List,
LogOut,
Moon,
PanelLeftClose,
PanelLeftOpen,
Settings,
ShoppingCart,
Sun,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuthContext } from "@/hooks/AuthContext";
import { useTheme } from "@/hooks/ThemeContext";
import { useHeaderConfig } from "@/hooks/useHeaderConfig";
import { RDBMS_RV_SCREEN_UUID } from "@/config";
import type { NavItem } from "@/api/viewService";
const ICON_MAP: Record<string, typeof FileText> = {
receipt_long: FileText,
help_outline: HelpCircle,
account_balance: Landmark,
list_alt: List,
inventory_2: FileText,
question_mark: HelpCircle,
list: List,
};
/** Screens not present in the served nav config. */
const EXTRA_NAV: Array<{ id: string; screen_uuid: string; label: string; icon: typeof FileText }> = [
{
id: "vendor-data",
screen_uuid: RDBMS_RV_SCREEN_UUID,
label: "Purchase Orders",
icon: ShoppingCart,
},
];
interface Props {
activeScreenId: string | null;
onNavigate: (screenUuid: string) => void;
sidebar: {
open: boolean;
toggle: () => void;
isMobile: boolean;
mobileOpen: boolean;
setMobileOpen: (v: boolean) => void;
};
}
export default function DynamicHeader({
activeScreenId,
onNavigate,
sidebar,
}: Props) {
const { user, logout } = useAuthContext();
const { theme, toggleTheme } = useTheme();
const navigate = useNavigate();
const { navItems, isPending } = useHeaderConfig();
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, " ") ?? "";
// Collapsed on desktop = icon rail. Previously the sidebar animated to w-0,
// which took the entire navigation away instead of condensing it.
const railed = !sidebar.open && !sidebar.isMobile;
const items = [
...navItems.map((item: NavItem) => ({
id: item.id,
screen_uuid: item.screen_uuid,
label: item.label,
Icon: ICON_MAP[item.icon] ?? FileText,
})),
...EXTRA_NAV.map((e) => ({
id: e.id,
screen_uuid: e.screen_uuid,
label: e.label,
Icon: e.icon,
})),
];
const nav = (
<SidebarBody
items={items}
isPending={isPending}
railed={railed}
activeScreenId={activeScreenId}
onNavigate={(id) => {
onNavigate(id);
sidebar.setMobileOpen(false);
}}
user={{ name: user?.name, roleName, initials }}
onLogout={handleLogout}
/>
);
return (
<>
{/* ── Top bar ───────────────────────────────────────────────────────── */}
{/* Offset past the sidebar so the two read as one chrome: the sidebar
runs full height and owns the brand; the bar starts where it ends. */}
<header
className={cn(
"sticky top-0 z-50 flex h-14 items-center justify-between gap-3 border-b border-border bg-background/85 px-3 backdrop-blur-md transition-[margin] duration-200",
sidebar.isMobile ? "ml-0" : sidebar.open ? "ml-60" : "ml-14",
)}
>
<div className="flex min-w-0 items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="size-8" onClick={sidebar.toggle}>
{sidebar.open || sidebar.isMobile ? (
<PanelLeftClose className="size-4" aria-hidden />
) : (
<PanelLeftOpen className="size-4" aria-hidden />
)}
<span className="sr-only">Toggle navigation</span>
</Button>
</TooltipTrigger>
<TooltipContent>Toggle navigation</TooltipContent>
</Tooltip>
{/* On desktop the sidebar carries the identity; repeating the logo
here would double it up. */}
{sidebar.isMobile && (
<img
src={import.meta.env.BASE_URL + "zino.svg"}
alt="Zino"
className="h-5 w-auto dark:brightness-0 dark:invert"
/>
)}
</div>
<div className="flex items-center gap-1.5">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="size-8" onClick={toggleTheme}>
{theme === "dark" ? (
<Sun className="size-4" aria-hidden />
) : (
<Moon className="size-4" aria-hidden />
)}
<span className="sr-only">
Switch to {theme === "dark" ? "light" : "dark"} theme
</span>
</Button>
</TooltipTrigger>
<TooltipContent>{theme === "dark" ? "Light mode" : "Dark mode"}</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="gradient-brand flex size-8 items-center justify-center rounded-full ring-2 ring-background transition-shadow hover:ring-primary/30">
<span className="text-xs font-bold leading-none text-white">{initials}</span>
<span className="sr-only">Account menu</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<div className="px-2 py-2">
<div className="flex items-center gap-2.5">
<div className="gradient-brand flex size-9 shrink-0 items-center justify-center rounded-full">
<span className="text-sm font-bold text-white">{initials}</span>
</div>
<div className="min-w-0">
<p className="truncate text-base font-semibold text-foreground">{user?.name}</p>
<p className="truncate text-xs text-muted-foreground">{user?.email}</p>
</div>
</div>
{roleName && (
<span className="mt-2 inline-block rounded-full border border-primary/25 bg-primary/10 px-2 py-0.5 text-2xs font-semibold uppercase tracking-wider text-primary">
{roleName}
</span>
)}
</div>
<DropdownMenuSeparator />
<DropdownMenuItem disabled>
<Settings className="size-3.5" aria-hidden />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={handleLogout}>
<LogOut className="size-3.5" aria-hidden />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
{/* ── Sidebar ───────────────────────────────────────────────────────── */}
{sidebar.isMobile ? (
<Sheet open={sidebar.mobileOpen} onOpenChange={sidebar.setMobileOpen}>
<SheetContent side="left" className="w-64 bg-sidebar p-0">
<SheetTitle className="sr-only">Navigation</SheetTitle>
{nav}
</SheetContent>
</Sheet>
) : (
<aside
className={cn(
"fixed inset-y-0 left-0 z-40 flex flex-col border-r border-sidebar-border bg-sidebar transition-[width] duration-200",
railed ? "w-14" : "w-60",
)}
>
{nav}
</aside>
)}
</>
);
}
// ---------------------------------------------------------------------------
interface SidebarBodyProps {
items: Array<{ id: string; screen_uuid: string; label: string; Icon: typeof FileText }>;
isPending: boolean;
railed: boolean;
activeScreenId: string | null;
onNavigate: (screenUuid: string) => void;
user: { name?: string; roleName: string; initials: string };
onLogout: () => void;
}
function SidebarBody({
items,
isPending,
railed,
activeScreenId,
onNavigate,
user,
onLogout,
}: SidebarBodyProps) {
return (
<div className="flex h-full min-h-0 flex-col">
{/* App identity h-14 with a bottom border so it lines up exactly with
the top bar and the two read as one continuous frame. */}
<div
className={cn(
"flex h-14 shrink-0 flex-col items-center justify-center gap-1 border-b border-sidebar-border px-3",
railed && "px-0",
)}
>
{/* 43×20 wordmark fits the 56px rail as-is, so it needs no separate
collapsed mark. Inverted in dark mode like the top-bar copy. */}
<img
src={import.meta.env.BASE_URL + "zino.svg"}
alt="Zino"
className="h-5 w-auto shrink-0 dark:brightness-0 dark:invert"
/>
{!railed && (
<span className="truncate text-2xs font-medium tracking-wide text-muted-foreground">
Procure to Pay
</span>
)}
</div>
{/* Nav */}
<nav
aria-label="Screens"
className="scrollbar-thin min-h-0 flex-1 overflow-y-auto px-2.5 py-4"
>
{!railed && (
<p className="mb-3 px-2 text-2xs font-bold uppercase tracking-widest text-muted-foreground">
Navigation
</p>
)}
{isPending ? (
<div className="space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className={cn("h-10", railed ? "mx-auto w-10" : "w-full")} />
))}
</div>
) : (
<ul className="space-y-2">
{items.map(({ id, screen_uuid, label, Icon }) => {
const active = activeScreenId === screen_uuid;
const button = (
<button
onClick={() => onNavigate(screen_uuid)}
aria-current={active ? "page" : undefined}
className={cn(
"flex w-full items-center gap-3 rounded-lg px-3 py-3 text-base font-medium transition-colors",
railed && "justify-center px-0",
active
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
)}
>
<Icon className="size-4 shrink-0" aria-hidden />
{!railed && <span className="truncate">{label}</span>}
{railed && <span className="sr-only">{label}</span>}
</button>
);
return (
<li key={id}>
{railed ? (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="right">{label}</TooltipContent>
</Tooltip>
) : (
button
)}
</li>
);
})}
</ul>
)}
</nav>
{/* User card */}
<Separator className="bg-sidebar-border" />
<div className="p-2">
<button
onClick={onLogout}
className={cn(
"group flex w-full items-center gap-2.5 rounded-lg px-2 py-2 transition-colors hover:bg-sidebar-accent/50",
railed && "justify-center px-0",
)}
>
<div className="gradient-brand flex size-7 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white">
{user.initials}
</div>
{!railed && (
<>
<div className="min-w-0 flex-1 text-left">
<p className="truncate text-sm font-semibold leading-none text-sidebar-foreground">
{user.name}
</p>
<p className="mt-0.5 truncate text-2xs capitalize text-muted-foreground">
{user.roleName}
</p>
</div>
<LogOut
className="size-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-destructive"
aria-hidden
/>
</>
)}
<span className="sr-only">Sign out</span>
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,429 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { AlertCircle, ArrowRight, CheckCircle2, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Skeleton } from "@/components/ui/skeleton";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
getFormScreen,
performActivity,
startWorkflow,
uploadFieldFile,
type FieldFileContext,
type FormScreenField,
} from "@/api/viewService";
import { WORKFLOW_ID } from "@/config";
import OcrUploadField, { resolveOcrConfig } from "./OcrUploadField";
type FormField = FormScreenField;
interface GridItem {
i: string;
x: number;
y: number;
w: number;
h: number;
}
// Field types whose value is a File rather than a scalar. They skip string
// validation and number coercion, and their File is uploaded via /upload before
// submit so instance data references the blob by metadata.
const MEDIA_TYPES = new Set(["ocr", "file", "image"]);
const isEmpty = (v: unknown) =>
v == null ||
(typeof v === "string" && v.trim() === "") ||
(Array.isArray(v) && v.length === 0);
interface Props {
activityId: string;
instanceId?: string;
title?: string;
onClose: () => void;
onSuccess: () => void;
}
export default function FormModal({ activityId, instanceId, title, onClose, onSuccess }: Props) {
const [formData, setFormData] = useState<Record<string, any>>({});
const [touched, setTouched] = useState<Set<string>>(new Set());
const [submitting, setSubmitting] = useState(false);
const [succeeded, setSucceeded] = useState(false);
const [error, setError] = useState<string | null>(null);
const { data, isPending } = useQuery({
queryKey: ["form-screen", activityId, instanceId],
queryFn: () => getFormScreen(activityId, instanceId) as Promise<any>,
});
const fields: FormField[] = data?.fields ?? data?.form_json?.fields ?? [];
const gridConfig: GridItem[] = data?.grid_config ?? [];
const formTitle = data?.activity_name || title || "Form";
// /upload and /ocr-extract resolve the field's deployed config and RBAC from
// these identifiers. The form screen echoes them; WORKFLOW_ID is the fallback
// for older view-service builds that don't.
const fileCtx: Omit<FieldFileContext, "fieldId"> = {
workflowUuid: data?.workflow_uuid || WORKFLOW_ID,
activityId,
versionUuid: data?.version_uuid || undefined,
instanceId,
};
// Seed prefills once the schema arrives.
useEffect(() => {
if (!fields.length) return;
const pre: Record<string, any> = {};
for (const f of fields) {
if (f.value == null) continue;
// Media prefills are already-uploaded metadata arrays, not strings.
pre[f.id] = MEDIA_TYPES.has(f.data_type) ? f.value : String(f.value);
}
setFormData(pre);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
/** Rows derived from the studio grid layout, so field order matches design. */
const rows = useMemo(() => {
const sorted = [...fields].sort((a, b) => {
const ga = gridConfig.find((g) => g.i === a.uid);
const 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 out: FormField[][] = [];
let currentY = -1;
for (const f of sorted) {
const y = gridConfig.find((g) => g.i === f.uid)?.y ?? out.length;
if (y !== currentY) {
out.push([]);
currentY = y;
}
out[out.length - 1].push(f);
}
return out;
}, [fields, gridConfig]);
const setValue = (id: string, v: unknown) => {
setFormData((p) => ({ ...p, [id]: v }));
setTouched((t) => new Set(t).add(id));
if (error) setError(null);
};
/** Write an OCR result into the fields ocr_config.field_mappings points at. */
const applyExtraction = (ocrField: FormField, extracted: Record<string, unknown>): number => {
const mappings = resolveOcrConfig(ocrField).field_mappings ?? [];
const next: Record<string, any> = {};
for (const m of mappings) {
if (!m?.extraction_key || !m?.target_field) continue;
const raw = extracted[m.extraction_key];
if (raw == null || raw === "") continue; // key absent, or model found nothing
const target = fields.find((f) => f.id === m.target_field);
const coerced = coerceExtracted(raw, target?.data_type);
if (coerced === "") continue;
next[m.target_field] = coerced;
}
const filled = Object.keys(next);
if (filled.length > 0) {
setFormData((p) => ({ ...p, ...next }));
// Mark them touched so a value the model got wrong still shows validation.
setTouched((t) => {
const s = new Set(t);
filled.forEach((k) => s.add(k));
return s;
});
setError(null);
}
return filled.length;
};
const isMissing = (f: FormField) => f.mandatory && touched.has(f.id) && isEmpty(formData[f.id]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setTouched(new Set(fields.map((f) => f.id)));
const missing = fields.filter((f) => f.mandatory && isEmpty(formData[f.id]));
if (missing.length > 0) {
setError(`Please fill in: ${missing.map((f) => f.name).join(", ")}`);
return;
}
setSubmitting(true);
setError(null);
try {
const payload: Record<string, unknown> = {};
for (const f of fields) {
const v = formData[f.id];
if (MEDIA_TYPES.has(f.data_type)) {
// A freshly picked File is uploaded now and submitted as metadata; an
// array is already-uploaded metadata from a prefill.
if (v instanceof File) {
payload[f.id] = [await uploadFieldFile(v, { ...fileCtx, fieldId: f.id })];
} else if (Array.isArray(v)) {
payload[f.id] = v;
}
continue;
}
const s = v ?? "";
payload[f.id] = f.data_type === "number" ? (s !== "" ? Number(s) : 0) : String(s);
}
if (instanceId) await performActivity(WORKFLOW_ID, instanceId, activityId, payload);
else await startWorkflow(WORKFLOW_ID, activityId, payload);
setSucceeded(true);
// The toast outlives the dialog. The old 800ms in-modal overlay vanished
// with the modal, leaving no confirmation behind at all.
toast.success(`${formTitle} submitted`, {
description: instanceId ? "The invoice has been updated." : "A new invoice was created.",
});
setTimeout(onSuccess, 600);
} catch (err: any) {
const message = err?.message || "Submission failed";
setError(message);
toast.error(`${formTitle} failed`, { description: message });
} finally {
setSubmitting(false);
}
};
return (
<Dialog open onOpenChange={(o) => !o && !submitting && onClose()}>
{/* Radix supplies the focus trap, Escape handling, aria-modal, scroll lock
and focus restore that the hand-rolled overlay never had. */}
<DialogContent
className="max-h-[90vh] gap-0 overflow-hidden p-0 sm:max-w-xl"
onInteractOutside={(e) => submitting && e.preventDefault()}
>
<DialogHeader className="border-b border-border px-5 py-4 text-left">
<DialogTitle className="text-lg">{formTitle}</DialogTitle>
<DialogDescription className="text-sm">
{instanceId ? "Update the details below." : "Fill in the details to submit."}
</DialogDescription>
</DialogHeader>
<div className="scrollbar-thin flex-1 overflow-y-auto px-5 py-4">
{isPending ? (
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, r) => (
<div key={r} className="grid grid-cols-2 gap-4">
{Array.from({ length: 2 }).map((__, c) => (
<div key={c}>
<Skeleton className="mb-2 h-3 w-20" />
<Skeleton className="h-9 w-full rounded-md" />
</div>
))}
</div>
))}
</div>
) : (
<form id="activity-form" onSubmit={handleSubmit} className="space-y-4">
{rows.map((row, ri) => (
<div
key={ri}
className={cn("grid gap-4", row.length > 1 ? "sm:grid-cols-2" : "grid-cols-1")}
>
{row.map((f) => {
const invalid = isMissing(f);
return (
<div key={f.id}>
<Label htmlFor={f.id} className="mb-1.5 text-sm text-muted-foreground">
{f.name}
{f.mandatory && (
<span className="text-destructive" aria-hidden>
*
</span>
)}
</Label>
{MEDIA_TYPES.has(f.data_type) ? (
<OcrUploadField
field={f}
ctx={fileCtx}
file={formData[f.id] instanceof File ? formData[f.id] : null}
onFileChange={(file) => setValue(f.id, file)}
onExtracted={(extracted) => applyExtraction(f, extracted)}
hasError={invalid}
/>
) : (
<FieldInput
field={f}
value={formData[f.id] ?? ""}
onChange={(v) => setValue(f.id, v)}
invalid={invalid}
/>
)}
{invalid && (
<p
id={`${f.id}-error`}
className="mt-1 flex items-center gap-1 text-xs text-destructive"
>
<AlertCircle className="size-3" aria-hidden />
Required
</p>
)}
</div>
);
})}
</div>
))}
</form>
)}
{error && (
<div
role="alert"
className="mt-4 flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-base text-destructive"
>
<AlertCircle className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>{error}</span>
</div>
)}
</div>
{/* mx-0/mb-0 cancel DialogFooter's default negative margins, which
assume the content keeps its own padding this dialog is p-0. */}
{!isPending && (
<DialogFooter className="mx-0 mb-0 items-center border-t border-border px-5 py-3 sm:justify-between">
<p className="text-xs text-muted-foreground">
<span className="text-destructive">*</span> Required
</p>
<div className="flex gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={submitting}>
Cancel
</Button>
<Button type="submit" form="activity-form" disabled={submitting || succeeded}>
{submitting ? (
<>
<Loader2 className="size-3.5 animate-spin" aria-hidden />
Submitting
</>
) : succeeded ? (
<>
<CheckCircle2 className="size-3.5" aria-hidden />
Submitted
</>
) : (
<>
{instanceId ? "Update" : "Submit"}
<ArrowRight className="size-3.5" aria-hidden />
</>
)}
</Button>
</div>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// Inputs
// ---------------------------------------------------------------------------
function FieldInput({
field,
value,
onChange,
invalid,
}: {
field: FormField;
value: string;
onChange: (v: string) => void;
invalid: boolean;
}) {
const options = field.properties?.options;
const describedBy = invalid ? `${field.id}-error` : undefined;
if (options && options.length > 0) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger
id={field.id}
aria-invalid={invalid}
aria-describedby={describedBy}
className="w-full"
>
<SelectValue placeholder={`Select ${field.name.toLowerCase()}`} />
</SelectTrigger>
<SelectContent>
{options.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
const common = {
id: field.id,
value,
"aria-invalid": invalid,
"aria-describedby": describedBy,
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
onChange(e.target.value),
};
switch (field.data_type) {
case "longtext":
return <Textarea {...common} rows={3} placeholder={`Enter ${field.name.toLowerCase()}`} />;
case "number":
return <Input {...common} type="number" step="any" placeholder="0" />;
case "date":
return <Input {...common} type="date" />;
case "email":
return <Input {...common} type="email" placeholder="name@company.com" />;
case "phone":
return <Input {...common} type="tel" placeholder={`Enter ${field.name.toLowerCase()}`} />;
default:
return <Input {...common} type="text" placeholder={`Enter ${field.name.toLowerCase()}`} />;
}
}
// ---------------------------------------------------------------------------
// OCR value coercion
// ---------------------------------------------------------------------------
// The vision model returns JSON scalars, but every input here is string-valued.
// The OCR prompt asks for plain numbers and YYYY-MM-DD dates; this is the
// belt-and-braces pass for when it doesn't comply — a currency symbol or
// thousands separator on a number, or a date in some other notation. Returns ""
// when the value can't be used, which the caller treats as "not filled".
function coerceExtracted(raw: unknown, dataType?: string): string {
if (typeof raw === "boolean") return raw ? "true" : "false";
const s = String(raw).trim();
if (s === "") return "";
if (dataType === "number") {
const n = Number(s.replace(/[^0-9.\-]/g, ""));
return Number.isFinite(n) ? String(n) : "";
}
if (dataType === "date") {
const iso = s.match(/^\d{4}-\d{2}-\d{2}/);
if (iso) return iso[0];
const d = new Date(s);
return Number.isNaN(d.getTime()) ? "" : d.toISOString().slice(0, 10);
}
return s;
}

View File

@ -0,0 +1,332 @@
// OcrUploadField — document dropzone for the file-valued form field types.
//
// Handles `ocr`, `file` and `image`. The Extract action only appears for `ocr`
// fields that actually carry an extraction schema and field mappings; for a
// plain file/image field this is just a picker.
//
// The user drops a PDF (or image), we POST it to the core-service's
// /ocr-extract, and hand the extracted key→value map back to the parent, which
// walks ocr_config.field_mappings and writes each value into its target form
// field. The file itself stays in the parent's form state as a raw File and is
// uploaded via /upload at submit time.
//
// The prompt and extraction schema are NOT sent from here — the backend
// resolves them from the deployed ocr_config using the identifier trio
// (workflow_uuid / activity_id / field_id) plus activity RBAC. What we read
// out of the local config is only picker UX (allowed_types / max_size_mb /
// placeholder); mirrors renderer-desktop's FFOcrField.
import { useEffect, useRef, useState } from "react";
import {
AlertCircle,
CheckCircle2,
ChevronDown,
Eye,
FileText,
Loader2,
ScanText,
UploadCloud,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { extractOcr } from "@/api/viewService";
import type { FieldFileContext, FormScreenField, OcrConfig } from "@/api/viewService";
// Studio writes the OCR keys either nested under properties.ocr_config or flat
// on properties. The nested shape wins when it actually carries a schema.
export function resolveOcrConfig(field: FormScreenField): OcrConfig {
const nested = field.properties?.ocr_config;
if (nested && Array.isArray(nested.extraction_fields)) return nested;
return (field.properties ?? {}) as OcrConfig;
}
// allowed_types arrives as a CSV string or an array, holding any of ".pdf",
// "pdf", "application/pdf" or "image/*". Normalise to what <input accept> wants.
function toAcceptAttr(raw: OcrConfig["allowed_types"]): string {
const entries = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(",") : [];
return entries
.map((e) => e.trim().toLowerCase())
.filter(Boolean)
.map((e) => (e.includes("/") || e.startsWith(".") ? e : `.${e}`))
.join(",");
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
type ExtractState =
| { status: "idle" }
| { status: "loading" }
| { status: "done"; applied: number }
| { status: "error"; message: string };
interface Props {
field: FormScreenField;
/** Identifiers the backend needs to resolve this field's ocr_config + RBAC. */
ctx: Omit<FieldFileContext, "fieldId">;
file: File | null;
onFileChange: (file: File | null) => void;
/** Receives the raw extraction map; the parent applies field_mappings. */
onExtracted: (extracted: Record<string, unknown>) => number;
disabled?: boolean;
hasError?: boolean;
}
export default function OcrUploadField({
field,
ctx,
file,
onFileChange,
onExtracted,
disabled,
hasError,
}: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const [state, setState] = useState<ExtractState>({ status: "idle" });
const [showPreview, setShowPreview] = useState(false);
// A blob URL lets the reviewer see the document they just picked, side by side
// with the fields OCR filled in. This only works for the in-flight File —
// once uploaded there is no endpoint that serves it back.
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
useEffect(() => {
if (!file) {
setPreviewUrl(null);
setShowPreview(false);
return;
}
const url = URL.createObjectURL(file);
setPreviewUrl(url);
return () => URL.revokeObjectURL(url);
}, [file]);
const config = resolveOcrConfig(field);
const accept = toAcceptAttr(config.allowed_types);
const maxSizeMb = Number(config.max_size_mb) > 0 ? Number(config.max_size_mb) : null;
const mappings = Array.isArray(config.field_mappings) ? config.field_mappings : [];
// Without extraction_fields the backend has nothing to ask for, and without
// mappings there is nowhere to put the answer — either way, no Extract button.
const canExtract =
(config.extraction_fields?.length ?? 0) > 0 && mappings.length > 0 && !!ctx.workflowUuid;
const acceptHint = accept
? accept.replace(/\./g, "").replace(/,/g, ", ").toUpperCase()
: "PDF or image";
const isPdf = file?.type === "application/pdf";
const isImage = file?.type.startsWith("image/") ?? false;
const canPreview = !!previewUrl && (isPdf || isImage);
const pick = (next: File | null) => {
setState({ status: "idle" });
if (next && maxSizeMb && next.size > maxSizeMb * 1024 * 1024) {
onFileChange(null);
setState({ status: "error", message: `File exceeds the ${maxSizeMb} MB limit` });
return;
}
onFileChange(next);
};
const runExtract = async () => {
if (!file || !canExtract) return;
setState({ status: "loading" });
try {
const res = await extractOcr(file, { ...ctx, fieldId: field.id });
const applied = onExtracted(res?.extracted ?? {});
if (applied === 0) {
setState({
status: "error",
message: res?.parse_error
? `Could not read the document: ${res.parse_error}`
: "Nothing could be read from this document — please fill the fields manually.",
});
return;
}
setState({ status: "done", applied });
// Opening the document next to the filled fields is the verification step.
if (canPreview) setShowPreview(true);
} catch (err: any) {
setState({ status: "error", message: err?.message || "Extraction failed" });
}
};
const clear = () => {
pick(null);
if (inputRef.current) inputRef.current.value = "";
};
return (
<div>
<input
ref={inputRef}
type="file"
accept={accept || undefined}
className="hidden"
onChange={(e) => pick(e.target.files?.[0] ?? null)}
/>
{!file ? (
/* ── Empty dropzone ─────────────────────────────────────────────── */
<button
type="button"
disabled={disabled}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
if (!disabled) pick(e.dataTransfer.files?.[0] ?? null);
}}
className={cn(
"flex w-full flex-col items-center gap-2 rounded-lg border border-dashed px-5 py-7 transition-colors disabled:opacity-50",
dragging
? "border-primary bg-accent"
: hasError
? "border-destructive/50 bg-destructive/5"
: "border-border bg-muted/40 hover:border-primary/50 hover:bg-accent/50",
)}
>
<span className="flex size-10 items-center justify-center rounded-full bg-card shadow-sm">
<UploadCloud className="size-4 text-primary" aria-hidden />
</span>
<span className="text-base font-medium text-foreground">
{config.placeholder || field.properties?.placeholder || `Drop ${field.name} here`}
</span>
<span className="text-xs text-muted-foreground">
or click to browse · {acceptHint}
{maxSizeMb ? ` up to ${maxSizeMb} MB` : ""}
</span>
</button>
) : (
/* ── Picked file + extract action ────────────────────────────────── */
<div className="rounded-lg border border-border bg-card">
<div className="flex items-center gap-3 p-2.5">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-accent">
<FileText className="size-4 text-primary" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-base font-medium text-foreground">{file.name}</p>
<p className="text-xs text-muted-foreground">{formatBytes(file.size)}</p>
</div>
{canPreview && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 gap-1.5"
aria-expanded={showPreview}
onClick={() => setShowPreview((s) => !s)}
>
<Eye className="size-3.5" aria-hidden />
{showPreview ? "Hide" : "View"}
<ChevronDown
className={cn("size-3 transition-transform", showPreview && "rotate-180")}
aria-hidden
/>
</Button>
)}
{canExtract && (
<Button
type="button"
size="sm"
className="h-8 shrink-0 gap-1.5"
disabled={disabled || state.status === "loading"}
onClick={runExtract}
>
{state.status === "loading" ? (
<>
<Loader2 className="size-3.5 animate-spin" aria-hidden />
Reading
</>
) : (
<>
<ScanText className="size-3.5" aria-hidden />
{state.status === "done" ? "Re-read" : "Read invoice"}
</>
)}
</Button>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0"
disabled={disabled || state.status === "loading"}
onClick={clear}
>
<X className="size-3.5" aria-hidden />
<span className="sr-only">Remove file</span>
</Button>
</div>
{state.status === "done" && (
<p className="flex items-center gap-1.5 px-3 pb-2.5 text-xs text-state-approved">
<CheckCircle2 className="size-3" aria-hidden />
Filled {state.applied} field{state.applied === 1 ? "" : "s"} from the document check
them against the original below.
</p>
)}
{state.status === "error" && (
<p className="flex items-start gap-1.5 px-3 pb-2.5 text-xs text-destructive">
<AlertCircle className="mt-px size-3 shrink-0" aria-hidden />
{state.message}
</p>
)}
{/* Inline document preview the browser renders both PDFs and images
from a blob URL, so no PDF library is needed for this. */}
{showPreview && canPreview && (
<div className="border-t border-border p-2.5">
{isImage ? (
<img
src={previewUrl!}
alt={`Preview of ${file.name}`}
className="max-h-80 w-full rounded-md object-contain"
/>
) : (
<object
data={previewUrl!}
type="application/pdf"
className="h-80 w-full rounded-md"
aria-label={`Preview of ${file.name}`}
>
<p className="p-4 text-sm text-muted-foreground">
Your browser can't display this PDF inline.{" "}
<a
href={previewUrl!}
target="_blank"
rel="noreferrer"
className="font-semibold text-primary hover:underline"
>
Open it in a new tab
</a>
.
</p>
</object>
)}
</div>
)}
</div>
)}
{state.status === "error" && !file && (
<p className="mt-2 flex items-start gap-1.5 text-xs text-destructive">
<AlertCircle className="mt-px size-3 shrink-0" aria-hidden />
{state.message}
</p>
)}
</div>
);
}

View File

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

View File

@ -0,0 +1,785 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQueryStates } from "nuqs";
import { useQueryClient } from "@tanstack/react-query";
import {
flexRender,
getCoreRowModel,
useReactTable,
type ColumnDef,
} from "@tanstack/react-table";
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
ChevronLeft,
ChevronRight,
Inbox,
MoreHorizontal,
Plus,
RefreshCw,
Search,
TriangleAlert,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import StatusBadge from "./StatusBadge";
import FormModal from "./FormModal";
import { TableSkeleton } from "./Skeleton";
import { useRVScreen, useRecordViewData } from "@/hooks/useRecordView";
import { tableParams, tableParamsOptions, DENSITY_CELL, type Density } from "@/lib/tableParams";
import { formatCurrency, formatDate, formatTime, isMoneyField } from "@/lib/format";
import { colorForName } from "@/lib/palette";
import { actionsForStateName, type StateAction } from "@/lib/workflow";
import type { RecordViewField, SearchQuery } from "@/api/viewService";
const STATE_FIELD = "current_state_name";
/** Candidate keys for the vendor/company column, best first. */
const VENDOR_KEYS = ["vendor_name", "company_name", "supplier_name", "vendor"];
interface Props {
viewId: number | string;
onRowClick?: (instanceId: string) => void;
toolbarAction?: React.ReactNode;
}
type Row = Record<string, unknown>;
const rowId = (row: Row) => String(row.instance_id ?? row._pk_value ?? "");
export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: Props) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [params, setParams] = useQueryStates(tableParams, tableParamsOptions);
const [searchInput, setSearchInput] = useState(params.q);
const [formModal, setFormModal] = useState<{
activityId: string;
instanceId?: string;
title: string;
} | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
const screen = useRVScreen(viewId);
const query: SearchQuery = useMemo(
() => ({
page: params.page,
limit: params.limit,
sort_by: params.sort || undefined,
sort_dir: params.dir,
search: params.q || undefined,
// One entry per selected state. NOTE: this assumes the view-service ORs
// repeated filters on the same field_key — verify against the live API,
// since an AND would make any multi-select return zero rows.
filters: params.status.map((value) => ({
field_key: STATE_FIELD,
value,
data_type: "text",
})),
}),
[params],
);
const { data, isPending, isError, error, isFetching, isPlaceholderData, refetch } =
useRecordViewData(screen.data?.templateId, viewId, query);
// Reflect an externally-changed search param (command palette, shared link).
useEffect(() => setSearchInput(params.q), [params.q]);
// "/" focuses search — this is a keyboard-driven triage queue.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const el = document.activeElement;
const typing =
el instanceof HTMLInputElement ||
el instanceof HTMLTextAreaElement ||
(el as HTMLElement | null)?.isContentEditable;
if (e.key === "/" && !typing) {
e.preventDefault();
searchRef.current?.focus();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
// The served field order puts the status chip in column two, where it
// dominates the row before the reader knows what the row is. Reorder so the
// identifying fields lead and the state sits fourth among the data columns.
const fields = useMemo(() => {
// The raw instance UUID identifies nothing to a reader — the company does.
let raw = (data?.fields ?? []).filter((f) => f.field_key !== "instance_id");
const sample = data?.rows?.[0];
const vendorKey =
VENDOR_KEYS.find((k) => raw.some((f) => f.field_key === k)) ??
(sample ? VENDOR_KEYS.find((k) => k in sample) : undefined);
// Promote the company to second column, synthesising it when the view
// serves the value on the row but doesn't list it as a field.
if (vendorKey) {
const existing = raw.find((f) => f.field_key === vendorKey);
const col: RecordViewField =
existing ?? {
field_key: vendorKey,
output_label: "Company",
data_type: "text",
is_filter: false,
is_search: true,
};
raw = [
...raw.filter((f) => f.field_key !== vendorKey).slice(0, 1),
col,
...raw.filter((f) => f.field_key !== vendorKey).slice(1),
];
}
const idx = raw.findIndex((f) => f.field_key === STATE_FIELD);
if (idx === -1) return raw;
const rest = raw.filter((_, i) => i !== idx);
const pos = Math.min(3, rest.length);
return [...rest.slice(0, pos), raw[idx], ...rest.slice(pos)];
}, [data?.fields, data?.rows]);
const rows = data?.rows ?? [];
// Money columns get a footer total. Scoped to the current page and labelled
// as such — summing only what's on screen is the honest thing a client can do
// without a server-side aggregate.
const pageTotals = useMemo(() => {
const out: Record<string, number> = {};
for (const f of fields) {
if (f.data_type !== "number" || !isMoneyField(f.field_key)) continue;
out[f.field_key] = rows.reduce((sum, r) => sum + (Number(r[f.field_key]) || 0), 0);
}
return out;
}, [fields, rows]);
const openRow = (row: Row) => {
const id = rowId(row);
if (!id) return;
onRowClick ? onRowClick(id) : navigate(`/detail/${id}`);
};
const afterAction = () => {
setFormModal(null);
refetch();
// Tiles and charts aggregate the same underlying view.
queryClient.invalidateQueries({ queryKey: ["invoice-analytics"] });
};
// ── Columns ───────────────────────────────────────────────────────────────
const columns = useMemo<ColumnDef<Row>[]>(() => {
if (fields.length === 0) return [];
const primaryKey = fields.find(
(f) => f.field_key !== STATE_FIELD && f.field_key !== "instance_id" && f.data_type === "text",
)?.field_key;
const dataCols: ColumnDef<Row>[] = fields.map((f) => ({
id: f.field_key,
accessorFn: (r) => r[f.field_key],
header: f.output_label,
// `numeric` drives right-alignment on the header, the cell and the footer
// total — figures only line up for comparison when they share an edge.
meta: { field: f, numeric: f.data_type === "number" },
cell: ({ getValue }) => renderCell(getValue(), f, f.field_key === primaryKey),
}));
const actions: ColumnDef<Row> = {
id: "__actions",
size: 72,
enableHiding: false,
header: () => <span className="sr-only">Row actions</span>,
cell: ({ row }) => (
<RowActions
row={row.original}
onAction={(a) =>
setFormModal({
activityId: a.activityId,
instanceId: rowId(row.original),
title: a.label,
})
}
onOpen={() => openRow(row.original)}
/>
),
};
return [...dataCols, actions];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fields]);
const table = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row, i) => rowId(row) || String(i),
// Sorting and pagination happen server-side; the table is a render model.
manualSorting: true,
manualPagination: true,
});
// ── Loading / error ───────────────────────────────────────────────────────
if (screen.isPending || (isPending && !data)) {
return <TableSkeleton rows={6} cols={6} />;
}
if (screen.isError || isError) {
const message =
(screen.error as Error)?.message ?? (error as Error)?.message ?? "Something went wrong";
return (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-10 text-center">
<TriangleAlert className="mx-auto mb-3 size-5 text-destructive" aria-hidden />
<p className="mb-3 text-base text-destructive">{message}</p>
<Button variant="outline" size="sm" onClick={() => refetch()}>
Try again
</Button>
</div>
);
}
const { page, limit, total_count, total_pages } = data!.pagination;
const from = total_count > 0 ? (page - 1) * limit + 1 : 0;
const to = Math.min(page * limit, total_count);
const filtersActive = params.status.length > 0 || !!params.q;
return (
<div className="rounded-xl border border-border bg-card">
{/* ── Toolbar ──────────────────────────────────────────────────────── */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2.5">
<div className="flex items-center gap-1.5">
{/* Search leads the toolbar it's the control reached for most often,
with the status filter immediately beside it. */}
<form
onSubmit={(e) => {
e.preventDefault();
setParams({ q: searchInput, page: 1 });
}}
className="relative"
>
<Search
className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<Input
ref={searchRef}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search… /"
aria-label="Search records"
className="h-8 w-44 pl-8 pr-7"
/>
{searchInput && (
<button
type="button"
onClick={() => {
setSearchInput("");
setParams({ q: "", page: 1 });
}}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded text-muted-foreground hover:text-foreground"
>
<X className="size-3" aria-hidden />
<span className="sr-only">Clear search</span>
</button>
)}
</form>
</div>
<div className="ml-auto flex items-center gap-1.5">
{screen.data?.startButtons.map((b) => (
<Button
key={b.key}
size="sm"
onClick={() => setFormModal({ activityId: b.activityId, title: b.label })}
className="h-8 gap-1.5"
>
<Plus className="size-3.5" strokeWidth={2.5} aria-hidden />
{b.label}
</Button>
))}
{toolbarAction}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-8"
disabled={isFetching}
onClick={() => refetch()}
>
<RefreshCw className={cn("size-4", isFetching && "animate-spin")} aria-hidden />
<span className="sr-only">Refresh</span>
</Button>
</TooltipTrigger>
<TooltipContent>Refresh</TooltipContent>
</Tooltip>
</div>
</div>
{/* ── Active filter chips ──────────────────────────────────────────── */}
{filtersActive && (
<div className="flex flex-wrap items-center gap-1.5 border-t border-border px-4 py-2">
<span className="text-2xs uppercase tracking-wider text-muted-foreground">Filtered</span>
{params.q && (
<Chip onRemove={() => setParams({ q: "", page: 1 })}>
search: <span className="font-semibold">{params.q}</span>
</Chip>
)}
{params.status.map((s) => (
<Chip
key={s}
onRemove={() => setParams({ status: params.status.filter((x) => x !== s), page: 1 })}
>
{s}
</Chip>
))}
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-2xs text-muted-foreground"
onClick={() => setParams({ status: [], q: "", page: 1 })}
>
Clear all
</Button>
</div>
)}
{/* ── Table ────────────────────────────────────────────────────────── */}
<div className="scrollbar-thin relative overflow-x-auto border-t border-border">
<table className="w-full border-separate border-spacing-0">
<thead>
<tr>
{table.getHeaderGroups()[0]?.headers.map((header, i) => {
const field = (header.column.columnDef.meta as any)?.field as
| RecordViewField
| undefined;
const sortable = !!field;
const sorted = params.sort === header.column.id;
const dir = sorted ? params.dir : undefined;
const numeric = !!(header.column.columnDef.meta as any)?.numeric;
return (
<th
key={header.id}
scope="col"
// aria-sort belongs on the header cell, not the button inside.
aria-sort={
sorted
? dir === "asc"
? "ascending"
: "descending"
: sortable
? "none"
: undefined
}
className={cn(
"sticky top-0 z-10 whitespace-nowrap border-b border-border bg-accent",
// Violet-on-violet keeps the header part of the theme
// rather than a grey band bolted onto it.
"text-xs font-semibold uppercase tracking-wider text-accent-foreground",
numeric ? "text-right" : "text-left",
i === 0 ? "pl-4 pr-3.5" : "px-3.5",
)}
style={{ width: header.getSize() === 150 ? undefined : header.getSize() }}
>
{sortable ? (
<button
type="button"
onClick={() =>
setParams({
sort: header.column.id,
dir: sorted && params.dir === "asc" ? "desc" : "asc",
page: 1,
})
}
className={cn(
"group/th inline-flex items-center gap-1 py-3 transition-colors hover:text-foreground",
sorted && "text-primary",
)}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{sorted ? (
dir === "asc" ? (
<ArrowUp className="size-3" aria-hidden />
) : (
<ArrowDown className="size-3" aria-hidden />
)
) : (
<ArrowUpDown
className="size-3 opacity-0 transition-opacity group-hover/th:opacity-60"
aria-hidden
/>
)}
</button>
) : (
<div className="py-3">
{flexRender(header.column.columnDef.header, header.getContext())}
</div>
)}
</th>
);
})}
</tr>
</thead>
<tbody className={cn(isPlaceholderData && "opacity-50 transition-opacity")}>
{rows.length === 0 ? (
<tr>
<td colSpan={table.getVisibleLeafColumns().length} className="px-4">
<EmptyState
filtered={filtersActive}
onClear={() => setParams({ status: [], q: "", page: 1 })}
/>
</td>
</tr>
) : (
table.getRowModel().rows.map((row) => {
const selected = row.getIsSelected();
return (
<tr
key={row.id}
// Rows are focusable and Enter/Space activatable; previously
// the click handler sat on a non-focusable <tr>.
tabIndex={0}
role="link"
aria-label={`Open record ${row.id}`}
onClick={() => openRow(row.original)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openRow(row.original);
}
}}
className={cn(
"group/row cursor-pointer transition-colors",
selected ? "bg-accent/60" : "hover:bg-accent/40",
)}
>
{row.getVisibleCells().map((cell, ci) => (
<td
key={cell.id}
onClick={
// The action cell must not open the row.
cell.column.id === "__actions"
? (e) => e.stopPropagation()
: undefined
}
className={cn(
"border-b border-border/60 text-base",
DENSITY_CELL[params.density as Density],
ci === 0 ? "pl-4 pr-3.5" : "px-3.5",
(cell.column.columnDef.meta as any)?.numeric && "text-right",
cell.column.id === "__actions" && "text-right",
)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
);
})
)}
</tbody>
{/* Totals for the rows actually on screen. Explicitly labelled "this
page" so it can't be mistaken for a total across the filter. */}
{rows.length > 0 && Object.keys(pageTotals).length > 0 && (
<tfoot>
<tr>
{table.getVisibleLeafColumns().map((col, ci) => {
const total = pageTotals[col.id];
return (
<td
key={col.id}
className={cn(
"border-t border-border bg-muted/40 py-2.5",
ci === 0 ? "pl-4 pr-3.5" : "px-3.5",
total != null && "text-right",
)}
>
{total != null ? (
<span className="text-base font-bold tabular-nums text-primary">
{formatCurrency(total)}
</span>
) : ci === 0 ? (
<span className="text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
Page total · {rows.length} rows
</span>
) : null}
</td>
);
})}
</tr>
</tfoot>
)}
</table>
</div>
{/* ── Pagination ───────────────────────────────────────────────────── */}
{total_count > 0 && (
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-2.5">
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<div className="flex items-center gap-1.5">
<span>Rows</span>
<Select
value={String(params.limit)}
onValueChange={(v) => setParams({ limit: Number(v), page: 1 })}
>
<SelectTrigger size="sm" className="h-7 w-[68px]" aria-label="Rows per page">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[10, 25, 50, 100].map((n) => (
<SelectItem key={n} value={String(n)}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Separator orientation="vertical" className="h-4" />
<span className="tabular-nums">
{from}{to} of {total_count}
</span>
</div>
{total_pages > 1 && (
<nav aria-label="Pagination" className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
className="size-7"
disabled={page <= 1}
onClick={() => setParams({ page: page - 1 })}
>
<ChevronLeft className="size-4" aria-hidden />
<span className="sr-only">Previous page</span>
</Button>
{buildPageRange(page, total_pages).map((p, idx) =>
p === "…" ? (
<span
key={`gap-${idx}`}
className="flex size-7 select-none items-center justify-center text-sm text-muted-foreground"
>
</span>
) : (
<Button
key={p}
variant={p === page ? "default" : "ghost"}
size="icon"
className="size-7 text-sm tabular-nums"
aria-current={p === page ? "page" : undefined}
onClick={() => setParams({ page: p as number })}
>
{p}
</Button>
),
)}
<Button
variant="ghost"
size="icon"
className="size-7"
disabled={page >= total_pages}
onClick={() => setParams({ page: page + 1 })}
>
<ChevronRight className="size-4" aria-hidden />
<span className="sr-only">Next page</span>
</Button>
</nav>
)}
</div>
)}
{formModal && (
<FormModal
activityId={formModal.activityId}
instanceId={formModal.instanceId}
title={formModal.title}
onClose={() => setFormModal(null)}
onSuccess={afterAction}
/>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Toolbar pieces
// ---------------------------------------------------------------------------
function Chip({ children, onRemove }: { children: React.ReactNode; onRemove: () => void }) {
return (
<span className="inline-flex items-center gap-1 rounded-md border border-primary/25 bg-primary/10 px-2 py-0.5 text-xs text-primary">
{children}
<button onClick={onRemove} className="rounded hover:opacity-60">
<X className="size-2.5" aria-hidden />
<span className="sr-only">Remove filter</span>
</button>
</span>
);
}
// ---------------------------------------------------------------------------
// Row actions
// ---------------------------------------------------------------------------
function RowActions({
row,
onAction,
onOpen,
}: {
row: Row;
onAction: (a: StateAction) => void;
onOpen: () => void;
}) {
const actions = actionsForStateName(String(row[STATE_FIELD] ?? ""));
return (
<div className="flex items-center justify-end gap-0.5 opacity-0 transition-opacity focus-within:opacity-100 group-hover/row:opacity-100">
{actions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-7">
<MoreHorizontal className="size-4" aria-hidden />
<span className="sr-only">Actions for this record</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
{actions.map((a) => (
<DropdownMenuItem
key={a.activityId}
variant={a.destructive ? "destructive" : "default"}
onClick={() => onAction(a)}
>
<a.icon className="size-3.5" aria-hidden />
{a.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<Button variant="ghost" size="icon" className="size-7" onClick={onOpen}>
<ChevronRight className="size-4" aria-hidden />
<span className="sr-only">Open record</span>
</Button>
</div>
);
}
// ---------------------------------------------------------------------------
// Cells / empty state
// ---------------------------------------------------------------------------
function renderCell(value: unknown, field: RecordViewField, isPrimary: boolean) {
const { field_key: key, data_type: type } = field;
if (key === STATE_FIELD) return <StatusBadge value={String(value ?? "")} />;
if (value == null || value === "") {
return <span className="text-muted-foreground/40"></span>;
}
if (VENDOR_KEYS.includes(key)) return <VendorCell name={String(value)} />;
if (type === "number") {
const n = Number(value);
if (!Number.isFinite(n)) return String(value);
return isMoneyField(key) ? (
<span className="font-medium tabular-nums text-foreground">{formatCurrency(n)}</span>
) : (
<span className="tabular-nums">{n.toLocaleString("en-IN")}</span>
);
}
if (type === "date" || type === "datetime") {
const time = formatTime(String(value));
return (
<span className="block whitespace-nowrap leading-tight">
{formatDate(String(value))}
{time && <span className="block text-2xs text-muted-foreground">{time}</span>}
</span>
);
}
return (
<span className={cn("block max-w-[28ch] truncate", isPrimary && "font-semibold text-foreground")}>
{String(value)}
</span>
);
}
/** Company name with an initials avatar — gives every row a visual anchor. */
function VendorCell({ name }: { name: string }) {
const initials = name
.split(/\s+/)
.filter((w) => /[a-z0-9]/i.test(w))
.slice(0, 2)
.map((w) => w[0]!.toUpperCase())
.join("");
const tint = colorForName(name);
return (
<span className="flex items-center gap-2.5">
<span
aria-hidden
className="flex size-9 shrink-0 items-center justify-center rounded-full text-xs font-bold"
style={{ background: `color-mix(in oklab, ${tint} 15%, transparent)`, color: tint }}
>
{initials || "?"}
</span>
<span className="block max-w-[22ch] truncate font-medium text-foreground">{name}</span>
</span>
);
}
function EmptyState({ filtered, onClear }: { filtered: boolean; onClear: () => void }) {
return (
<div className="flex flex-col items-center gap-3 py-16 text-center">
<div className="flex size-12 items-center justify-center rounded-xl bg-accent">
<Inbox className="size-5 text-primary" aria-hidden />
</div>
<div>
<p className="text-md font-semibold text-foreground">No records found</p>
<p className="mt-0.5 text-sm text-muted-foreground">
{filtered
? "No invoices match the current filters."
: "Submit a new invoice to get started."}
</p>
</div>
{filtered && (
<Button variant="outline" size="sm" onClick={onClear}>
Clear filters
</Button>
)}
</div>
);
}
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];
}

View File

@ -0,0 +1,95 @@
import { useState, ReactNode } from "react";
import { Plus } from "lucide-react";
import type { LayoutElement } from "../api/viewService";
import { Button } from "@/components/ui/button";
import RecordViewTable from "./RecordViewTable";
import DetailViewPanel from "./DetailViewPanel";
import FormModal from "./FormModal";
import { collectStartButtons } from "../lib/startButtons";
interface Props {
layout: LayoutElement[];
instanceId?: string;
onRefresh?: () => void;
onRowClick?: (instanceId: string) => void;
/** Forwarded to any detail view so the shell can title its breadcrumb. */
onTitle?: (title: string) => void;
}
export default function ScreenRenderer({ layout, instanceId, onRefresh, onRowClick, onTitle }: 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) {
// Current studio writes params.activity_uid_init; older configs used
// activity_id_init. collectStartButtons accepts both — see lib/startButtons.
const [btn] = collectStartButtons(cfg);
const aid = btn?.activityId;
if (!aid) return; // a button with no activity to run is not renderable
const label = (cfg.name || "").replace(/^\+\s*/, "");
buttons.push(
<Button
key={aid}
size="sm"
className="h-8 gap-1.5"
onClick={() => setFormModal({ activityId: aid, instanceId: cfg.job_template === "perform_activity" ? instanceId : undefined, title: label })}
>
<Plus className="size-3.5" strokeWidth={2.5} aria-hidden />{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 || ""} onTitle={onTitle} /></div>;
}
return null;
})}
</div>
{formModal && <FormModal activityId={formModal.activityId} instanceId={formModal.instanceId} title={formModal.title} onClose={() => setFormModal(null)} onSuccess={() => { setFormModal(null); onRefresh?.(); }} />}
</>
);
}

View File

@ -0,0 +1,89 @@
// Composite loading states. The primitive lives in components/ui/skeleton.
//
// StatsSkeleton was dropped along with the old DashboardStats — the rebuilt
// tiles carry their own skeleton so the two can't drift out of shape.
import { Skeleton } from "@/components/ui/skeleton";
export function TableSkeleton({ rows = 5, cols = 6 }: { rows?: number; cols?: number }) {
return (
<div className="rounded-xl border border-border bg-card">
{/* Toolbar */}
<div className="flex items-center justify-between px-4 py-2.5">
<Skeleton className="h-5 w-32" />
<div className="flex gap-1.5">
<Skeleton className="size-8 rounded-md" />
<Skeleton className="size-8 rounded-md" />
<Skeleton className="h-8 w-44 rounded-md" />
</div>
</div>
{/* Header */}
<div className="flex gap-4 border-y border-border bg-muted/50 px-4 py-2.5">
{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="flex gap-4 border-b border-border/60 px-4 py-3.5">
{Array.from({ length: cols }).map((_, ci) => (
<Skeleton key={ci} className={ci === 0 ? "h-4 max-w-20 flex-1" : "h-4 flex-1"} />
))}
</div>
))}
</div>
);
}
export function DetailSkeleton() {
return (
<div className="space-y-3">
{/* Stepper */}
<div className="flex items-center gap-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-8 flex-1 rounded-lg" />
))}
</div>
<div className="grid gap-3 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
<div className="space-y-3">
<div className="rounded-xl border border-border bg-card px-5 py-4">
<div className="flex items-start justify-between">
<div className="space-y-2">
<Skeleton className="h-6 w-40" />
<Skeleton className="h-3 w-56" />
</div>
<Skeleton className="h-8 w-28" />
</div>
</div>
<div className="rounded-xl border border-border bg-card px-5 py-4">
<Skeleton className="mb-4 h-2.5 w-24" />
<div className="grid gap-x-8 gap-y-4 sm:grid-cols-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i}>
<Skeleton className="mb-1.5 h-2.5 w-20" />
<Skeleton className="h-4 w-32" />
</div>
))}
</div>
</div>
</div>
<div className="space-y-3">
<div className="rounded-xl border border-border bg-card px-5 py-4">
<Skeleton className="mb-4 h-2.5 w-20" />
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex justify-between border-b border-border/50 py-2">
<Skeleton className="h-3.5 w-24" />
<Skeleton className="h-3.5 w-16" />
</div>
))}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,148 @@
// The source invoice PDF, viewable by whoever is looking at the record.
//
// `invoice_pdf` is write-only everywhere else: the vendor uploads it, OCR reads
// it, and it appears in no record view and no detail view — so the department
// answering a clarification and the reviewer approving payment can't open the
// document they're reasoning about.
//
// The /download endpoint sets Content-Disposition: attachment, so a plain <a>
// would always download. Fetching a Blob and building an object URL instead
// lets the same bytes render inline AND be opened or saved on demand.
import { useEffect, useRef, useState } from "react";
import { AlertCircle, Download, ExternalLink, Eye, FileText, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { downloadFieldFile, type FileMeta } from "@/api/viewService";
import { formatBytes } from "@/lib/format";
export default function SourceDocument({ file }: { file: FileMeta | null }) {
const [url, setUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const urlRef = useRef<string | null>(null);
// Fetch once, on first expand. Object URLs must be revoked or the blob leaks
// for the lifetime of the document.
useEffect(() => {
if (!file || !open || urlRef.current) return;
let cancelled = false;
setLoading(true);
setError(null);
downloadFieldFile(file.blob_path, file.original_name)
.then((blob) => {
if (cancelled) return;
const objUrl = URL.createObjectURL(blob);
urlRef.current = objUrl;
setUrl(objUrl);
})
.catch((e: any) => {
if (!cancelled) setError(e?.message || "Could not load the document");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [file, open]);
useEffect(
() => () => {
if (urlRef.current) URL.revokeObjectURL(urlRef.current);
},
[],
);
if (!file) {
return (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<FileText className="size-3.5 shrink-0" aria-hidden />
No document was attached to this submission.
</p>
);
}
const isPdf = (file.mime_type || "").includes("pdf");
const save = () => {
if (!url) return;
const a = document.createElement("a");
a.href = url;
a.download = file.original_name || "invoice.pdf";
a.click();
};
return (
<div>
<div className="flex flex-wrap items-center gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<FileText className="size-4 text-primary" aria-hidden />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-md font-semibold text-foreground">
{file.original_name || "Invoice document"}
</p>
<p className="text-2xs text-muted-foreground">
{formatBytes(file.size_bytes)}
{isPdf ? " · PDF" : file.mime_type ? ` · ${file.mime_type}` : ""}
</p>
</div>
<div className="flex items-center gap-1.5">
<Button variant="outline" size="sm" className="h-8 gap-1.5" onClick={() => setOpen((o) => !o)}>
<Eye className="size-3.5" aria-hidden />
{open ? "Hide" : "View"}
</Button>
{url && (
<>
<Button asChild variant="ghost" size="sm" className="h-8 gap-1.5">
<a href={url} target="_blank" rel="noreferrer">
<ExternalLink className="size-3.5" aria-hidden />
Open
</a>
</Button>
<Button variant="ghost" size="sm" className="h-8 gap-1.5" onClick={save}>
<Download className="size-3.5" aria-hidden />
Save
</Button>
</>
)}
</div>
</div>
{open && (
<div className="mt-3 overflow-hidden rounded-lg border border-border/60">
{loading && (
<div className="flex h-64 flex-col items-center justify-center gap-2 bg-muted/30">
<Loader2 className="size-4 animate-spin text-primary" aria-hidden />
<span className="text-xs text-muted-foreground">Loading document</span>
</div>
)}
{error && (
<p className="flex items-start gap-2 px-3 py-2.5 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-3.5 shrink-0" aria-hidden />
{error}
</p>
)}
{url &&
!loading &&
!error &&
(isPdf ? (
<iframe
src={url}
title={file.original_name || "Invoice"}
className="h-[560px] w-full bg-muted/30"
/>
) : (
<img
src={url}
alt={file.original_name || "Invoice"}
className="max-h-[560px] w-full bg-muted/30 object-contain"
/>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,50 @@
import { cn } from "@/lib/utils";
import { resolveStatus } from "@/lib/workflow";
interface Props {
value: string | null | undefined;
/** `sm` for table cells, `md` for the detail header. */
size?: "sm" | "md";
/** Show the state icon alongside the dot. Off in dense tables. */
withIcon?: boolean;
className?: string;
}
/**
* The one status pill. Colour, label and icon all come from lib/workflow, so a
* state looks identical in a table cell, a detail header and a chart legend.
*/
export default function StatusBadge({ value, size = "sm", withIcon = false, className }: Props) {
const status = resolveStatus(value);
if (!status) return null;
const { label, tone, pulse, icon: Icon } = status;
return (
<span
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-full border font-semibold",
tone.bg,
tone.border,
tone.text,
size === "sm" ? "px-2 py-0.5 text-xs" : "px-2.5 py-1 text-sm",
className,
)}
>
{withIcon ? (
<Icon className={size === "sm" ? "size-3" : "size-3.5"} aria-hidden />
) : (
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
tone.solid,
// Only the in-progress state pulses, so motion means "working".
pulse && "animate-pulse",
)}
aria-hidden
/>
)}
{label}
</span>
);
}

View File

@ -0,0 +1,115 @@
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
import { HAPPY_PATH, resolveStatus, stateByName } from "@/lib/workflow";
interface Props {
/** `current_state_name` for the instance. */
state: string | null | undefined;
className?: string;
}
/**
* Renders the invoice lifecycle as a stepper.
*
* The workflow states existed in config.ts all along but were never shown a
* reviewer could see "Finance Review" as a pill without any sense of what came
* before it or what is left. Off-path outcomes (Rejected, On Hold) aren't steps,
* so when the instance is in one of those the strip renders as a single banner
* instead of a misleading progress line.
*/
export default function WorkflowStepper({ state, className }: Props) {
const current = stateByName(state);
const status = resolveStatus(state);
if (!status) return null;
// Rejected / On Hold have no position on the happy path.
if (!current || current.step === null) {
const Icon = status.icon;
return (
<div
className={cn(
"flex items-center gap-2 rounded-xl border px-4 py-3",
status.tone.bg,
status.tone.border,
className,
)}
>
<Icon className={cn("size-4 shrink-0", status.tone.text)} aria-hidden />
<span className={cn("text-sm font-semibold", status.tone.text)}>{status.label}</span>
<span className="text-xs text-muted-foreground">
{current?.key === "hold"
? "Paused — no further action until it is resumed."
: "This invoice is closed."}
</span>
</div>
);
}
const activeStep = current.step;
const doneCount = HAPPY_PATH.filter((s) => (s.step as number) < activeStep).length;
return (
<div className={cn("rounded-xl border border-border bg-card px-4 py-3", className)}>
<div className="mb-2 flex items-center justify-between">
<p className="text-2xs font-bold uppercase tracking-widest text-muted-foreground">
Workflow Progress
</p>
<span className="text-2xs tabular-nums text-muted-foreground">
Step {doneCount + 1} of {HAPPY_PATH.length}
</span>
</div>
<ol className="flex items-center gap-1 overflow-x-auto" aria-label="Invoice progress">
{HAPPY_PATH.map((s, i) => {
const done = (s.step as number) < activeStep;
const active = s.key === current.key;
const Icon = s.icon;
return (
<li key={s.key} className="flex min-w-0 flex-1 items-center gap-1">
{/* No pill on the active step the solid dot and the coloured
label already mark it, and the ring read as a stray box. */}
<div
className="flex min-w-0 items-center gap-2 px-1.5 py-1.5"
aria-current={active ? "step" : undefined}
>
<span
className={cn(
"flex size-6 shrink-0 items-center justify-center rounded-full text-2xs font-bold",
done && "bg-state-approved/15 text-state-approved",
active && cn(s.tone.solid, "text-white shadow-sm"),
!done && !active && "bg-muted text-muted-foreground",
)}
>
{done ? (
<Check className="size-3.5" strokeWidth={3} aria-hidden />
) : (
<Icon className="size-3.5" aria-hidden />
)}
</span>
<span
className={cn(
"truncate text-xs font-medium",
active ? cn(s.tone.text, "font-semibold") : done ? "text-foreground/70" : "text-muted-foreground",
)}
>
{s.label}
</span>
</div>
{i < HAPPY_PATH.length - 1 && (
<span
aria-hidden
className={cn(
"h-0.5 min-w-2 flex-1 shrink rounded-full",
done ? "bg-state-approved/50" : "bg-border",
)}
/>
)}
</li>
);
})}
</ol>
</div>
);
}

View File

@ -0,0 +1,49 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,67 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

103
src/components/ui/card.tsx Normal file
View File

@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@ -0,0 +1,31 @@
import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@ -0,0 +1,195 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
InputGroup,
InputGroupAddon,
} from "@/components/ui/input-group"
import { SearchIcon, CheckIcon } from "lucide-react"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
className
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className
)}
{...props}
/>
)
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@ -0,0 +1,166 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@ -0,0 +1,267 @@
import * as React from "react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@ -0,0 +1,156 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

View File

@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 flex w-72 origin-(--radix-popover-content-transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return (
<div
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}

View File

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import { Progress as ProgressPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="size-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@ -0,0 +1,53 @@
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@ -0,0 +1,192 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

145
src/components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,145 @@
import * as React from "react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" asChild>
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@ -0,0 +1,51 @@
"use client"
// Upstream shadcn wires this to next-themes; this project has its own
// ThemeContext (class strategy on <html>), so it reads from that instead.
import { useTheme } from "@/hooks/ThemeContext"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@ -0,0 +1,88 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@ -0,0 +1,87 @@
import * as React from "react"
import { type VariantProps } from "class-variance-authority"
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}
>({
size: "default",
variant: "default",
spacing: 2,
orientation: "horizontal",
})
function ToggleGroup({
className,
variant,
size,
spacing = 2,
orientation = "horizontal",
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }

View File

@ -0,0 +1,47 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Toggle as TogglePrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default:
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

View File

@ -0,0 +1,55 @@
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }

74
src/config.ts Normal file
View File

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

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

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

View File

@ -0,0 +1,48 @@
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;
root.classList.toggle("dark", theme === "dark");
localStorage.setItem("p2p_theme", theme);
}, [theme]);
// The colour-transition is opt-in per switch rather than a standing global
// rule, so ordinary hovers and mounts don't animate. Skipped entirely when
// the user prefers reduced motion.
const toggleTheme = () => {
const root = document.documentElement;
const animate = !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (animate) {
root.classList.add("theming");
window.setTimeout(() => root.classList.remove("theming"), 220);
}
setTheme((t) => (t === "light" ? "dark" : "light"));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);

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

@ -0,0 +1,97 @@
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 || "https://studio.getzino.in"
).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));
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);
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,
};
}

View File

@ -0,0 +1,19 @@
// Header/nav config.
//
// AppShell and DynamicHeader each called getHeaderConfig() independently on
// mount, so every page load fetched the same nav payload twice. One cached
// query now serves both.
import { useQuery } from "@tanstack/react-query";
import { getHeaderConfig, type NavItem } from "../api/viewService";
export function useHeaderConfig() {
const query = useQuery({
queryKey: ["header-config", "desktop"],
staleTime: 5 * 60_000,
queryFn: () => getHeaderConfig("desktop"),
});
const navItems: NavItem[] = query.data?.components?.nav?.items ?? [];
return { ...query, navItems };
}

View File

@ -0,0 +1,136 @@
// useInstanceDetail — one fetch pass for everything the invoice detail page needs.
//
// The detail page shows four panels that all derive from the same three calls,
// so they're fetched once here and passed down as props rather than each panel
// doing its own round trip.
//
// /app/{id}/view/audit → the activity trail (timeline + submitted data)
// /monitor/instances/{id}/log → the AI employee's decisions + escalations
// /monitor/instances/{id}/trace→ per-run step traces
//
// The two /monitor calls are best-effort: an instance the employee never
// touched has no rows, and ai-employee-service is a separate deployment that
// can be down without the invoice page needing to break. Those failures are
// swallowed into empty results and surfaced as `aiUnavailable`.
import { useCallback, useEffect, useMemo, useState } from "react";
import {
getAuditLog, getAIInstanceLog, getAIInstanceTrace,
type AuditEntry, type AIDecision, type AIEscalation, type AITrace, type FileMeta,
} from "../api/viewService";
import { ACTIVITY_IDS } from "../config";
export interface InstanceDetail {
audit: AuditEntry[];
decisions: AIDecision[];
escalations: AIEscalation[];
traces: AITrace[];
/** True when the AI monitoring service couldn't be reached. */
aiUnavailable: boolean;
loading: boolean;
error: string | null;
refresh: () => void;
// ── derived ───────────────────────────────────────────────────────────────
/** All submitted field values across every activity, later writes winning. */
submitted: Record<string, unknown>;
/** The invoice PDF from the Submit Invoice activity, if one was uploaded. */
invoicePdf: FileMeta | null;
/** Most recent decision that actually went through. */
latestDecision: AIDecision | null;
}
// The audit can carry more than one row for the same activity — the trigger's
// performActivity node commits the submission, which lands alongside the
// original. Collapse on (activity_id, created_at to the second).
function dedupe(entries: AuditEntry[]): AuditEntry[] {
const seen = new Set<string>();
const out: AuditEntry[] = [];
for (const e of entries) {
const key = `${e.activity_id}|${(e.created_at || "").slice(0, 19)}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(e);
}
return out;
}
const asFileMeta = (v: unknown): FileMeta | null => {
const first = Array.isArray(v) ? v[0] : v;
if (first && typeof first === "object" && "blob_path" in (first as any)) return first as FileMeta;
return null;
};
export function useInstanceDetail(instanceId: string | null): InstanceDetail {
const [audit, setAudit] = useState<AuditEntry[]>([]);
const [decisions, setDecisions] = useState<AIDecision[]>([]);
const [escalations, setEscalations] = useState<AIEscalation[]>([]);
const [traces, setTraces] = useState<AITrace[]>([]);
const [aiUnavailable, setAiUnavailable] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tick, setTick] = useState(0);
const refresh = useCallback(() => setTick((t) => t + 1), []);
useEffect(() => {
if (!instanceId) return;
let cancelled = false;
setLoading(true);
setError(null);
// The audit is the only required call — the page is still useful without
// the AI panels, so those resolve to empty instead of rejecting.
Promise.all([
getAuditLog(instanceId),
getAIInstanceLog(instanceId).catch(() => null),
getAIInstanceTrace(instanceId).catch(() => null),
])
.then(([auditRes, logRes, traceRes]) => {
if (cancelled) return;
const sorted = dedupe(
[...(auditRes ?? [])].sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
),
);
setAudit(sorted);
setDecisions(logRes?.decisions ?? []);
setEscalations(logRes?.escalations ?? []);
setTraces(traceRes?.traces ?? []);
setAiUnavailable(logRes === null && traceRes === null);
})
.catch((e: any) => { if (!cancelled) setError(e?.message || "Could not load this invoice"); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [instanceId, tick]);
const submitted = useMemo(() => {
const merged: Record<string, unknown> = {};
for (const e of audit) Object.assign(merged, e.data ?? {});
return merged;
}, [audit]);
const invoicePdf = useMemo(() => {
// Prefer the Submit Invoice activity explicitly; fall back to any entry
// carrying the field, so a re-submission still resolves.
const init = audit.find((e) => e.activity_id === ACTIVITY_IDS.SUBMIT_INVOICE);
return asFileMeta(init?.data?.invoice_pdf) ?? asFileMeta(submitted.invoice_pdf);
}, [audit, submitted]);
const latestDecision = useMemo(() => {
const ok = decisions.filter((d) => d.status === "submitted");
const pool = ok.length ? ok : decisions;
return (
[...pool].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
)[0] ?? null
);
}, [decisions]);
return {
audit, decisions, escalations, traces, aiUnavailable,
loading, error, refresh,
submitted, invoicePdf, latestDecision,
};
}

View File

@ -0,0 +1,284 @@
// Dashboard analytics — one cached fetch shared by the tiles and the charts.
//
// What this replaces: DashboardStats made two sequential requests, the second
// with `limit: <exact total>`, pulling every invoice row down just to count
// three states client-side. It also refetched on every screen change because
// nothing cached it. This does a single bounded request through react-query
// (already provided by ZinoProvider but previously unused by app code), and
// derives every tile and chart from that one payload.
import { useQuery } from "@tanstack/react-query";
import { getRVScreen, getRecordView } from "../api/viewService";
import { ALL_INVOICES_RV_SCREEN_UUID, RECORD_VIEW_IDS } from "../config";
import { WORKFLOW_STATES, stateByName, type StateKey } from "../lib/workflow";
/**
* Upper bound on rows pulled for client-side aggregation. The view-service can
* return `tile_values`/`chart_data` computed server-side; when it does we use
* those instead (see below). This cap keeps the demo honest if it doesn't.
*/
const ROW_CAP = 500;
const AMOUNT_KEYS = ["total_amount", "amount", "net_amount", "gross_amount"];
const DATE_KEYS = ["created_at", "invoice_date", "submitted_at"];
const VENDOR_KEYS = ["vendor_name", "company_name", "supplier_name", "vendor"];
export interface AgingBucket {
label: string;
count: number;
amount: number;
/** Buckets past this point are the ones worth chasing. */
overdue: boolean;
}
export interface TrendPoint {
date: string;
label: string;
count: number;
amount: number;
}
export interface Momentum {
last7: number;
prev7: number;
/** Change vs the preceding 7 days, or null when there is no baseline. */
deltaPct: number | null;
}
export interface VendorTotal {
name: string;
count: number;
amount: number;
}
export interface StateSlice {
key: StateKey;
name: string;
label: string;
count: number;
amount: number;
color: string;
}
export interface InvoiceAnalytics {
total: number;
/** Rows actually aggregated — less than `total` if the cap kicked in. */
sampled: number;
capped: boolean;
byState: StateSlice[];
counts: Record<StateKey, number>;
totalAmount: number;
/** Sitting in clarification or review — money not yet resolved. */
amountAtRisk: number;
approvedAmount: number;
/** Approved / (approved + rejected), or null when nothing is decided yet. */
approvalRate: number | null;
aging: AgingBucket[];
/** Biggest vendors by billed value, highest first. */
topVendors: VendorTotal[];
trend: TrendPoint[];
/** Submission volume this week vs last — drives the tile delta chip. */
momentum: Momentum;
/** Whether the server supplied precomputed tiles for this view. */
serverTiles: boolean;
}
function pickKey(row: Record<string, unknown>, candidates: string[]): string | null {
for (const k of candidates) if (k in row) return k;
return null;
}
function toNumber(v: unknown): number {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
function daysBetween(from: Date, to: Date): number {
return Math.floor((to.getTime() - from.getTime()) / 86_400_000);
}
function buildAging(rows: Record<string, unknown>[], dateKey: string | null, amountKey: string | null): AgingBucket[] {
const buckets: AgingBucket[] = [
{ label: "07d", count: 0, amount: 0, overdue: false },
{ label: "814d", count: 0, amount: 0, overdue: false },
{ label: "1530d", count: 0, amount: 0, overdue: true },
{ label: "3160d", count: 0, amount: 0, overdue: true },
{ label: "60d+", count: 0, amount: 0, overdue: true },
];
if (!dateKey) return buckets;
const now = new Date();
for (const row of rows) {
// Settled invoices aren't aging any more — only open ones matter here.
const state = stateByName(String(row.current_state_name ?? ""));
if (state?.terminal) continue;
const raw = row[dateKey];
if (!raw) continue;
const d = new Date(String(raw));
if (Number.isNaN(d.getTime())) continue;
const age = daysBetween(d, now);
const i = age <= 7 ? 0 : age <= 14 ? 1 : age <= 30 ? 2 : age <= 60 ? 3 : 4;
buckets[i].count++;
buckets[i].amount += amountKey ? toNumber(row[amountKey]) : 0;
}
return buckets;
}
function buildTrend(
rows: Record<string, unknown>[],
dateKey: string | null,
amountKey: string | null,
days = 14,
): TrendPoint[] {
const points: TrendPoint[] = [];
const index = new Map<string, TrendPoint>();
const today = new Date();
today.setHours(0, 0, 0, 0);
for (let i = days - 1; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const iso = d.toISOString().slice(0, 10);
const p: TrendPoint = {
date: iso,
label: d.toLocaleDateString("en-IN", { day: "numeric", month: "short" }),
count: 0,
amount: 0,
};
points.push(p);
index.set(iso, p);
}
if (!dateKey) return points;
for (const row of rows) {
const raw = row[dateKey];
if (!raw) continue;
const d = new Date(String(raw));
if (Number.isNaN(d.getTime())) continue;
const p = index.get(d.toISOString().slice(0, 10));
if (!p) continue; // outside the window
p.count++;
p.amount += amountKey ? toNumber(row[amountKey]) : 0;
}
return points;
}
function buildTopVendors(
rows: Record<string, unknown>[],
vendorKey: string | null,
amountKey: string | null,
limit = 6,
): VendorTotal[] {
if (!vendorKey) return [];
const totals = new Map<string, VendorTotal>();
for (const row of rows) {
const name = String(row[vendorKey] ?? "").trim();
if (!name) continue;
const entry = totals.get(name) ?? { name, count: 0, amount: 0 };
entry.count++;
entry.amount += amountKey ? toNumber(row[amountKey]) : 0;
totals.set(name, entry);
}
// Fall back to volume when the view carries no amount column.
const by: keyof VendorTotal = amountKey ? "amount" : "count";
return [...totals.values()]
.sort((a, b) => (b[by] as number) - (a[by] as number))
.slice(0, limit);
}
/** Splits a 14-point trend into this week vs last week. */
function buildMomentum(trend: TrendPoint[]): Momentum {
const half = Math.floor(trend.length / 2);
const sum = (pts: TrendPoint[]) => pts.reduce((n, p) => n + p.count, 0);
const prev7 = sum(trend.slice(0, half));
const last7 = sum(trend.slice(half));
return { last7, prev7, deltaPct: prev7 > 0 ? (last7 - prev7) / prev7 : null };
}
function aggregate(
rows: Record<string, unknown>[],
total: number,
serverTiles: boolean,
): InvoiceAnalytics {
const sample = rows[0] ?? {};
const amountKey = pickKey(sample, AMOUNT_KEYS);
const dateKey = pickKey(sample, DATE_KEYS);
const vendorKey = pickKey(sample, VENDOR_KEYS);
const counts = Object.fromEntries(
WORKFLOW_STATES.map((s) => [s.key, 0]),
) as Record<StateKey, number>;
const amounts = Object.fromEntries(
WORKFLOW_STATES.map((s) => [s.key, 0]),
) as Record<StateKey, number>;
let totalAmount = 0;
for (const row of rows) {
const amount = amountKey ? toNumber(row[amountKey]) : 0;
totalAmount += amount;
const state = stateByName(String(row.current_state_name ?? ""));
if (!state) continue;
counts[state.key]++;
amounts[state.key] += amount;
}
const byState: StateSlice[] = WORKFLOW_STATES.map((s) => ({
key: s.key,
name: s.name,
label: s.label,
count: counts[s.key],
amount: amounts[s.key],
color: s.tone.css,
})).filter((s) => s.count > 0);
const decided = counts.approved + counts.rejected;
const trend = buildTrend(rows, dateKey, amountKey);
return {
total,
sampled: rows.length,
capped: total > rows.length,
byState,
counts,
totalAmount,
amountAtRisk: amounts.clarification + amounts.review,
approvedAmount: amounts.approved,
approvalRate: decided > 0 ? counts.approved / decided : null,
aging: buildAging(rows, dateKey, amountKey),
topVendors: buildTopVendors(rows, vendorKey, amountKey),
trend,
momentum: buildMomentum(trend),
serverTiles,
};
}
export function useInvoiceAnalytics() {
return useQuery<InvoiceAnalytics>({
queryKey: ["invoice-analytics", ALL_INVOICES_RV_SCREEN_UUID],
staleTime: 60_000,
queryFn: async () => {
const rvScreen = await getRVScreen(ALL_INVOICES_RV_SCREEN_UUID);
const res = await getRecordView(
rvScreen.rv_template_uid,
RECORD_VIEW_IDS.ALL_INVOICES,
{ page: 1, limit: ROW_CAP },
);
const rows = Array.isArray(res.data) ? res.data : [];
const total = res.pagination?.total_count ?? rows.length;
return aggregate(rows, total, (res.tile_values?.length ?? 0) > 0);
},
});
}
export function formatCurrency(n: number, compact = false): string {
return n.toLocaleString("en-IN", {
style: "currency",
currency: "INR",
maximumFractionDigits: 0,
...(compact ? { notation: "compact", compactDisplay: "short" } : {}),
});
}

View File

@ -0,0 +1,65 @@
// Record-view data access.
//
// Replaces the hand-rolled useEffect/useState fetching in RecordViewTable: no
// caching, no request dedupe, and a `dataLoading` flag that flashed an overlay
// on every keystroke-triggered refetch. react-query gives caching and
// `isPlaceholderData` (previous page stays on screen while the next loads).
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
getRVScreen,
getRecordView,
type RecordViewField,
type SearchQuery,
} from "../api/viewService";
import { startWorkflowButtons, type StartButton } from "../lib/startButtons";
export interface RecordViewData {
fields: RecordViewField[];
rows: Record<string, unknown>[];
pagination: { page: number; limit: number; total_count: number; total_pages: number };
}
/** rv_screen metadata: the template id to query and any workflow-launch buttons. */
export function useRVScreen(viewId: number | string) {
return useQuery({
queryKey: ["rv-screen", String(viewId)],
staleTime: 5 * 60_000, // screen config is effectively static
queryFn: async () => {
const rv = await getRVScreen(viewId);
return {
templateId: rv.rv_template_uid,
startButtons: startWorkflowButtons(rv.layout) as StartButton[],
};
},
});
}
export function useRecordViewData(
templateId: string | undefined,
viewId: number | string,
query: SearchQuery,
) {
return useQuery<RecordViewData>({
queryKey: ["record-view", templateId, String(viewId), query],
enabled: !!templateId,
placeholderData: keepPreviousData,
queryFn: async () => {
const res = await getRecordView(templateId!, String(viewId), query);
const rows = Array.isArray(res.data) ? res.data : [];
const limit = query.limit ?? 10;
return {
fields: (res.config?.fields ?? []).filter((f) => f.field_key !== ""),
rows,
// RDBMS-backed views return every row without a pagination envelope.
pagination:
res.pagination ?? {
page: query.page ?? 1,
limit,
total_count: rows.length,
total_pages: Math.max(1, Math.ceil(rows.length / limit)),
},
};
},
});
}

48
src/hooks/useSidebar.ts Normal file
View File

@ -0,0 +1,48 @@
// Sidebar open/collapsed state.
//
// This used to live in two places: DynamicHeader owned it and pushed changes up
// to AppShell through an onSidebarChange callback, while AppShell kept its own
// copy to offset the main content. Neither survived a reload. One hook, one
// value, persisted.
import { useCallback, useEffect, useState } from "react";
const KEY = "p2p_sidebar_open";
/** Below this the sidebar becomes an overlay drawer rather than a rail. */
export const MOBILE_BREAKPOINT = 1024;
function readStored(): boolean {
const stored = localStorage.getItem(KEY);
return stored === null ? true : stored === "true";
}
export function useSidebar() {
const [open, setOpenState] = useState(readStored);
const [isMobile, setIsMobile] = useState(
() => typeof window !== "undefined" && window.innerWidth < MOBILE_BREAKPOINT,
);
/** Separate from `open` — the mobile drawer shouldn't persist. */
const [mobileOpen, setMobileOpen] = useState(false);
useEffect(() => {
const mq = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(mq.matches);
if (!mq.matches) setMobileOpen(false);
};
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
const setOpen = useCallback((next: boolean) => {
setOpenState(next);
localStorage.setItem(KEY, String(next));
}, []);
const toggle = useCallback(() => {
if (isMobile) setMobileOpen((o) => !o);
else setOpen(!open);
}, [isMobile, open, setOpen]);
return { open, setOpen, toggle, isMobile, mobileOpen, setMobileOpen };
}

333
src/index.css Normal file
View File

@ -0,0 +1,333 @@
@import "tailwindcss";
@import "tw-animate-css";
/* Tailwind v4 dropped `darkMode: 'class'` from config the class strategy is
declared here instead. ThemeContext toggles `.dark` on <html>. */
@custom-variant dark (&:is(.dark *));
/* ---------------------------------------------------------------------------
Raw palette
Kept as plain custom properties so they can be re-declared under .dark;
@theme below maps them into Tailwind's utility namespaces.
--------------------------------------------------------------------------- */
:root {
/* Faintly tinted rather than pure white white cards need something to lift
off, and the table now relies on card-vs-page contrast, not zebra rows. */
--background: hsl(210 40% 98%);
--foreground: hsl(217 33% 17%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(217 33% 17%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(217 33% 17%);
/* Brand violet-600. Deep enough to clear WCAG AA on white for body text
and CTAs; lighter violets are reserved for fills, chips and rings. */
--primary: hsl(263 83% 58%);
--primary-foreground: hsl(0 0% 100%);
--secondary: hsl(210 40% 96%);
--secondary-foreground: hsl(217 33% 17%);
--muted: hsl(210 40% 96%);
--muted-foreground: hsl(215 16% 47%);
--accent: hsl(251 91% 95%);
--accent-foreground: hsl(263 83% 58%);
--destructive: hsl(0 84% 60%);
--destructive-foreground: hsl(0 0% 100%);
--border: hsl(214 32% 91%);
--input: hsl(214 32% 91%);
--ring: hsl(263 83% 58%);
--radius: 0.625rem;
--sidebar: hsl(250 100% 99%);
--sidebar-foreground: hsl(217 33% 17%);
--sidebar-accent: hsl(251 91% 95%);
--sidebar-accent-foreground: hsl(263 83% 58%);
--sidebar-border: hsl(251 91% 95%);
/* Workflow states single source of truth for status colour. lib/workflow.ts
is the only thing that should map a state name onto these. */
--state-submitted: hsl(199 89% 48%);
--state-analysis: hsl(258 90% 66%);
--state-clarification: hsl(38 92% 50%);
--state-review: hsl(239 84% 67%);
--state-approved: hsl(160 84% 39%);
--state-rejected: hsl(0 84% 60%);
--state-hold: hsl(215 20% 65%);
/* Charts reuse the state hues where a series *is* a state, so a status means
the same colour in a table pill and in a donut slice. */
--chart-1: hsl(263 83% 58%);
--chart-2: hsl(292 84% 61%);
--chart-3: hsl(239 84% 67%);
--chart-4: hsl(199 89% 48%);
--chart-5: hsl(160 84% 39%);
}
.dark {
--background: hsl(240 10% 4%);
--foreground: hsl(240 5% 96%);
--card: hsl(240 6% 10%);
--card-foreground: hsl(240 5% 96%);
--popover: hsl(240 6% 10%);
--popover-foreground: hsl(240 5% 96%);
/* Lifted to violet-500 — violet-600 is too dark to read on near-black. */
--primary: hsl(258 90% 66%);
--primary-foreground: hsl(240 10% 4%);
--secondary: hsl(240 4% 16%);
--secondary-foreground: hsl(240 5% 96%);
--muted: hsl(240 4% 16%);
--muted-foreground: hsl(240 5% 65%);
--accent: hsl(263 50% 20%);
--accent-foreground: hsl(258 90% 80%);
--destructive: hsl(0 72% 62%);
--destructive-foreground: hsl(240 10% 4%);
--border: hsl(240 5% 20%);
--input: hsl(240 5% 26%);
--ring: hsl(258 90% 66%);
--sidebar: hsl(240 10% 4%);
--sidebar-foreground: hsl(240 5% 96%);
--sidebar-accent: hsl(263 45% 18%);
--sidebar-accent-foreground: hsl(258 90% 80%);
--sidebar-border: hsl(240 5% 16%);
--state-submitted: hsl(199 89% 60%);
--state-analysis: hsl(258 90% 72%);
--state-clarification: hsl(38 92% 60%);
--state-review: hsl(239 84% 74%);
--state-approved: hsl(160 70% 50%);
--state-rejected: hsl(0 84% 68%);
--state-hold: hsl(240 5% 55%);
--chart-1: hsl(258 90% 70%);
--chart-2: hsl(292 84% 70%);
--chart-3: hsl(239 84% 74%);
--chart-4: hsl(199 89% 60%);
--chart-5: hsl(160 70% 50%);
}
/* ---------------------------------------------------------------------------
Theme maps the palette into Tailwind namespaces.
`inline` so the generated utilities reference var() directly and therefore
respond to the .dark re-declarations above.
--------------------------------------------------------------------------- */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-state-submitted: var(--state-submitted);
--color-state-analysis: var(--state-analysis);
--color-state-clarification: var(--state-clarification);
--color-state-review: var(--state-review);
--color-state-approved: var(--state-approved);
--color-state-rejected: var(--state-rejected);
--color-state-hold: var(--state-hold);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--font-sans: Inter, system-ui, -apple-system, sans-serif;
/* Dense application type scale replaces the ad-hoc text-[10px]text-[26px]
arbitrary values that were scattered through the components. */
--text-2xs: 0.625rem;
--text-2xs--line-height: 0.875rem;
--text-xs: 0.6875rem;
--text-xs--line-height: 1rem;
--text-sm: 0.75rem;
--text-sm--line-height: 1.125rem;
--text-base: 0.8125rem;
--text-base--line-height: 1.25rem;
--text-md: 0.875rem;
--text-md--line-height: 1.375rem;
--text-lg: 0.9375rem;
--text-lg--line-height: 1.5rem;
--text-xl: 1.125rem;
--text-xl--line-height: 1.625rem;
--text-2xl: 1.375rem;
--text-2xl--line-height: 1.75rem;
--text-3xl: 1.625rem;
--text-3xl--line-height: 2rem;
--text-4xl: 2rem;
--text-4xl--line-height: 2.375rem;
--text-5xl: 2.625rem;
--text-5xl--line-height: 1.1;
--animate-fade-in-up: fade-in-up 0.25s ease-out both;
--animate-shimmer: shimmer 1.6s infinite;
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
}
/* --------------------------------------------------------------------------- */
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground text-base antialiased;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
}
/* Only the theme switch repaints animates. The previous global `*` transition
on background/border/color made every table hover and mount pay for a
transition it never needed. ThemeContext adds .theming for ~220ms. */
.theming,
.theming * {
transition:
background-color 180ms ease,
border-color 180ms ease,
color 180ms ease,
fill 180ms ease;
}
/* One consistent keyboard focus ring. focus-visible (not focus) so it only
appears for keyboard nav; WCAG 2.2 requires a visible indicator. */
:focus-visible {
@apply outline-hidden ring-2 ring-ring/70 ring-offset-2 ring-offset-background;
}
/* Inside table cells keep the ring inset so it isn't clipped by the cell. */
th :focus-visible,
td :focus-visible {
@apply ring-offset-0;
}
}
/* ---------------------------------------------------------------------------
Reduced motion
Decorative motion is stripped; functional feedback (focus rings, and colour
that communicates state) is preserved.
--------------------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
/* Spinners still need to convey "working", just without the spin. */
.animate-spin {
animation: none !important;
opacity: 0.6;
}
}
@utility tabular-nums {
font-variant-numeric: tabular-nums;
}
/* Skip link first focusable element in the shell, visually hidden until
focused. Must move focus, not merely scroll (WCAG 2.4.1). */
@utility skip-link {
@apply absolute left-4 top-4 z-100 -translate-y-24 rounded-md bg-primary px-4
py-2 text-sm font-semibold text-primary-foreground shadow-lg
transition-transform focus-visible:translate-y-0;
}
@utility gradient-brand {
background-image: linear-gradient(135deg, var(--primary), hsl(292 84% 61%));
}
@utility gradient-text {
background-image: linear-gradient(135deg, var(--primary), hsl(292 84% 61%));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
@utility scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
&::-webkit-scrollbar {
width: 8px;
height: 8px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
@apply rounded-full bg-border;
}
}

66
src/lib/format.ts Normal file
View File

@ -0,0 +1,66 @@
// Value formatting shared by the record view and the detail view. Previously
// each had its own near-identical fmtVal/renderCell pair, which drifted.
const MONEY_HINTS = ["amount", "tax", "price", "cost", "total", "payment", "value"];
export const isMoneyField = (key: string) => {
const k = key.toLowerCase();
return MONEY_HINTS.some((h) => k.includes(h));
};
export function formatCurrency(n: number, compact = false): string {
return n.toLocaleString("en-IN", {
style: "currency",
currency: "INR",
maximumFractionDigits: 0,
...(compact ? { notation: "compact", compactDisplay: "short" } : {}),
});
}
export function formatDate(value: string): string {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return d.toLocaleDateString("en-IN", { day: "2-digit", month: "short", year: "numeric" });
}
/** Clock time, or null when the value carries no time component. */
export function formatTime(value: string): string | null {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return null;
if (!/[T\s]\d{2}:\d{2}/.test(value)) return null;
return d.toLocaleTimeString("en-IN", { hour: "2-digit", minute: "2-digit" });
}
/** "3 days ago" / "in 2 days" — for aging and timeline captions. */
export function formatRelative(value: string): string | null {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return null;
const diffDays = Math.round((d.getTime() - Date.now()) / 86_400_000);
if (diffDays === 0) return "today";
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
if (Math.abs(diffDays) < 31) return rtf.format(diffDays, "day");
return rtf.format(Math.round(diffDays / 30), "month");
}
export function formatBytes(n?: number): string {
if (n == null || !Number.isFinite(n)) return "";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
export function formatLabel(label: string): string {
return label.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Plain-text formatting of a view value, honouring its declared data_type. */
export function formatValue(value: unknown, dataType: string, fieldKey: string): string {
if (value == null || value === "") return "—";
if (dataType === "number") {
const n = Number(value);
if (!Number.isFinite(n)) return String(value);
return isMoneyField(fieldKey) ? formatCurrency(n) : n.toLocaleString("en-IN");
}
if (dataType === "date" || dataType === "datetime") return formatDate(String(value));
return String(value);
}

82
src/lib/matchSummary.ts Normal file
View File

@ -0,0 +1,82 @@
// Parsers for the AI employee's two free-text reconciliation fields.
//
// Kept separate from the card that renders them so the logic is testable
// without React. Both formats come from the deployed prompt/knowledge, not from
// a schema, so these parsers are deliberately forgiving: an unrecognised chunk
// is dropped rather than throwing, and a missing verdict keyword degrades to a
// neutral chip instead of losing the row.
export type Tone = "good" | "bad" | "warn" | "neutral";
export const VERDICT_TONE: Record<string, Tone> = {
MATCH: "good", VALID: "good", CLEAN: "good", ACTIVE: "good",
PASS: "good", PASSED: "good", OK: "good", EXISTS: "good", APPROVED: "good",
MISMATCH: "bad", INVALID: "bad", FAIL: "bad", FAILED: "bad",
DUPLICATE: "bad", EXPIRED: "bad", EXCEEDED: "bad", VIOLATION: "bad",
PARTIAL: "warn", MISSING: "warn", PENDING: "warn",
UNKNOWN: "warn", UNVERIFIED: "warn",
// "Payments: NONE" means no prior payment was found — the good outcome for a
// duplicate check, so it must not read as a warning.
NONE: "neutral", "N/A": "neutral",
};
/**
* Splits "Label: rest. Label: rest." into pairs.
*
* A sentence boundary only separates records when what follows looks like a new
* "Label:" otherwise a value containing a full stop (a date, "2% tolerance.")
* would be torn in half.
*
* The label pattern has to allow digits and 2-character labels: real values
* include "PO-3001:", "GR-3001:", "Contract CNT-300:" and "GR:". An earlier
* letters-only, 3-char-minimum pattern silently merged the whole evidence
* string into one row and dropped "GR:" entirely.
*/
export function splitLabelled(text: string): Array<{ label: string; rest: string }> {
const out: Array<{ label: string; rest: string }> = [];
const chunks = text
.split(/(?<=\.)\s+(?=[A-Z][A-Za-z0-9][A-Za-z0-9 \-/]{0,28}:)/)
.map((s) => s.trim())
.filter(Boolean);
for (const c of chunks) {
const m = c.match(/^([^:]{2,30}):\s*([\s\S]*)$/);
if (!m) continue;
out.push({ label: m[1].trim(), rest: m[2].trim().replace(/\.$/, "") });
}
return out;
}
export interface Verdict { label: string; verdict: string; detail: string; tone: Tone }
/** "Price: MATCH (₹550/unit per …)" → {label:"Price", verdict:"MATCH", detail:"₹550/unit per …"} */
export function parseMatchSummary(text?: string): Verdict[] {
if (!text?.trim()) return [];
return splitLabelled(text).map(({ label, rest }) => {
// A leading ALL-CAPS token is the verdict; the remainder explains it.
const m = rest.match(/^([A-Z][A-Z/_]{1,14})\b[\s:.\-]*([\s\S]*)$/);
const verdict = m ? m[1] : "";
let detail = (m ? m[2] : rest).trim();
// Most details arrive fully parenthesised — unwrap for cleaner rendering.
detail = detail.replace(/^\(([\s\S]*)\)$/, "$1").trim();
return { label, verdict, detail, tone: VERDICT_TONE[verdict] ?? "neutral" };
});
}
export interface EvidenceRow { label: string; value: string; isIssue: boolean }
/** "PO-3001: 200 units @ ₹500/unit = ₹100,000." → one row per entity. */
export function parseEvidence(text?: string): EvidenceRow[] {
if (!text?.trim()) return [];
return splitLabelled(text).map(({ label, rest }) => ({
label,
value: rest,
isIssue: /^(issue|issues|concern|problem|discrepanc)/i.test(label),
}));
}
/** Worst verdict across the set — drives the card's headline. */
export function overallTone(verdicts: Verdict[]): Tone {
if (verdicts.some((v) => v.tone === "bad")) return "bad";
if (verdicts.some((v) => v.tone === "warn")) return "warn";
return "good";
}

19
src/lib/palette.ts Normal file
View File

@ -0,0 +1,19 @@
// Categorical colours for things that aren't workflow states — vendor avatars
// and vendor bars. Deliberately separate from --chart-* (which reuses the state
// hues, magenta included) so arbitrary categories read as distinct without
// implying a status.
export const CATEGORY_COLORS = [
"hsl(263 83% 58%)", // violet
"hsl(199 89% 48%)", // sky
"hsl(160 84% 39%)", // emerald
"hsl(38 92% 50%)", // amber
"hsl(239 84% 67%)", // indigo
"hsl(174 72% 41%)", // teal
];
/** Stable colour for a name — the same vendor keeps its hue across pages. */
export function colorForName(name: string): string {
const hash = [...name].reduce((n, c) => n + c.charCodeAt(0), 0);
return CATEGORY_COLORS[hash % CATEGORY_COLORS.length];
}

86
src/lib/startButtons.ts Normal file
View File

@ -0,0 +1,86 @@
// collectStartButtons — find the workflow-launching buttons in a studio layout.
//
// Studio puts these buttons in two different places, in two different shapes,
// which is why they were previously missed entirely:
//
// * screen layout (tbl_appconfig_screens.layout) — the button lives under a
// node's `config` key, nested anywhere in a `children` tree.
// * rv_screen layout (tbl_appconfig_rv_screens.layout) — the button object
// itself carries `type` / `params` / `job_template` at its top level.
//
// The activity identifier has also changed key over time. Current studio writes
// `params.activity_uid_init`; older configs (and the dev-era app) used
// `params.activity_id_init`. Both are accepted here, newest first.
//
// Rather than model either shape, this walks every object in the tree and picks
// out anything that looks like a button carrying an activity id.
export interface StartButton {
/** React key — the activity uid is unique per button in practice. */
key: string;
label: string;
/** Activity uid to launch. For start_state_machine this is the INIT activity. */
activityId: string;
/** 'start_state_machine' (new instance) | 'perform_activity' (existing) | other. */
jobTemplate?: string;
workflowUuid?: string;
versionUuid?: string;
}
const pickActivityId = (params: any): string | undefined =>
params?.activity_uid_init ||
params?.activity_id_init ||
params?.activity_uid ||
params?.activity_id ||
undefined;
// A node is button-ish if it says so, or if it carries a job_template (studio
// omits type on some button variants).
const isButtonish = (o: any): boolean =>
o?.type === "button" || typeof o?.job_template === "string";
export function collectStartButtons(layout: unknown): StartButton[] {
const out: StartButton[] = [];
const seen = new Set<string>();
const visit = (node: any): void => {
if (!node || typeof node !== "object") return;
if (Array.isArray(node)) {
node.forEach(visit);
return;
}
if (isButtonish(node)) {
const activityId = pickActivityId(node.params);
if (activityId) {
const jobTemplate = node.job_template;
const key = `${jobTemplate ?? "button"}:${activityId}`;
if (!seen.has(key)) {
seen.add(key);
out.push({
key,
// Studio labels sometimes carry a leading "+ ".
label: String(node.name ?? "New").replace(/^\+\s*/, ""),
activityId,
jobTemplate,
workflowUuid: node.params?.workflow_uuid,
versionUuid: node.params?.version_uuid,
});
}
}
}
// Recurse into everything — `config`, `children`, and any other nesting.
for (const v of Object.values(node)) visit(v);
};
visit(layout);
return out;
}
/** Buttons that start a brand-new workflow instance. */
export const startWorkflowButtons = (layout: unknown): StartButton[] =>
collectStartButtons(layout).filter(
(b) => b.jobTemplate === "start_state_machine" || b.jobTemplate === undefined,
);

50
src/lib/tableParams.ts Normal file
View File

@ -0,0 +1,50 @@
// Shared URL state for the record view.
//
// Table sort/page/search/filters used to live in component state, so a refresh
// or a shared link lost the user's view entirely. These parsers put it in the
// query string instead, which also lets the dashboard tiles act as filters —
// a tile writes `status`, the table reads it.
import {
parseAsArrayOf,
parseAsInteger,
parseAsString,
parseAsStringLiteral,
} from "nuqs";
export const DENSITIES = ["compact", "regular", "relaxed"] as const;
export type Density = (typeof DENSITIES)[number];
/** Row heights per density — the 40/48/56px convention. */
export const DENSITY_ROW: Record<Density, string> = {
compact: "h-10",
regular: "h-12",
relaxed: "h-14",
};
export const DENSITY_CELL: Record<Density, string> = {
compact: "py-1.5",
regular: "py-3",
relaxed: "py-4",
};
export const tableParams = {
q: parseAsString.withDefault(""),
page: parseAsInteger.withDefault(1),
limit: parseAsInteger.withDefault(10),
sort: parseAsString.withDefault(""),
dir: parseAsStringLiteral(["asc", "desc"] as const).withDefault("desc"),
/** Workflow state names, matching `current_state_name`. */
status: parseAsArrayOf(parseAsString).withDefault([]),
density: parseAsStringLiteral(DENSITIES).withDefault("regular"),
};
/**
* Keep the URL short: nuqs omits any value equal to its default, and history
* is replaced rather than pushed so filtering doesn't fill the back button.
*/
export const tableParamsOptions = {
history: "replace" as const,
clearOnDefault: true,
shallow: true,
};

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

330
src/lib/workflow.ts Normal file
View File

@ -0,0 +1,330 @@
// Single source of truth for workflow state presentation.
//
// Before this, STATUS colour maps were duplicated in RecordViewTable and
// DetailViewPanel with different shapes (one used bg/text/dot/dark*, the other
// pill/dot/bar), so a state could legitimately render two different colours in
// two places. Everything state-shaped now resolves through here.
//
// Matching is by `current_state_name` because that is what the record- and
// detail-view payloads actually carry. STATE_IDS UUIDs are kept alongside for
// the places that compare against `current_state_id` (instance payloads).
import {
AlertCircle,
Ban,
CheckCircle2,
Clock,
FileText,
PauseCircle,
Sparkles,
Truck,
XCircle,
type LucideIcon,
} from "lucide-react";
import { ACTIVITY_IDS, STATE_IDS } from "../config";
export type StateKey =
| "submitted"
| "analysis"
| "clarification"
| "review"
| "approved"
| "rejected"
| "hold";
export interface StateTone {
/** Foreground for the label. */
text: string;
/** Tinted fill for pills and row accents. */
bg: string;
/** Hairline for pills. */
border: string;
/** Solid dot / progress bar. */
solid: string;
/** Raw CSS colour for non-Tailwind consumers (recharts fills). */
css: string;
}
export interface WorkflowState {
key: StateKey;
/** STATE_IDS uuid — compare against instance.current_state_id. */
id: string;
/** Canonical `current_state_name` from the view payloads. */
name: string;
/** Other spellings the payloads use for the same state. */
aliases?: string[];
label: string;
icon: LucideIcon;
tone: StateTone;
/** Position on the happy path, or null for off-path outcomes. */
step: number | null;
terminal: boolean;
}
// Class strings are written out literally rather than composed, so Tailwind's
// scanner can see every one of them.
const TONES: Record<StateKey, StateTone> = {
submitted: {
text: "text-state-submitted",
bg: "bg-state-submitted/10",
border: "border-state-submitted/25",
solid: "bg-state-submitted",
css: "var(--state-submitted)",
},
analysis: {
text: "text-state-analysis",
bg: "bg-state-analysis/10",
border: "border-state-analysis/25",
solid: "bg-state-analysis",
css: "var(--state-analysis)",
},
clarification: {
text: "text-state-clarification",
bg: "bg-state-clarification/10",
border: "border-state-clarification/25",
solid: "bg-state-clarification",
css: "var(--state-clarification)",
},
review: {
text: "text-state-review",
bg: "bg-state-review/10",
border: "border-state-review/25",
solid: "bg-state-review",
css: "var(--state-review)",
},
approved: {
text: "text-state-approved",
bg: "bg-state-approved/10",
border: "border-state-approved/25",
solid: "bg-state-approved",
css: "var(--state-approved)",
},
rejected: {
text: "text-state-rejected",
bg: "bg-state-rejected/10",
border: "border-state-rejected/25",
solid: "bg-state-rejected",
css: "var(--state-rejected)",
},
hold: {
text: "text-state-hold",
bg: "bg-state-hold/10",
border: "border-state-hold/25",
solid: "bg-state-hold",
css: "var(--state-hold)",
},
};
/** Ordered by the happy path — drives the detail-view stepper. */
export const WORKFLOW_STATES: WorkflowState[] = [
{
key: "submitted",
id: STATE_IDS.INVOICE_SUBMITTED,
name: "Invoice Submitted",
label: "Submitted",
icon: FileText,
tone: TONES.submitted,
step: 0,
terminal: false,
},
{
key: "analysis",
id: STATE_IDS.AI_ANALYSIS,
name: "AI Analysis",
label: "AI Analysis",
icon: Sparkles,
tone: TONES.analysis,
step: 1,
terminal: false,
},
{
key: "clarification",
id: STATE_IDS.AWAITING_CLARIFICATION,
name: "Awaiting Clarification",
label: "Awaiting Clarification",
icon: AlertCircle,
tone: TONES.clarification,
step: 2,
terminal: false,
},
{
key: "review",
id: STATE_IDS.FINANCE_REVIEW,
// The record view serves "Financial Review"; older payloads say
// "Finance Review". Both must resolve to this state or the rows fall
// through to the unstyled fallback and drop out of every count.
name: "Financial Review",
aliases: ["Finance Review"],
label: "Finance Review",
icon: Clock,
tone: TONES.review,
step: 3,
terminal: false,
},
{
key: "approved",
id: STATE_IDS.APPROVED,
name: "Approved",
label: "Approved",
icon: CheckCircle2,
tone: TONES.approved,
step: 4,
terminal: true,
},
{
key: "rejected",
id: STATE_IDS.REJECTED,
name: "Rejected",
label: "Rejected",
icon: XCircle,
tone: TONES.rejected,
step: null,
terminal: true,
},
{
key: "hold",
id: STATE_IDS.ON_HOLD,
name: "On Hold",
label: "On Hold",
icon: PauseCircle,
tone: TONES.hold,
step: null,
terminal: false,
},
];
/** The happy path, for the stepper. Excludes off-path outcomes. */
export const HAPPY_PATH = WORKFLOW_STATES.filter((s) => s.step !== null).sort(
(a, b) => (a.step as number) - (b.step as number),
);
const BY_NAME = new Map(
WORKFLOW_STATES.flatMap((s) =>
[s.name, ...(s.aliases ?? [])].map((n) => [n.toLowerCase(), s] as const),
),
);
const BY_ID = new Map(WORKFLOW_STATES.map((s) => [s.id, s]));
// The RDBMS purchase-order views carry a `po_status` with its own vocabulary.
// Reusing the invoice tones keeps one colour language across both record types.
const PO_STATUSES: Record<string, { label: string; icon: LucideIcon; tone: StateTone }> = {
pending: { label: "Pending", icon: Clock, tone: TONES.clarification },
delivered: { label: "Delivered", icon: Truck, tone: TONES.submitted },
cancelled: { label: "Cancelled", icon: Ban, tone: TONES.rejected },
};
export interface ResolvedStatus {
label: string;
icon: LucideIcon;
tone: StateTone;
/** True for the state whose dot should pulse (work in progress). */
pulse: boolean;
state?: WorkflowState;
}
const FALLBACK_TONE = TONES.hold;
/** Resolve any status string — invoice state name or PO status — for display. */
export function resolveStatus(value: string | null | undefined): ResolvedStatus | null {
if (!value) return null;
const key = String(value).trim();
if (!key) return null;
const state = BY_NAME.get(key.toLowerCase());
if (state) {
return {
label: state.label,
icon: state.icon,
tone: state.tone,
pulse: state.key === "analysis",
state,
};
}
const po = PO_STATUSES[key.toLowerCase()];
if (po) return { label: po.label, icon: po.icon, tone: po.tone, pulse: false };
// Unknown status still renders, just without a colour opinion.
return { label: key, icon: FileText, tone: FALLBACK_TONE, pulse: false };
}
export const stateByName = (name: string | null | undefined) =>
name ? BY_NAME.get(String(name).trim().toLowerCase()) : undefined;
export const stateById = (id: string | null | undefined) =>
id ? BY_ID.get(id) : undefined;
// ---------------------------------------------------------------------------
// Actions available in each state
// ---------------------------------------------------------------------------
export type ActionStyle = "primary" | "danger" | "ghost";
export interface StateAction {
activityId: string;
label: string;
/** Shorter label for the cramped row menu. */
shortLabel: string;
icon: LucideIcon;
style: ActionStyle;
/** Requires a confirm step / offers undo, because it ends the invoice. */
destructive: boolean;
}
const ACTIONS: Record<string, StateAction> = {
clarify: {
activityId: ACTIVITY_IDS.PROVIDE_CLARIFICATION,
label: "Provide Clarification",
shortLabel: "Clarify",
icon: AlertCircle,
style: "primary",
destructive: false,
},
approve: {
activityId: ACTIVITY_IDS.APPROVE_PAYMENT,
label: "Approve",
shortLabel: "Approve",
icon: CheckCircle2,
style: "primary",
destructive: false,
},
reject: {
activityId: ACTIVITY_IDS.REJECT_PAYMENT,
label: "Reject",
shortLabel: "Reject",
icon: XCircle,
style: "danger",
destructive: true,
},
hold: {
activityId: ACTIVITY_IDS.HOLD_PAYMENT,
label: "Hold",
shortLabel: "Hold",
icon: PauseCircle,
style: "ghost",
destructive: false,
},
};
const ACTIONS_BY_STATE: Partial<Record<StateKey, StateAction[]>> = {
clarification: [ACTIONS.clarify],
review: [ACTIONS.approve, ACTIONS.reject, ACTIONS.hold],
};
/**
* Actions offered for a state name.
*
* Replaces a reverse-lookup in RecordViewTable that tried to rebuild a state
* name by stripping a `p2p-state-` prefix off each STATE_IDS value. Those
* values are UUIDs, so the comparison never matched and the row action menu
* never rendered at all.
*/
export function actionsForStateName(name: string | null | undefined): StateAction[] {
const state = stateByName(name);
return state ? (ACTIONS_BY_STATE[state.key] ?? []) : [];
}
export function actionsForStateId(id: string | null | undefined): StateAction[] {
const state = stateById(id);
return state ? (ACTIONS_BY_STATE[state.key] ?? []) : [];
}

147
src/lib/workflowMeta.ts Normal file
View File

@ -0,0 +1,147 @@
// Display metadata for the P2P workflow.
//
// Everything here keys off UUIDs (ACTIVITY_IDS / STATE_IDS from config.ts)
// rather than off the human-readable names the API returns. That matters: the
// deployed state is named "Financial Review", and several older call sites
// match on the string "Finance Review" and silently fall through to a neutral
// style. Keying on the uuid can't drift.
import { ACTIVITY_IDS, STATE_IDS } from "../config";
// ---------------------------------------------------------------------------
// Activities
// ---------------------------------------------------------------------------
export const ACTIVITY_LABELS: Record<string, string> = {
[ACTIVITY_IDS.SUBMIT_INVOICE]: "Invoice submitted",
[ACTIVITY_IDS.REQUEST_CLARIFICATION]: "Clarification requested",
[ACTIVITY_IDS.PROVIDE_CLARIFICATION]: "Clarification provided",
[ACTIVITY_IDS.SUBMIT_RECOMMENDATION]: "Recommendation submitted",
[ACTIVITY_IDS.APPROVE_PAYMENT]: "Payment approved",
[ACTIVITY_IDS.REJECT_PAYMENT]: "Payment rejected",
[ACTIVITY_IDS.HOLD_PAYMENT]: "Payment held",
};
export const activityLabel = (uid: string): string =>
ACTIVITY_LABELS[uid] ?? "Activity";
// ---------------------------------------------------------------------------
// States
// ---------------------------------------------------------------------------
export type StateTone = "sky" | "violet" | "amber" | "indigo" | "emerald" | "red" | "slate";
interface StateMeta { label: string; tone: StateTone; terminal?: boolean }
// Labels match the deployed workflow exactly (note "Financial Review").
export const STATE_META: Record<string, StateMeta> = {
[STATE_IDS.INVOICE_SUBMITTED]: { label: "Invoice Submitted", tone: "sky" },
[STATE_IDS.AI_ANALYSIS]: { label: "AI Analysis", tone: "violet" },
[STATE_IDS.AWAITING_CLARIFICATION]: { label: "Awaiting Clarification", tone: "amber" },
[STATE_IDS.FINANCE_REVIEW]: { label: "Financial Review", tone: "indigo" },
[STATE_IDS.APPROVED]: { label: "Approved", tone: "emerald", terminal: true },
[STATE_IDS.REJECTED]: { label: "Rejected", tone: "red", terminal: true },
[STATE_IDS.ON_HOLD]: { label: "On Hold", tone: "slate", terminal: true },
};
export const stateMeta = (uid?: string | null): StateMeta =>
(uid && STATE_META[uid]) || { label: "—", tone: "slate" };
// TONE_PILL / TONE_DOT lived here as hardcoded slate/zinc/amber class strings.
// They were the only remaining pre-migration palette in the app; state colour
// now resolves through lib/workflow.ts tones and StatusBadge. STATE_META is
// kept because keying display metadata off state UUIDs is more robust than
// matching served names, and folding it into lib/workflow.ts is the next step.
// ---------------------------------------------------------------------------
// Actors
// ---------------------------------------------------------------------------
// The audit records user_id + roles, not names. Roles are stable and readable,
// so derive the actor from the role rather than hardcoding a user-id table.
const ROLE_ACTORS: Record<string, { name: string; ai?: boolean }> = {
p2p_ai_finance: { name: "Priya · AI Finance", ai: true },
p2p_vendor: { name: "Vendor" },
p2p_warehouse: { name: "Warehouse" },
p2p_procurement: { name: "Procurement" },
p2p_ap_team: { name: "Accounts Payable" },
p2p_finance_reviewer: { name: "Finance Reviewer" },
};
export interface Actor { name: string; isAI: boolean }
export function resolveActor(roles?: string[] | null, userId?: string): Actor {
const list = Array.isArray(roles) ? roles : [];
// A single-role actor is unambiguous. Multi-role users (e.g. the admin test
// account holds all six) can't be pinned down, so fall back to the user id.
if (list.length === 1) {
const m = ROLE_ACTORS[list[0]];
if (m) return { name: m.name, isAI: !!m.ai };
}
const ai = list.includes("p2p_ai_finance") && list.length === 1;
return { name: userId ? `User ${userId}` : "Unknown", isAI: ai };
}
// ---------------------------------------------------------------------------
// Formatting
// ---------------------------------------------------------------------------
export function fmtDateTime(iso?: string): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso);
return d.toLocaleString(undefined, {
day: "2-digit", month: "short", year: "numeric",
hour: "2-digit", minute: "2-digit",
});
}
export function fmtRelative(iso?: string): string {
if (!iso) return "";
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return "";
const secs = Math.round((Date.now() - t) / 1000);
if (secs < 60) return "just now";
const mins = Math.round(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.round(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.round(hrs / 24);
if (days < 30) return `${days}d ago`;
return fmtDateTime(iso);
}
/** Elapsed time between two ISO stamps, for timeline gaps. */
export function fmtGap(fromIso: string, toIso: string): string {
const a = new Date(fromIso).getTime(), b = new Date(toIso).getTime();
if (Number.isNaN(a) || Number.isNaN(b)) return "";
const secs = Math.round((b - a) / 1000);
if (secs < 1) return "";
if (secs < 60) return `${secs}s`;
const mins = Math.round(secs / 60);
if (mins < 60) return `${mins}m`;
const hrs = Math.round(mins / 60);
if (hrs < 48) return `${hrs}h`;
return `${Math.round(hrs / 24)}d`;
}
export function fmtDuration(ms?: number): string {
if (ms == null) return "—";
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`;
}
/** Amounts in this app are INR. Keep the code, not the glyph — matches the PDFs. */
export function fmtAmount(v: unknown, currency = "INR"): string {
const n = typeof v === "number" ? v : Number(String(v ?? "").replace(/[^0-9.\-]/g, ""));
if (!Number.isFinite(n)) return "—";
return `${currency} ${n.toLocaleString("en-IN", { maximumFractionDigits: 2 })}`;
}
export function fmtBytes(n?: number): string {
if (!n) return "";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}

34
src/main.tsx Normal file
View File

@ -0,0 +1,34 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { NuqsAdapter } from 'nuqs/adapters/react-router/v6'
import { ZinoProvider } from './zino-sdk'
import { AuthProvider } from './hooks/AuthContext'
import { ThemeProvider } from './hooks/ThemeContext'
import { TooltipProvider } from './components/ui/tooltip'
import { Toaster } from './components/ui/sonner'
import './index.css'
import App from './App'
const apiUrl = import.meta.env.VITE_ZINO_API_URL || 'https://studio.getzino.in'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider>
<ZinoProvider baseUrl={apiUrl}>
<BrowserRouter basename={import.meta.env.BASE_URL}>
{/* NuqsAdapter must sit inside the router it reads and writes the
router's search params, so table state can live in the URL. */}
<NuqsAdapter>
<AuthProvider>
<TooltipProvider delayDuration={300}>
<App />
<Toaster />
</TooltipProvider>
</AuthProvider>
</NuqsAdapter>
</BrowserRouter>
</ZinoProvider>
</ThemeProvider>
</StrictMode>,
)

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

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

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

@ -0,0 +1,213 @@
import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { ArrowRight, BarChart3, Bot, Loader2, ShieldCheck, Zap } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAuthContext } from "@/hooks/AuthContext";
import { ORG_ID } from "@/config";
const HIGHLIGHTS = [
{
icon: Zap,
title: "Instant Validation",
desc: "AI matches invoices against POs and GRNs in seconds",
},
{ icon: Bot, title: "Smart Routing", desc: "Exceptions auto-route to the right department" },
{
icon: BarChart3,
title: "Full Audit Trail",
desc: "Complete visibility from submission to payment",
},
];
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 from = (location.state as any)?.from?.pathname || "/";
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearError();
try {
// Org id comes from config rather than a hardcoded literal in component state.
await login({ email, password, orgId: ORG_ID });
navigate(from, { replace: true });
} catch {
// AuthContext surfaces the message through `error`.
}
};
return (
<div className="flex min-h-screen bg-background">
{/* ── Left — branding ───────────────────────────────────────────────── */}
<div className="relative hidden overflow-hidden p-16 lg:flex lg:w-[45%] lg:items-center lg:justify-center">
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(145deg, #1e1040 0%, #2d1567 40%, #1a0f3a 70%, #0f0a2e 100%)",
}}
aria-hidden
/>
{/* Decorative orbs + grid */}
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
<div
className="absolute -left-32 -top-32 size-[500px] rounded-full opacity-30"
style={{ background: "radial-gradient(circle, #7c3aed 0%, transparent 70%)" }}
/>
<div
className="absolute -bottom-20 -right-20 size-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>
<img
src={import.meta.env.BASE_URL + "zino.svg"}
alt="Zino"
className="absolute left-16 top-10 z-10 h-5 w-auto brightness-0 invert"
/>
<div className="relative z-10 max-w-lg">
<span className="mb-8 inline-flex items-center gap-2 rounded-full border border-violet-300/30 bg-primary/20 px-3 py-1.5 text-sm font-medium text-violet-200">
<ShieldCheck className="size-3.5" aria-hidden />
Enterprise-grade procurement automation
</span>
<h1 className="mb-8 text-5xl font-bold tracking-tight text-white">
Procure-to-Pay
<br />
<span
className="bg-clip-text text-transparent"
style={{ backgroundImage: "linear-gradient(135deg, #c4b5fd, #f0abfc, #67e8f9)" }}
>
Invoice Intelligence
</span>
</h1>
<ul className="space-y-4">
{HIGHLIGHTS.map(({ icon: Icon, title, desc }) => (
<li key={title} className="flex items-start gap-3">
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-violet-300/25 bg-primary/25 text-violet-200">
<Icon className="size-3.5" aria-hidden />
</span>
<div>
<p className="text-base font-semibold text-white">{title}</p>
<p className="mt-0.5 text-sm text-violet-200/70">{desc}</p>
</div>
</li>
))}
</ul>
</div>
</div>
{/* ── Right — form ──────────────────────────────────────────────────── */}
<div className="flex flex-1 items-center justify-center px-6">
<div className="w-full max-w-[340px]">
<img
src={import.meta.env.BASE_URL + "zino.svg"}
alt="Zino"
className="mb-10 h-5 w-auto dark:invert lg:hidden"
/>
<div className="mb-8">
<h2 className="text-4xl font-bold tracking-tight text-foreground">Sign in</h2>
<p className="mt-1.5 text-base text-muted-foreground">
Enter your credentials to access the portal.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="email" className="mb-1.5">
Email
</Label>
<Input
id="email"
type="email"
required
autoComplete="email"
autoFocus
value={email}
onChange={(e) => {
setEmail(e.target.value);
clearError();
}}
aria-invalid={!!error}
placeholder="you@company.com"
className="h-10"
/>
</div>
<div>
<Label htmlFor="password" className="mb-1.5">
Password
</Label>
<Input
id="password"
type="password"
required
autoComplete="current-password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
clearError();
}}
aria-invalid={!!error}
aria-describedby={error ? "login-error" : undefined}
placeholder="••••••••"
className="h-10"
/>
</div>
{error && (
<p
id="login-error"
role="alert"
className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive"
>
{error}
</p>
)}
<Button type="submit" disabled={loading} className="h-10 w-full">
{loading ? (
<>
<Loader2 className="size-3.5 animate-spin" aria-hidden />
Signing in
</>
) : (
<>
Sign in
<ArrowRight className="size-3.5" aria-hidden />
</>
)}
</Button>
</form>
<div className="mt-10 flex items-center justify-center gap-1.5">
<span className="text-xs text-muted-foreground">Powered by</span>
<img
src={import.meta.env.BASE_URL + "zino.svg"}
alt="Zino"
className="h-3.5 w-auto dark:invert"
/>
</div>
</div>
</div>
</div>
);
}

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

23
tsconfig.app.json Normal file
View File

@ -0,0 +1,23 @@
{
"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,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

13
tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

16
tsconfig.node.json Normal file
View File

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

38
vite.config.ts Normal file
View File

@ -0,0 +1,38 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { fileURLToPath, URL } from 'node:url'
const devTarget = 'https://studio.getzino.in'
const proxy = (target: string) => ({ target, changeOrigin: true, secure: false })
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
base: process.env.VITE_BASE_URL ?? '/p2p/',
server: {
port: 3007,
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),
},
},
})