chore: initial scaffold

This commit is contained in:
frontgen 2026-09-30 09:48:01 +00:00
parent ebcc0486df
commit d2d3a12e92
25 changed files with 1657 additions and 1 deletions

8
.env.example Normal file
View File

@ -0,0 +1,8 @@
# Local development only (`npm run dev`). Reference for the values
# `npm run dev` reads from .env; nothing here is used by a deployed build.
#
# In a deployed environment these values come from config.js, which the server
# writes when it places the build. Do not commit environment-specific values —
# one build is promoted across environments unchanged.
VITE_ZINO_API_URL=http://localhost:8085
VITE_ZINO_MOCK=false

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.local

View File

@ -1,3 +1,3 @@
# asset-management # asset-management
asset-management — generated by frontgen Frontgen project for org 1 / app 531.

14
index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!--BASE_HREF-->
<title>asset-management</title>
<script src="./config.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "asset-management",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc -b",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.60.5",
"lucide-react": "^0.441.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"
},
"devDependencies": {
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.41",
"tailwindcss": "^3.4.10",
"typescript": "~5.5.4",
"vite": "^5.4.2"
}
}

6
postcss.config.js Normal file
View File

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

7
src/App.tsx Normal file
View File

@ -0,0 +1,7 @@
export default function App() {
return (
<div className="min-h-screen flex items-center justify-center">
<h1 className="text-2xl font-semibold">Asset Management</h1>
</div>
)
}

3
src/index.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

17
src/main.tsx Normal file
View File

@ -0,0 +1,17 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { ZinoProvider } from './zino-sdk'
import { requireConfigValue, basePath } from './runtimeConfig'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ZinoProvider baseUrl={requireConfigValue('VITE_ZINO_API_URL')}>
<BrowserRouter basename={basePath()}>
<App />
</BrowserRouter>
</ZinoProvider>
</StrictMode>,
)

35
src/runtimeConfig.ts Normal file
View File

@ -0,0 +1,35 @@
declare global {
interface Window {
__RUNTIME_CONFIG__?: Record<string, string>
}
}
// SAME_ORIGIN resolves to the serving origin, for a frontend mounted on a custom
// domain where the platform is proxied under the same host. It exists because an
// empty string is falsy and so can never survive the || chain below, which is
// exactly what a "use the current origin" value has to do.
export function resolveConfigValue(key: string, fallback = ''): string {
const runtime = (window.__RUNTIME_CONFIG__ || {})[key]
const devFallback = import.meta.env.DEV ? (import.meta.env[key] as string) : ''
const value = runtime || devFallback || fallback
return value === 'SAME_ORIGIN' ? window.location.origin.replace(/\/$/, '') : value
}
// Throws rather than silently running against the wrong backend.
export function requireConfigValue(key: string): string {
const value = resolveConfigValue(key)
if (!value) {
throw new Error(
'Missing runtime config "' + key + '". The server writes config.js when it ' +
'places the build; for local dev set ' + key + ' in .env.',
)
}
return value
}
// The mount path this app is served under, taken from the <base href> the server
// writes into index.html. Drives the router basename, so one build serves any
// mount without being rebuilt.
export function basePath(): string {
return new URL(document.baseURI).pathname
}

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -0,0 +1,197 @@
import type { ZinoClient } from "./client";
import type {
ActivityLogEntry,
Column,
DetailViewResponse,
InstanceReport,
ReportSection,
StateTransition,
TabularViewParams,
TabularViewResponse,
} from "./types";
import {
mockDelay,
mockTabularResponse,
mockInstanceReport,
mockAuditLog,
} from "./mock";
/**
* View and reporting methods — tabular record views, detail views,
* and audit trails. Maps to view-service endpoints.
*/
export class ViewService {
private client: ZinoClient;
constructor(client: ZinoClient) {
this.client = client;
}
/**
* Fetch a paginated, sortable, filterable record view.
* Maps to `GET /view/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", `/view/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 /view/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", `/view/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", "/view/instance", {
workflow_id: workflowId,
instance_id: instanceId,
}),
this.client.request<ActivityLogEntry[]>(
"GET",
`/view/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", `/view/api-docs?workflow_id=${encodeURIComponent(workflowId)}`),
this.client.request<WorkflowInstance>("POST", "/view/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", `/view/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_uuid: 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_uuid: workflowId,
activity_id: activityId,
device_type: deviceType,
form_data: formData,
...(instanceId ? { instance_id: instanceId } : {}),
});
}
}

6
tailwind.config.js Normal file
View File

@ -0,0 +1,6 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: { extend: {} },
plugins: [],
}

19
tsconfig.app.json Normal file
View File

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

7
tsconfig.json Normal file
View File

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

15
tsconfig.node.json Normal file
View File

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

8
vite.config.ts Normal file
View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: './',
server: { port: 5173 },
})