feat: EV Sales Desk console — place a transferring call, watch it live

Two screens over Zino app 583:

  Place a call — lead name, model, and the two numbers that matter: the
  customer Aria dials, and the colleague the transfer bridges to. Submits the
  Add Lead workflow activity as the signed-in user; the workflow's trigger
  places the call server-side, so the zvk_ agent key never reaches the browser.

  Live monitor — polls the leads record view every 3s. "Transferred to human"
  is call_end_reason == handover, called out on its own because it is the thing
  the demo exists to show.

src/api.ts deliberately bypasses two SDK methods; both are SDK bugs:
  - workflows.startWorkflow() posts workflow_id, but core's StartRequest reads
    workflow_uuid and rejects the call.
  - views.getTabularView() does GET /view/recordview, the legacy unscoped route;
    the app-scoped one is POST /app/{id}/view/recordview with the query in the
    body.
Auth still goes through the SDK, so there is one token and one session.

Base URL is the root, not an app path: login is /usr/login while the app APIs
are under /app/583, so they sit at different depths.

Frontgen scaffold generated by the service's own ScaffoldReactProject, so the
build pipeline conventions (relative base, BASE_HREF placeholder, runtime
config.js) are byte-identical rather than reimplemented.

vite build + tsc -b both clean.
This commit is contained in:
Yashas 2026-08-26 22:58:25 +05:30
commit 1669bb755d
32 changed files with 5290 additions and 0 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

75
README.md Normal file
View File

@ -0,0 +1,75 @@
# EV Sales Desk Console
Two-screen operator console for the **live call-transfer demo**: place an
outbound AI sales call, and watch it transfer to a human colleague mid-call.
Backs onto Zino app **583** (org 900000104, `dev`). Built on the frontgen React
scaffold — Vite + Tailwind + React Router + TanStack Query + `src/zino-sdk`.
## The two screens
**Place a call** — lead name, model, and the two numbers: the **customer** Aria
dials, and the **colleague** the transfer bridges to. Submitting starts a
`Lead Outreach` workflow instance; its trigger places the call server-side.
**Live monitor** — polls the leads record view every 3s. `Transferred to human`
is the transfer having fired.
## Running locally
```bash
npm install
echo 'VITE_ZINO_API_URL=https://dev-apps.getzino.in' >> .env
npm run dev
```
Sign in with an app user of app 583 (e.g. `salesops@verta-motors.demo`).
`VITE_ZINO_API_URL` is only read from `.env` in **dev**. A production build
requires the `config.js` the server writes at placement time, and throws if it
is absent — a promoted artifact must never fall back to the build machine's
value and quietly call the wrong backend.
## API notes worth keeping
The base URL is the **root** (`https://dev-apps.getzino.in`), not an app-scoped
path, because login and the app APIs live at different depths:
| Call | Path |
|---|---|
| Login | `POST /usr/login` |
| Start a lead | `POST /app/583/start` |
| Leads list | `POST /app/583/view/recordview` |
**`src/api.ts` bypasses two SDK methods on purpose** — both are SDK bugs, not
preferences:
- `workflows.startWorkflow()` posts `workflow_id`, but core's `StartRequest`
reads **`workflow_uuid`** and rejects the call outright.
- `views.getTabularView()` issues `GET /view/recordview` — the legacy unscoped
route. The app-scoped one is **POST** `/app/{id}/view/recordview` with the
paging/filters in the body.
Auth still goes through the SDK, so there is one token and one session.
## Why the browser holds no API key
Screen 1 submits a workflow activity as the signed-in user. The workflow's own
trigger then places the call using the agent key held in its server-side config.
The `zvk_` key never reaches the browser.
## The per-call transfer number
`handover_number` rides on the lead into the call's `context`, and agents-backend
prefers it over anything configured on the agent — the agent's numbers are the
fallback for calls that don't name one. Requires an agents-backend carrying the
per-call override; without it the field is sent, silently ignored, and the call
transfers to the agent's default number instead.
## Known gap
The live transcript and the co-pilot's suggestions live in **agents-backend**,
which the app-user token does not reach — the SDK talks to the renderer's
workflow/view services. So the monitor shows call *status* live, but not the
turn-by-turn feed. Closing that needs either a new endpoint or the trigger
writing transcript lines onto the instance.

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>EV Sales Desk Console</title>
<script src="./config.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2764
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "ev-sales-desk-console",
"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: {},
},
}

32
src/App.tsx Normal file
View File

@ -0,0 +1,32 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { useZino } from './zino-sdk'
import Layout from './components/Layout'
import Login from './pages/Login'
import PlaceCall from './pages/PlaceCall'
import CallMonitor from './pages/CallMonitor'
export default function App() {
const { client } = useZino()
// The SDK restores its token from localStorage on construction, so a refresh
// keeps the session without a round-trip.
const signedIn = Boolean(client.getToken())
if (!signedIn) {
return (
<Routes>
<Route path="*" element={<Login />} />
</Routes>
)
}
return (
<Layout>
<Routes>
<Route path="/" element={<Navigate to="/place-call" replace />} />
<Route path="/place-call" element={<PlaceCall />} />
<Route path="/monitor" element={<CallMonitor />} />
<Route path="*" element={<Navigate to="/place-call" replace />} />
</Routes>
</Layout>
)
}

112
src/api.ts Normal file
View File

@ -0,0 +1,112 @@
import type { ZinoClient } from './zino-sdk'
import { APP_ID, RECORD_VIEW_ID, WORKFLOW_UUID, ACTIVITY_IDS } from './config'
// Why this file exists rather than using the SDK's WorkflowService/ViewService:
//
// * `workflows.startWorkflow()` posts `workflow_id`, but core's StartRequest
// reads `workflow_uuid` and rejects the call with "workflow_uuid is required".
// * `views.getTabularView()` does GET /view/recordview — the legacy unscoped
// route. The app-scoped one is POST /app/{id}/view/recordview with the
// filters in the body.
//
// Both are SDK bugs worth fixing upstream; until then this layer talks to the
// endpoints as they actually are. Auth still goes through the SDK, so the token
// is shared and there is one session.
/** App-scoped path. The bare paths are the legacy single-app routes. */
const appPath = (p: string) => `/app/${APP_ID}${p}`
export interface LeadRow {
instance_id?: string | number
[key: string]: unknown
}
export interface RecordViewField {
field_key: string
output_label: string
data_type: string
}
export interface LeadsPage {
fields: RecordViewField[]
rows: LeadRow[]
total: number
}
/**
* Submit the INIT activity. The workflow's trigger places the outbound call
* server-side using the agent API key held in its config which is why the
* browser never needs one.
*/
export async function placeCall(
client: ZinoClient,
input: {
leadName: string
leadPhone: string
modelInterest: string
handoverNumber: string
},
): Promise<{ instanceId: string }> {
const res = await client.request<{ success?: boolean; instance_id?: string }>(
'POST',
appPath('/start'),
{
workflow_uuid: WORKFLOW_UUID,
activity_id: ACTIVITY_IDS.ADD_LEAD,
data: {
lead_name: input.leadName,
lead_phone: input.leadPhone,
model_interest: input.modelInterest,
handover_number: input.handoverNumber,
},
},
)
return { instanceId: String(res.instance_id ?? '') }
}
export async function fetchLeads(
client: ZinoClient,
opts: { page?: number; limit?: number; search?: string } = {},
): Promise<LeadsPage> {
const res = await client.request<{
config?: { fields?: RecordViewField[] }
data?: LeadRow[]
pagination?: { total_count?: number }
}>('POST', appPath('/view/recordview'), {
rv_template_uid: RECORD_VIEW_ID,
search_query: {
page: opts.page ?? 1,
limit: opts.limit ?? 50,
search: opts.search ?? '',
sort_by: 'instance_id',
sort_dir: 'desc',
},
})
const rows = Array.isArray(res.data) ? res.data : []
return {
// Only fields the view template declares come back — a workflow field with
// no record-view column is simply absent, never an error.
fields: (res.config?.fields ?? []).filter((f) => f.field_key),
rows,
total: res.pagination?.total_count ?? rows.length,
}
}
/**
* Best-effort E.164. The dialler normalises server-side too, so this is a
* courtesy for the operator rather than the guarantee never the only check.
*/
export function toE164(raw: string): string {
const cleaned = (raw || '').replace(/[^0-9+]/g, '')
if (!cleaned) return ''
if (cleaned.startsWith('+')) return cleaned
if (cleaned.length === 10) return `+91${cleaned}`
if (cleaned.length === 12 && cleaned.startsWith('91')) return `+${cleaned}`
return `+${cleaned}`
}
export function isPlausiblePhone(raw: string): boolean {
const e = toE164(raw)
return /^\+\d{10,15}$/.test(e)
}

88
src/components/Layout.tsx Normal file
View File

@ -0,0 +1,88 @@
import type { ReactNode } from 'react'
import { NavLink } from 'react-router-dom'
import { LogOut, PhoneOutgoing, Radio, Zap } from 'lucide-react'
import { useZino } from '../zino-sdk'
const navItem =
'inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors'
export default function Layout({ children }: { children: ReactNode }) {
const { auth, client } = useZino()
const signOut = () => {
auth.logout()
// The router is inside the auth gate in App, so a reload is the simplest
// way back to the login screen without threading state through context.
window.location.reload()
}
const email = (() => {
try {
const t = client.getToken()
if (!t) return ''
return JSON.parse(atob(t.split('.')[1])).email ?? ''
} catch {
return ''
}
})()
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
<header className="sticky top-0 z-10 border-b border-slate-200 bg-white">
<div className="mx-auto flex max-w-6xl items-center gap-6 px-6 py-3">
<div className="flex items-center gap-2">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-emerald-600 text-white">
<Zap size={17} />
</span>
<div className="leading-tight">
<div className="text-sm font-semibold">Verta Motors</div>
<div className="text-xs text-slate-500">EV Sales Desk</div>
</div>
</div>
<nav className="flex items-center gap-1">
<NavLink
to="/place-call"
className={({ isActive }) =>
`${navItem} ${
isActive
? 'bg-emerald-50 text-emerald-700'
: 'text-slate-600 hover:bg-slate-100'
}`
}
>
<PhoneOutgoing size={16} />
Place a call
</NavLink>
<NavLink
to="/monitor"
className={({ isActive }) =>
`${navItem} ${
isActive
? 'bg-emerald-50 text-emerald-700'
: 'text-slate-600 hover:bg-slate-100'
}`
}
>
<Radio size={16} />
Live monitor
</NavLink>
</nav>
<div className="ml-auto flex items-center gap-3">
{email && <span className="text-xs text-slate-500">{email}</span>}
<button
onClick={signOut}
className="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-100"
>
<LogOut size={14} />
Sign out
</button>
</div>
</div>
</header>
<main className="mx-auto max-w-6xl px-6 py-8">{children}</main>
</div>
)
}

41
src/config.ts Normal file
View File

@ -0,0 +1,41 @@
// Real IDs for the EV Sales Desk app. Never inline these anywhere else.
/** Numeric app id — every core/view call is scoped under /app/{APP_ID}. */
export const APP_ID = 583
/**
* Workflow UID, not the numeric id. `/start` takes `workflow_uuid` and resolves
* it to the runtime integer itself, so the UID is what survives a clone.
*/
export const WORKFLOW_UUID = 'wf-verta-lead-outreach'
export const ACTIVITY_IDS = {
ADD_LEAD: 'act-add-lead', // INIT — submitting this places the call
RECORD_OUTCOME: 'act-outcome', // performed by the post-call webhook, not by us
} as const
export const RECORD_VIEW_ID = 'rv-verta-leads'
/** Field keys as the record view returns them. */
export const FIELDS = {
LEAD_NAME: 'lead_name',
LEAD_PHONE: 'lead_phone',
MODEL_INTEREST: 'model_interest',
/** Per-call transfer destination — overrides the agent's own numbers. */
HANDOVER_NUMBER: 'handover_number',
CALL_STATUS: 'call_status',
CALL_END_REASON: 'call_end_reason',
CALL_ANSWERED: 'call_answered',
CALL_DURATION: 'call_duration_seconds',
CALL_SUMMARY: 'call_summary',
CONTACT_NOTES: 'contact_notes',
} as const
/**
* `call_end_reason` when the AI transferred the caller to a human. This is the
* whole point of the demo, so it gets a name rather than a bare string compare.
*/
export const END_REASON_HANDOVER = 'handover'
/** Models the agent is briefed on. */
export const MODELS = ['Verta One', 'Verta SUV', 'Verta Basic'] as const

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>,
)

270
src/pages/CallMonitor.tsx Normal file
View File

@ -0,0 +1,270 @@
import { useState } from 'react'
import {
CheckCircle2,
CircleDashed,
PhoneForwarded,
PhoneMissed,
RefreshCw,
X,
} from 'lucide-react'
import { useQuery, useZino } from '../zino-sdk'
import { fetchLeads, type LeadRow } from '../api'
import { END_REASON_HANDOVER, FIELDS } from '../config'
const POLL_MS = 3000
export default function CallMonitor() {
const { client } = useZino()
const [selected, setSelected] = useState<LeadRow | null>(null)
const { data, isLoading, isFetching, error, refetch } = useQuery({
queryKey: ['leads'],
queryFn: () => fetchLeads(client, { limit: 50 }),
// The whole point of this screen is watching a call change state, so it
// polls rather than waiting for a manual refresh.
refetchInterval: POLL_MS,
refetchOnWindowFocus: true,
})
const rows = data?.rows ?? []
return (
<div>
<div className="flex items-center gap-3">
<div>
<h1 className="text-xl font-semibold">Live monitor</h1>
<p className="mt-1 text-sm text-slate-500">
Updates every {POLL_MS / 1000}s while a call is in progress.
</p>
</div>
<button
onClick={() => refetch()}
className="ml-auto inline-flex items-center gap-1.5 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-50"
>
<RefreshCw size={13} className={isFetching ? 'animate-spin' : ''} />
Refresh
</button>
</div>
{error && (
<p className="mt-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
{(error as any)?.message || 'Could not load leads'}
</p>
)}
<div className="mt-5 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
<tr>
<Th>Lead</Th>
<Th>Customer</Th>
<Th>Transfers to</Th>
<Th>Outcome</Th>
<Th className="text-right">Duration</Th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{isLoading && (
<tr>
<td colSpan={5} className="px-4 py-10 text-center text-slate-400">
Loading
</td>
</tr>
)}
{!isLoading && rows.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-12 text-center text-slate-400">
No calls yet place one from Place a call.
</td>
</tr>
)}
{rows.map((row, i) => (
<tr
key={String(row.instance_id ?? i)}
onClick={() => setSelected(row)}
className="cursor-pointer transition-colors hover:bg-slate-50"
>
<td className="px-4 py-3">
<div className="font-medium">{str(row[FIELDS.LEAD_NAME]) || '—'}</div>
<div className="text-xs text-slate-500">
{str(row[FIELDS.MODEL_INTEREST])}
</div>
</td>
<td className="px-4 py-3 font-mono text-xs">{str(row[FIELDS.LEAD_PHONE])}</td>
<td className="px-4 py-3 font-mono text-xs">
{str(row[FIELDS.HANDOVER_NUMBER]) || (
<span className="font-sans text-slate-400">agent default</span>
)}
</td>
<td className="px-4 py-3">
<OutcomePill row={row} />
</td>
<td className="px-4 py-3 text-right tabular-nums text-slate-600">
{duration(row[FIELDS.CALL_DURATION])}
</td>
</tr>
))}
</tbody>
</table>
</div>
{selected && <DetailDrawer row={selected} onClose={() => setSelected(null)} />}
</div>
)
}
/**
* The outcome column. `call_end_reason === 'handover'` is the transfer having
* happened it is the thing this whole demo exists to show, so it gets its own
* treatment rather than reading as just another end reason.
*
* Note it means the AI *handed the call over*, not that the conversation
* succeeded the gateway pre-sets that classification before the conference is
* built, so it is a statement about intent, not proof the bridge completed.
*/
function OutcomePill({ row }: { row: LeadRow }) {
const endReason = str(row[FIELDS.CALL_END_REASON])
const status = str(row[FIELDS.CALL_STATUS])
if (endReason === END_REASON_HANDOVER) {
return (
<Pill className="bg-indigo-50 text-indigo-700">
<PhoneForwarded size={12} /> Transferred to human
</Pill>
)
}
if (endReason) {
const bad = /no-answer|busy|voicemail|failed|error|drop/i.test(endReason)
return (
<Pill className={bad ? 'bg-amber-50 text-amber-800' : 'bg-slate-100 text-slate-700'}>
{bad ? <PhoneMissed size={12} /> : <CheckCircle2 size={12} />}
{endReason.replace(/-/g, ' ')}
</Pill>
)
}
if (status && status.startsWith('failed')) {
return (
<Pill className="bg-red-50 text-red-700">
<PhoneMissed size={12} /> {status.replace(/_/g, ' ')}
</Pill>
)
}
if (status === 'dialled') {
return (
<Pill className="bg-emerald-50 text-emerald-700">
<CircleDashed size={12} className="animate-spin" /> On call
</Pill>
)
}
return (
<Pill className="bg-slate-100 text-slate-500">
<CircleDashed size={12} /> {status || 'queued'}
</Pill>
)
}
function DetailDrawer({ row, onClose }: { row: LeadRow; onClose: () => void }) {
const summary = str(row[FIELDS.CALL_SUMMARY])
const notes = str(row[FIELDS.CONTACT_NOTES])
const transferred = str(row[FIELDS.CALL_END_REASON]) === END_REASON_HANDOVER
return (
<div className="fixed inset-0 z-20 flex justify-end bg-slate-900/20" onClick={onClose}>
<aside
onClick={(e) => e.stopPropagation()}
className="h-full w-full max-w-md overflow-y-auto bg-white p-6 shadow-xl"
>
<div className="flex items-start gap-3">
<div>
<h2 className="text-lg font-semibold">{str(row[FIELDS.LEAD_NAME]) || 'Lead'}</h2>
<p className="font-mono text-xs text-slate-500">{str(row[FIELDS.LEAD_PHONE])}</p>
</div>
<button
onClick={onClose}
className="ml-auto rounded-lg p-1.5 text-slate-400 hover:bg-slate-100"
>
<X size={16} />
</button>
</div>
<div className="mt-4">
<OutcomePill row={row} />
</div>
{transferred && (
<div className="mt-4 rounded-xl border border-indigo-100 bg-indigo-50 p-3">
<p className="text-xs font-semibold text-indigo-900">Transferred</p>
<p className="mt-1 text-xs text-indigo-800">
Aria bridged{' '}
<span className="font-mono">{str(row[FIELDS.HANDOVER_NUMBER]) || 'the colleague'}</span>{' '}
into the call and stayed on as a silent listener, briefing them as the
conversation continued.
</p>
</div>
)}
<Section title="Call summary" body={summary} empty="No summary yet — it is written when the call ends." />
<Section title="Notes" body={notes} />
<dl className="mt-6 grid grid-cols-2 gap-3 text-xs">
<Meta label="Model" value={str(row[FIELDS.MODEL_INTEREST])} />
<Meta label="Answered" value={bool(row[FIELDS.CALL_ANSWERED])} />
<Meta label="Duration" value={duration(row[FIELDS.CALL_DURATION])} />
<Meta label="Dial status" value={str(row[FIELDS.CALL_STATUS])} />
</dl>
</aside>
</div>
)
}
// --- small bits ------------------------------------------------------------
function Section({ title, body, empty }: { title: string; body: string; empty?: string }) {
if (!body && !empty) return null
return (
<div className="mt-5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">{title}</h3>
<p className="mt-1.5 whitespace-pre-wrap text-sm text-slate-700">
{body || <span className="text-slate-400">{empty}</span>}
</p>
</div>
)
}
function Meta({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg bg-slate-50 px-3 py-2">
<dt className="text-[11px] text-slate-500">{label}</dt>
<dd className="mt-0.5 font-medium text-slate-800">{value || '—'}</dd>
</div>
)
}
function Pill({ children, className }: { children: React.ReactNode; className: string }) {
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${className}`}
>
{children}
</span>
)
}
function Th({ children, className = '' }: { children: React.ReactNode; className?: string }) {
return <th className={`px-4 py-2.5 font-medium ${className}`}>{children}</th>
}
const str = (v: unknown): string => (v === null || v === undefined ? '' : String(v))
const bool = (v: unknown): string =>
v === true || v === 'true' ? 'Yes' : v === false || v === 'false' ? 'No' : ''
function duration(v: unknown): string {
const n = Number(v)
if (!Number.isFinite(n) || n <= 0) return '—'
const m = Math.floor(n / 60)
const s = n % 60
return m ? `${m}m ${s}s` : `${s}s`
}

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

@ -0,0 +1,75 @@
import { useState } from 'react'
import { Zap } from 'lucide-react'
import { useZino } from '../zino-sdk'
export default function Login() {
const { auth, setUser } = useZino()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const submit = async (e: React.FormEvent) => {
e.preventDefault()
setBusy(true)
setError('')
try {
const res = await auth.login(email.trim(), password)
setUser(res.user ?? auth.getCurrentUser())
window.location.reload()
} catch (err: any) {
setError(err?.message || 'Sign-in failed')
setBusy(false)
}
}
return (
<div className="grid min-h-screen place-items-center bg-slate-50 px-6">
<form
onSubmit={submit}
className="w-full max-w-sm rounded-2xl border border-slate-200 bg-white p-8 shadow-sm"
>
<div className="mb-6 flex items-center gap-2">
<span className="grid h-9 w-9 place-items-center rounded-lg bg-emerald-600 text-white">
<Zap size={18} />
</span>
<div className="leading-tight">
<div className="font-semibold">Verta Motors</div>
<div className="text-xs text-slate-500">EV Sales Desk</div>
</div>
</div>
<label className="mb-1 block text-xs font-medium text-slate-600">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
className="mb-4 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100"
/>
<label className="mb-1 block text-xs font-medium text-slate-600">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="mb-5 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100"
/>
{error && (
<p className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-xs text-red-700">{error}</p>
)}
<button
type="submit"
disabled={busy}
className="w-full rounded-lg bg-emerald-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-emerald-700 disabled:opacity-50"
>
{busy ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
)
}

184
src/pages/PlaceCall.tsx Normal file
View File

@ -0,0 +1,184 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { ArrowRight, PhoneForwarded, PhoneOutgoing, User } from 'lucide-react'
import { useZino } from '../zino-sdk'
import { MODELS } from '../config'
import { isPlausiblePhone, placeCall, toE164 } from '../api'
export default function PlaceCall() {
const { client } = useZino()
const navigate = useNavigate()
const [leadName, setLeadName] = useState('')
const [modelInterest, setModelInterest] = useState<string>(MODELS[0])
const [leadPhone, setLeadPhone] = useState('')
const [handoverNumber, setHandoverNumber] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
// Both numbers joining the same conference would put the tester on hold with
// themselves — catch it here rather than on a live call.
const sameNumber =
leadPhone && handoverNumber && toE164(leadPhone) === toE164(handoverNumber)
const canSubmit =
leadName.trim() &&
isPlausiblePhone(leadPhone) &&
isPlausiblePhone(handoverNumber) &&
!sameNumber &&
!busy
const submit = async (e: React.FormEvent) => {
e.preventDefault()
if (!canSubmit) return
setBusy(true)
setError('')
try {
await placeCall(client, {
leadName: leadName.trim(),
leadPhone: toE164(leadPhone),
modelInterest,
handoverNumber: toE164(handoverNumber),
})
navigate('/monitor')
} catch (err: any) {
setError(err?.message || 'Could not place the call')
setBusy(false)
}
}
return (
<div className="mx-auto max-w-2xl">
<h1 className="text-xl font-semibold">Place a call</h1>
<p className="mt-1 text-sm text-slate-500">
Aria calls the customer and qualifies the lead. When the caller asks for a person
or pushes on price she announces a transfer and bridges the colleague below into
the same call, then keeps listening to brief them.
</p>
<form
onSubmit={submit}
className="mt-6 space-y-5 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"
>
<div className="grid gap-5 sm:grid-cols-2">
<div>
<label className="mb-1 flex items-center gap-1.5 text-xs font-medium text-slate-600">
<User size={13} /> Lead name
</label>
<input
value={leadName}
onChange={(e) => setLeadName(e.target.value)}
placeholder="Yashas"
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">
Model of interest
</label>
<select
value={modelInterest}
onChange={(e) => setModelInterest(e.target.value)}
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100"
>
{MODELS.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</div>
</div>
<div className="grid items-start gap-4 rounded-xl bg-slate-50 p-4 sm:grid-cols-[1fr_auto_1fr]">
<PhoneBox
icon={<PhoneOutgoing size={13} />}
label="Customer — Aria calls this"
value={leadPhone}
onChange={setLeadPhone}
accent="emerald"
/>
<div className="hidden self-center pt-6 text-slate-400 sm:block">
<ArrowRight size={18} />
</div>
<PhoneBox
icon={<PhoneForwarded size={13} />}
label="Colleague — transfer bridges to this"
value={handoverNumber}
onChange={setHandoverNumber}
accent="indigo"
/>
</div>
{sameNumber && (
<p className="rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-800">
Both numbers are the same the transfer would conference you with yourself.
Use two different phones.
</p>
)}
{error && (
<p className="rounded-lg bg-red-50 px-3 py-2 text-xs text-red-700">{error}</p>
)}
<div className="flex items-center gap-3 pt-1">
<button
type="submit"
disabled={!canSubmit}
className="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-40"
>
<PhoneOutgoing size={16} />
{busy ? 'Placing call…' : 'Place call'}
</button>
<span className="text-xs text-slate-500">
The customer's phone rings within a few seconds.
</span>
</div>
</form>
</div>
)
}
function PhoneBox({
icon,
label,
value,
onChange,
accent,
}: {
icon: React.ReactNode
label: string
value: string
onChange: (v: string) => void
accent: 'emerald' | 'indigo'
}) {
const normalised = toE164(value)
const valid = isPlausiblePhone(value)
const ring =
accent === 'emerald'
? 'focus:border-emerald-500 focus:ring-emerald-100'
: 'focus:border-indigo-500 focus:ring-indigo-100'
return (
<div>
<label className="mb-1 flex items-center gap-1.5 text-xs font-medium text-slate-600">
{icon} {label}
</label>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="+91 98765 43210"
inputMode="tel"
className={`w-full rounded-lg border border-slate-300 bg-white px-3 py-2 font-mono text-sm outline-none focus:ring-2 ${ring}`}
/>
{/* Show what will actually be dialled the operator types loosely, the
carrier needs E.164, and a silent rewrite is worth surfacing. */}
<p className="mt-1 h-4 text-[11px] text-slate-500">
{value && (valid ? `Dials as ${normalised}` : 'Needs 10 digits, or +country code')}
</p>
</div>
)
}

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 },
})