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:
commit
1669bb755d
8
.env.example
Normal file
8
.env.example
Normal 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
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.local
|
||||||
75
README.md
Normal file
75
README.md
Normal 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
14
index.html
Normal 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
2764
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "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
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
32
src/App.tsx
Normal file
32
src/App.tsx
Normal 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
112
src/api.ts
Normal 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
88
src/components/Layout.tsx
Normal 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
41
src/config.ts
Normal 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
3
src/index.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
17
src/main.tsx
Normal file
17
src/main.tsx
Normal 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
270
src/pages/CallMonitor.tsx
Normal 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
75
src/pages/Login.tsx
Normal 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
184
src/pages/PlaceCall.tsx
Normal 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
35
src/runtimeConfig.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
175
src/zino-sdk/DynamicForm.tsx
Normal file
175
src/zino-sdk/DynamicForm.tsx
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useZino } from "./provider";
|
||||||
|
import type { FormField } from "./types";
|
||||||
|
|
||||||
|
export interface DynamicFormProps {
|
||||||
|
/** Workflow definition ID. */
|
||||||
|
workflowId: string;
|
||||||
|
/** Activity UID — the form schema is fetched for this activity. */
|
||||||
|
activityId: string;
|
||||||
|
/** Instance ID — omit for INIT activities (start workflow). */
|
||||||
|
instanceId?: string;
|
||||||
|
/** Called after successful submission with the result. */
|
||||||
|
onSuccess?: (result: unknown) => void;
|
||||||
|
/** Called on submission error. */
|
||||||
|
onError?: (error: unknown) => void;
|
||||||
|
/** Optional CSS class for the form wrapper. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a dynamic form based on the activity's form schema.
|
||||||
|
*
|
||||||
|
* Fetches the form config from the core-service, renders fields by type,
|
||||||
|
* and submits via performActivity or startWorkflow.
|
||||||
|
*
|
||||||
|
* ```tsx
|
||||||
|
* <DynamicForm
|
||||||
|
* workflowId={WORKFLOW_ID}
|
||||||
|
* activityId={ACTIVITY_IDS.ASSIGN_TICKET}
|
||||||
|
* instanceId={instanceId}
|
||||||
|
* onSuccess={() => { closeModal(); }}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function DynamicForm({
|
||||||
|
workflowId,
|
||||||
|
activityId,
|
||||||
|
instanceId,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
className,
|
||||||
|
}: DynamicFormProps) {
|
||||||
|
const { workflows } = useZino();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [formData, setFormData] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// Fetch form schema
|
||||||
|
const { data: form, isLoading: formLoading, error: formError } = useQuery({
|
||||||
|
queryKey: ["form", workflowId, activityId, instanceId],
|
||||||
|
queryFn: () => workflows.getForm(workflowId, activityId, "desktop", instanceId),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Submit mutation
|
||||||
|
const submit = useMutation({
|
||||||
|
mutationFn: async (data: Record<string, string>) => {
|
||||||
|
if (instanceId) {
|
||||||
|
return workflows.performActivity(workflowId, instanceId, activityId, data);
|
||||||
|
} else {
|
||||||
|
return workflows.startWorkflow(workflowId, activityId, data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: (result) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["recordview"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["instance"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["detailview"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["audit"] });
|
||||||
|
onSuccess?.(result);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
onError?.(err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleChange = (fieldId: string, value: string) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [fieldId]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
// Map form field IDs to their mapped_activity_field_id
|
||||||
|
const mapped: Record<string, string> = {};
|
||||||
|
for (const field of form?.form_json?.fields ?? []) {
|
||||||
|
const key = field.mapped_activity_field_id || field.id;
|
||||||
|
mapped[key] = formData[field.id] ?? "";
|
||||||
|
}
|
||||||
|
submit.mutate(mapped);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (formLoading) {
|
||||||
|
return <div className="flex justify-center py-8"><div className="animate-spin h-6 w-6 border-2 border-blue-500 border-t-transparent rounded-full" /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formError) {
|
||||||
|
return <div className="text-red-600 text-sm py-4">Failed to load form: {(formError as any)?.message ?? "Unknown error"}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields: FormField[] = form?.form_json?.fields ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className={className}>
|
||||||
|
{form?.form_json?.title && (
|
||||||
|
<h3 className="text-lg font-semibold mb-4">{form.form_json.title}</h3>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{fields.map((field) => (
|
||||||
|
<div key={field.id}>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{field.label}
|
||||||
|
</label>
|
||||||
|
{renderField(field, formData[field.id] ?? "", (v) => handleChange(field.id, v))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{submit.error && (
|
||||||
|
<div className="mt-3 text-sm text-red-600">
|
||||||
|
{(submit.error as any)?.message ?? "Submission failed"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submit.isPending}
|
||||||
|
className="mt-6 w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium py-2.5 px-4 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{submit.isPending ? "Submitting..." : "Submit"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderField(
|
||||||
|
field: FormField,
|
||||||
|
value: string,
|
||||||
|
onChange: (value: string) => void,
|
||||||
|
) {
|
||||||
|
const baseClass =
|
||||||
|
"w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent";
|
||||||
|
|
||||||
|
switch (field.type) {
|
||||||
|
case "paragraph":
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
className={baseClass}
|
||||||
|
placeholder={field.label}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "number":
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className={baseClass}
|
||||||
|
placeholder={field.label}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "text":
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className={baseClass}
|
||||||
|
placeholder={field.label}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/zino-sdk/auth.ts
Normal file
64
src/zino-sdk/auth.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import type { ZinoClient } from "./client";
|
||||||
|
import type { LoginResponse, User } from "./types";
|
||||||
|
import { mockDelay, MOCK_USER } from "./mock";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication methods — login, logout, and JWT decoding.
|
||||||
|
* Maps to user-service endpoints.
|
||||||
|
*/
|
||||||
|
export class AuthService {
|
||||||
|
private client: ZinoClient;
|
||||||
|
constructor(client: ZinoClient) {
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
orgId?: string,
|
||||||
|
): Promise<LoginResponse> {
|
||||||
|
if (this.client.isMock) {
|
||||||
|
await mockDelay();
|
||||||
|
const res: LoginResponse = {
|
||||||
|
token: "mock-jwt-token-" + Date.now(),
|
||||||
|
user: { ...MOCK_USER },
|
||||||
|
};
|
||||||
|
this.client.setToken(res.token);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await this.client.request<LoginResponse>("POST", "/usr/login", {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
...(orgId ? { org_id: orgId } : {}),
|
||||||
|
});
|
||||||
|
this.client.setToken(res.token);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
logout(): void {
|
||||||
|
this.client.setToken(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentUser(): User | null {
|
||||||
|
if (this.client.isMock) return { ...MOCK_USER };
|
||||||
|
|
||||||
|
const token = this.client.getToken();
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||||
|
return {
|
||||||
|
id: payload.user_id ?? payload.sub,
|
||||||
|
org_id: payload.org_id ?? "",
|
||||||
|
name: payload.name ?? "",
|
||||||
|
email: payload.email ?? "",
|
||||||
|
roles: payload.roles ?? [],
|
||||||
|
groups: payload.groups ?? [],
|
||||||
|
role_assignments: payload.role_assignments ?? [],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/zino-sdk/client.ts
Normal file
86
src/zino-sdk/client.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import type { ApiError, ZinoClientConfig } from "./types";
|
||||||
|
|
||||||
|
const TOKEN_KEY = "zino_token";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core HTTP client for the Zino API.
|
||||||
|
*
|
||||||
|
* Persists JWT in localStorage so sessions survive page refresh.
|
||||||
|
*/
|
||||||
|
export class ZinoClient {
|
||||||
|
readonly baseUrl: string;
|
||||||
|
private token: string | null = null;
|
||||||
|
private onAuthError?: () => void;
|
||||||
|
|
||||||
|
constructor(config: ZinoClientConfig) {
|
||||||
|
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
||||||
|
this.onAuthError = config.onAuthError;
|
||||||
|
// Restore token from localStorage on init
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
this.token = localStorage.getItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get isMock(): boolean {
|
||||||
|
return this.baseUrl === "mock";
|
||||||
|
}
|
||||||
|
|
||||||
|
setToken(token: string | null): void {
|
||||||
|
this.token = token;
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getToken(): string | null {
|
||||||
|
return this.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async request<T>(
|
||||||
|
method: "GET" | "POST" | "PUT" | "DELETE",
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${this.baseUrl}${path}`;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.token) {
|
||||||
|
headers["Authorization"] = `Bearer ${this.token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
this.token = null;
|
||||||
|
this.onAuthError?.();
|
||||||
|
const err: ApiError = { status: 401, message: "Unauthorized" };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = res.statusText;
|
||||||
|
try {
|
||||||
|
const errBody = (await res.json()) as { error?: string };
|
||||||
|
if (errBody.error) message = errBody.error;
|
||||||
|
} catch {
|
||||||
|
// body wasn't JSON
|
||||||
|
}
|
||||||
|
const err: ApiError = { status: res.status, message };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/zino-sdk/index.ts
Normal file
61
src/zino-sdk/index.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Zino Service SDK — Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Core client
|
||||||
|
export { ZinoClient } from "./client";
|
||||||
|
|
||||||
|
// Service modules
|
||||||
|
export { AuthService } from "./auth";
|
||||||
|
export { WorkflowService } from "./workflow";
|
||||||
|
export { ViewService } from "./views";
|
||||||
|
|
||||||
|
// React bindings
|
||||||
|
export { ZinoProvider, useZino, queryClient } from "./provider";
|
||||||
|
|
||||||
|
// Components
|
||||||
|
export { DynamicForm } from "./DynamicForm";
|
||||||
|
|
||||||
|
// Re-export TanStack Query hooks for convenience
|
||||||
|
export { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type {
|
||||||
|
User,
|
||||||
|
RoleAssignment,
|
||||||
|
LoginRequest,
|
||||||
|
LoginResponse,
|
||||||
|
App,
|
||||||
|
WorkflowDefinition,
|
||||||
|
State,
|
||||||
|
Transition,
|
||||||
|
Activity,
|
||||||
|
ActivityDataField,
|
||||||
|
AllowedRole,
|
||||||
|
WorkflowDataField,
|
||||||
|
GridColumn,
|
||||||
|
DataType,
|
||||||
|
StageGate,
|
||||||
|
StageGateRuleSet,
|
||||||
|
SGRuleOutcome,
|
||||||
|
WorkflowInstance,
|
||||||
|
ActivityLogEntry,
|
||||||
|
FormFieldType,
|
||||||
|
FormField,
|
||||||
|
FormConfig,
|
||||||
|
ActivityForm,
|
||||||
|
ViewField,
|
||||||
|
ViewFieldType,
|
||||||
|
ActionConfig,
|
||||||
|
Column,
|
||||||
|
SortDir,
|
||||||
|
TabularViewParams,
|
||||||
|
TabularViewResponse,
|
||||||
|
ActivityEntry,
|
||||||
|
InstanceReport,
|
||||||
|
DetailViewResponse,
|
||||||
|
ReportSection,
|
||||||
|
StateTransition,
|
||||||
|
ZinoClientConfig,
|
||||||
|
ApiError,
|
||||||
|
} from "./types";
|
||||||
243
src/zino-sdk/mock.ts
Normal file
243
src/zino-sdk/mock.ts
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
import type {
|
||||||
|
ActivityForm,
|
||||||
|
ActivityLogEntry,
|
||||||
|
Column,
|
||||||
|
InstanceReport,
|
||||||
|
ReportSection,
|
||||||
|
StateTransition,
|
||||||
|
TabularViewParams,
|
||||||
|
TabularViewResponse,
|
||||||
|
User,
|
||||||
|
WorkflowDefinition,
|
||||||
|
WorkflowInstance,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export function mockDelay(): Promise<void> {
|
||||||
|
const ms = 200 + Math.random() * 200;
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomId(): string {
|
||||||
|
return Math.random().toString(36).slice(2, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoNow(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysAgo(n: number): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - n);
|
||||||
|
return d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MOCK_USER: User = {
|
||||||
|
id: "usr-001",
|
||||||
|
org_id: "org-acme",
|
||||||
|
name: "Jane Doe",
|
||||||
|
email: "jane@acme.corp",
|
||||||
|
roles: ["admin", "reviewer"],
|
||||||
|
groups: ["engineering"],
|
||||||
|
role_assignments: [
|
||||||
|
{ role_id: "admin", positions: ["manager"] },
|
||||||
|
{ role_id: "reviewer" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MOCK_WORKFLOW_DEF: WorkflowDefinition = {
|
||||||
|
workflow_id: "wf-support-ticket",
|
||||||
|
version: 1,
|
||||||
|
name: "Support Ticket",
|
||||||
|
states: [
|
||||||
|
{ uid: "s-new", name: "New", type: "initial", allowed_activities: ["a-init"] },
|
||||||
|
{ uid: "s-open", name: "Open", allowed_activities: ["a-assign", "a-close"] },
|
||||||
|
{ uid: "s-in-progress", name: "In Progress", allowed_activities: ["a-resolve", "a-escalate"] },
|
||||||
|
{ uid: "s-resolved", name: "Resolved", allowed_activities: ["a-reopen", "a-close"] },
|
||||||
|
{ uid: "s-closed", name: "Closed", type: "terminal", allowed_activities: [] },
|
||||||
|
],
|
||||||
|
activities: [
|
||||||
|
{ uid: "a-init", name: "Create Ticket", type: "INIT", data_fields: [
|
||||||
|
{ id: "f-title", name: "title", type: "local", data_type: "text", mandatory: true },
|
||||||
|
{ id: "f-desc", name: "description", type: "local", data_type: "longtext", mandatory: false },
|
||||||
|
{ id: "f-priority", name: "priority", type: "local", data_type: "text", mandatory: true },
|
||||||
|
] },
|
||||||
|
{ uid: "a-assign", name: "Assign Agent", type: "USER", data_fields: [
|
||||||
|
{ id: "f-agent", name: "assigned_to", type: "local", data_type: "text", mandatory: true },
|
||||||
|
] },
|
||||||
|
{ uid: "a-resolve", name: "Resolve", type: "USER", data_fields: [
|
||||||
|
{ id: "f-resolution", name: "resolution_notes", type: "local", data_type: "longtext", mandatory: true },
|
||||||
|
] },
|
||||||
|
{ uid: "a-escalate", name: "Escalate", type: "USER" },
|
||||||
|
{ uid: "a-reopen", name: "Reopen", type: "USER" },
|
||||||
|
{ uid: "a-close", name: "Close", type: "USER" },
|
||||||
|
],
|
||||||
|
transitions: [
|
||||||
|
{ from_state_id: "s-new", by_activity_id: "a-init", transition_type: "direct", to_state_id: "s-open" },
|
||||||
|
{ from_state_id: "s-open", by_activity_id: "a-assign", transition_type: "direct", to_state_id: "s-in-progress" },
|
||||||
|
{ from_state_id: "s-in-progress", by_activity_id: "a-resolve", transition_type: "direct", to_state_id: "s-resolved" },
|
||||||
|
{ from_state_id: "s-in-progress", by_activity_id: "a-escalate", transition_type: "direct", to_state_id: "s-open" },
|
||||||
|
{ from_state_id: "s-resolved", by_activity_id: "a-reopen", transition_type: "direct", to_state_id: "s-open" },
|
||||||
|
{ from_state_id: "s-resolved", by_activity_id: "a-close", transition_type: "direct", to_state_id: "s-closed" },
|
||||||
|
{ from_state_id: "s-open", by_activity_id: "a-close", transition_type: "direct", to_state_id: "s-closed" },
|
||||||
|
],
|
||||||
|
workflow_data_fields: [
|
||||||
|
{ id: "wdf-title", uid: "wdf-title", name: "title", data_type: "text" },
|
||||||
|
{ id: "wdf-desc", uid: "wdf-desc", name: "description", data_type: "longtext" },
|
||||||
|
{ id: "wdf-priority", uid: "wdf-priority", name: "priority", data_type: "text" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const NAMES = ["Alice Johnson", "Bob Ramirez", "Chandra Patel", "Dina Sokolov", "Emeka Obi"];
|
||||||
|
const PRIORITIES = ["High", "Medium", "Low"];
|
||||||
|
const STATUSES = ["Open", "In Progress", "Resolved", "Closed"];
|
||||||
|
const TITLES = [
|
||||||
|
"Login page not loading",
|
||||||
|
"Payment gateway timeout",
|
||||||
|
"Dashboard chart misaligned",
|
||||||
|
"Export CSV returns empty file",
|
||||||
|
"Password reset email delayed",
|
||||||
|
"Mobile sidebar overlaps content",
|
||||||
|
"Search returns stale results",
|
||||||
|
"Role permissions not syncing",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function mockWorkflowInstance(workflowId: string, instanceId?: string): WorkflowInstance {
|
||||||
|
return {
|
||||||
|
instance_id: instanceId ?? `inst-${randomId()}`,
|
||||||
|
workflow_id: workflowId,
|
||||||
|
workflow_version: 1,
|
||||||
|
current_state_id: "s-open",
|
||||||
|
data: {
|
||||||
|
title: TITLES[Math.floor(Math.random() * TITLES.length)],
|
||||||
|
priority: PRIORITIES[Math.floor(Math.random() * PRIORITIES.length)],
|
||||||
|
description: "Detailed description of the reported issue.",
|
||||||
|
},
|
||||||
|
created_at: daysAgo(3),
|
||||||
|
updated_at: isoNow(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mockActivityForm(workflowId: string, activityId: string): ActivityForm {
|
||||||
|
return {
|
||||||
|
id: `form-${randomId()}`,
|
||||||
|
workflow_id: workflowId,
|
||||||
|
activity_id: activityId,
|
||||||
|
form_id: `form-${activityId}`,
|
||||||
|
device_type: "desktop",
|
||||||
|
form_json: {
|
||||||
|
title: activityId === "a-init" ? "Create Ticket" : "Update Ticket",
|
||||||
|
fields: [
|
||||||
|
{ id: "f1", label: "Title", type: "text", mapped_activity_field_id: "f-title" },
|
||||||
|
{ id: "f2", label: "Description", type: "paragraph", mapped_activity_field_id: "f-desc" },
|
||||||
|
{ id: "f3", label: "Priority", type: "text", mapped_activity_field_id: "f-priority" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
created_at: daysAgo(30),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mockTabularResponse(params: TabularViewParams): TabularViewResponse {
|
||||||
|
const pageSize = params.pageSize ?? 10;
|
||||||
|
const page = params.page ?? 1;
|
||||||
|
const total = 47;
|
||||||
|
|
||||||
|
const columns: Column[] = [
|
||||||
|
{ key: "instance_id", label: "ID", data_type: "text", sortable: true, filterable: false, searchable: true },
|
||||||
|
{ key: "title", label: "Title", data_type: "text", sortable: true, filterable: false, searchable: true },
|
||||||
|
{ key: "priority", label: "Priority", data_type: "text", sortable: true, filterable: true, searchable: false },
|
||||||
|
{ key: "status", label: "Status", data_type: "text", sortable: true, filterable: true, searchable: false },
|
||||||
|
{ key: "assigned_to", label: "Assigned To", data_type: "text", sortable: true, filterable: true, searchable: true },
|
||||||
|
{ key: "created_at", label: "Created", data_type: "text", sortable: true, filterable: false, searchable: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows: Record<string, unknown>[] = [];
|
||||||
|
const count = Math.min(pageSize, total - (page - 1) * pageSize);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const idx = (page - 1) * pageSize + i;
|
||||||
|
rows.push({
|
||||||
|
instance_id: `TICKET-${1000 + idx}`,
|
||||||
|
title: TITLES[idx % TITLES.length],
|
||||||
|
priority: PRIORITIES[idx % PRIORITIES.length],
|
||||||
|
status: STATUSES[idx % STATUSES.length],
|
||||||
|
assigned_to: NAMES[idx % NAMES.length],
|
||||||
|
created_at: daysAgo(total - idx),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rows, total, page, columns };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mockInstanceReport(instanceId: string): {
|
||||||
|
report: InstanceReport;
|
||||||
|
sections: ReportSection[];
|
||||||
|
history: StateTransition[];
|
||||||
|
} {
|
||||||
|
const report: InstanceReport = {
|
||||||
|
workflow_id: "wf-support-ticket",
|
||||||
|
instance_id: instanceId,
|
||||||
|
workflow_version: 1,
|
||||||
|
current_state_id: "s-in-progress",
|
||||||
|
current_state_name: "In Progress",
|
||||||
|
global_data: {
|
||||||
|
title: "Payment gateway timeout",
|
||||||
|
description: "Users report intermittent 504 errors during checkout.",
|
||||||
|
priority: "High",
|
||||||
|
assigned_to: "Bob Ramirez",
|
||||||
|
},
|
||||||
|
activities: [
|
||||||
|
{ activity_id: "a-init", data: { title: "Payment gateway timeout" }, activity_performed_at: daysAgo(5), performed_by_id: "usr-001" },
|
||||||
|
{ activity_id: "a-assign", data: { assigned_to: "Bob Ramirez" }, activity_performed_at: daysAgo(4), performed_by_id: "usr-001" },
|
||||||
|
],
|
||||||
|
created_at: daysAgo(5),
|
||||||
|
updated_at: daysAgo(4),
|
||||||
|
};
|
||||||
|
|
||||||
|
const sections: ReportSection[] = [
|
||||||
|
{
|
||||||
|
title: "Instance Details",
|
||||||
|
fields: [
|
||||||
|
{ label: "Instance ID", value: instanceId },
|
||||||
|
{ label: "Workflow", value: "Support Ticket" },
|
||||||
|
{ label: "Current State", value: "In Progress" },
|
||||||
|
{ label: "Created", value: report.created_at },
|
||||||
|
{ label: "Updated", value: report.updated_at },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Data",
|
||||||
|
fields: Object.entries(report.global_data).map(([k, v]) => ({ label: k, value: v })),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const history: StateTransition[] = [
|
||||||
|
{ from_state: "New", to_state: "Open", activity_name: "Create Ticket", performed_by: "Jane Doe", timestamp: daysAgo(5), data: {} },
|
||||||
|
{ from_state: "Open", to_state: "In Progress", activity_name: "Assign Agent", performed_by: "Jane Doe", timestamp: daysAgo(4), data: { assigned_to: "Bob Ramirez" } },
|
||||||
|
];
|
||||||
|
|
||||||
|
return { report, sections, history };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mockAuditLog(): ActivityLogEntry[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
user_id: "usr-001",
|
||||||
|
user_roles: ["admin"],
|
||||||
|
user_groups: ["engineering"],
|
||||||
|
activity_id: "a-init",
|
||||||
|
data: { title: "Payment gateway timeout", priority: "High" },
|
||||||
|
execution_state: "s-open",
|
||||||
|
created_at: daysAgo(5),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
user_id: "usr-001",
|
||||||
|
user_roles: ["admin"],
|
||||||
|
user_groups: ["engineering"],
|
||||||
|
activity_id: "a-assign",
|
||||||
|
data: { assigned_to: "Bob Ramirez" },
|
||||||
|
execution_state: "s-in-progress",
|
||||||
|
created_at: daysAgo(4),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
143
src/zino-sdk/provider.tsx
Normal file
143
src/zino-sdk/provider.tsx
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { ZinoClient } from "./client";
|
||||||
|
import { AuthService } from "./auth";
|
||||||
|
import { WorkflowService } from "./workflow";
|
||||||
|
import { ViewService } from "./views";
|
||||||
|
import type { User } from "./types";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Context
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ZinoContextValue {
|
||||||
|
client: ZinoClient;
|
||||||
|
auth: AuthService;
|
||||||
|
workflows: WorkflowService;
|
||||||
|
views: ViewService;
|
||||||
|
user: User | null;
|
||||||
|
setUser: (user: User | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZinoContext = createContext<ZinoContextValue | null>(null);
|
||||||
|
|
||||||
|
const MOCK_SESSION_KEY = "zino_mock_session";
|
||||||
|
|
||||||
|
// Shared QueryClient — sensible defaults for generated apps.
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000, // 30s before refetch
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Provider
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ZinoProviderProps {
|
||||||
|
baseUrl: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap your app with `<ZinoProvider>` to get access to Zino services
|
||||||
|
* and TanStack Query throughout the component tree.
|
||||||
|
*
|
||||||
|
* ```tsx
|
||||||
|
* <ZinoProvider baseUrl="http://localhost:8091">
|
||||||
|
* <App />
|
||||||
|
* </ZinoProvider>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function ZinoProvider({ baseUrl, children }: ZinoProviderProps) {
|
||||||
|
const [user, setUserState] = useState<User | null>(null);
|
||||||
|
|
||||||
|
const viteMock = import.meta.env.VITE_ZINO_MOCK === "true";
|
||||||
|
const resolvedUrl = baseUrl === "mock" || viteMock ? "mock" : baseUrl;
|
||||||
|
const isMock = resolvedUrl === "mock";
|
||||||
|
|
||||||
|
const setUser = useMemo(() => (u: User | null) => {
|
||||||
|
setUserState(u);
|
||||||
|
if (isMock) {
|
||||||
|
if (u) {
|
||||||
|
sessionStorage.setItem(MOCK_SESSION_KEY, "1");
|
||||||
|
} else {
|
||||||
|
sessionStorage.removeItem(MOCK_SESSION_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isMock]);
|
||||||
|
|
||||||
|
// Restore session on mount — from localStorage token or mock sessionStorage.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMock && sessionStorage.getItem(MOCK_SESSION_KEY)) {
|
||||||
|
const authSvc = new AuthService(new ZinoClient({ baseUrl: "mock" }));
|
||||||
|
const mockUser = authSvc.getCurrentUser();
|
||||||
|
if (mockUser) setUserState(mockUser);
|
||||||
|
} else {
|
||||||
|
// Real mode — check if a token exists in localStorage
|
||||||
|
const client = new ZinoClient({ baseUrl: resolvedUrl });
|
||||||
|
if (client.getToken()) {
|
||||||
|
const authSvc = new AuthService(client);
|
||||||
|
const restored = authSvc.getCurrentUser();
|
||||||
|
if (restored) setUserState(restored);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<ZinoContextValue>(() => {
|
||||||
|
const client = new ZinoClient({
|
||||||
|
baseUrl: resolvedUrl,
|
||||||
|
onAuthError: () => setUser(null),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
auth: new AuthService(client),
|
||||||
|
workflows: new WorkflowService(client),
|
||||||
|
views: new ViewService(client),
|
||||||
|
user,
|
||||||
|
setUser,
|
||||||
|
};
|
||||||
|
}, [resolvedUrl, setUser]);
|
||||||
|
|
||||||
|
const ctx = useMemo(
|
||||||
|
() => ({ ...value, user, setUser }),
|
||||||
|
[value, user, setUser],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ZinoContext.Provider value={ctx}>{children}</ZinoContext.Provider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hook
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Access Zino services. Use with TanStack Query:
|
||||||
|
*
|
||||||
|
* ```tsx
|
||||||
|
* const { views } = useZino();
|
||||||
|
* const { data } = useQuery({
|
||||||
|
* queryKey: ['recordview', rvId],
|
||||||
|
* queryFn: () => views.getTabularView(rvId, { page: 1 }),
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useZino(): ZinoContextValue {
|
||||||
|
const ctx = useContext(ZinoContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useZino must be used within a <ZinoProvider>");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-export queryClient for direct invalidation in mutations. */
|
||||||
|
export { queryClient };
|
||||||
285
src/zino-sdk/types.ts
Normal file
285
src/zino-sdk/types.ts
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Zino Service SDK — TypeScript Interfaces
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ---- Auth & User ----
|
||||||
|
|
||||||
|
export interface RoleAssignment {
|
||||||
|
role_id: string;
|
||||||
|
positions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
org_id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
roles: string[];
|
||||||
|
groups: string[];
|
||||||
|
role_assignments: RoleAssignment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
org_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
token: string;
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface App {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
org_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Workflow Definition ----
|
||||||
|
|
||||||
|
export interface AllowedRole {
|
||||||
|
role: string;
|
||||||
|
positions?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface State {
|
||||||
|
uid: string;
|
||||||
|
name: string;
|
||||||
|
type?: string;
|
||||||
|
allowed_activities: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Transition {
|
||||||
|
from_state_id: string;
|
||||||
|
by_activity_id?: string;
|
||||||
|
by_outcome?: string;
|
||||||
|
transition_type: string;
|
||||||
|
to_state_id?: string;
|
||||||
|
stage_gate_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DataType = "text" | "longtext" | "number" | "grid";
|
||||||
|
|
||||||
|
export interface GridColumn {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
data_type: DataType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowDataField {
|
||||||
|
id: string;
|
||||||
|
uid: string;
|
||||||
|
name: string;
|
||||||
|
data_type: DataType;
|
||||||
|
columns?: GridColumn[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityDataField {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: "local" | "mapped";
|
||||||
|
data_type?: DataType;
|
||||||
|
mandatory: boolean;
|
||||||
|
mapped_workflow_field?: string;
|
||||||
|
columns?: GridColumn[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Activity {
|
||||||
|
uid: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
allowed_roles?: AllowedRole[];
|
||||||
|
allowed_groups?: string[];
|
||||||
|
dynamic_rbac_allowed?: boolean;
|
||||||
|
only_roles_positions?: boolean;
|
||||||
|
data_fields?: ActivityDataField[];
|
||||||
|
trigger_uid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StageGate {
|
||||||
|
id: string;
|
||||||
|
rules_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SGRuleOutcome {
|
||||||
|
state_id: string;
|
||||||
|
sg_rule_config?: {
|
||||||
|
condition_field?: string;
|
||||||
|
condition_operator?: string;
|
||||||
|
condition_value?: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StageGateRuleSet {
|
||||||
|
id: string;
|
||||||
|
default_state: string;
|
||||||
|
other_states: SGRuleOutcome[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowDefinition {
|
||||||
|
workflow_id: string;
|
||||||
|
version: number;
|
||||||
|
name: string;
|
||||||
|
states: State[];
|
||||||
|
activities: Activity[];
|
||||||
|
transitions: Transition[];
|
||||||
|
workflow_data_fields: WorkflowDataField[];
|
||||||
|
stage_gates?: StageGate[];
|
||||||
|
sg_rules?: StageGateRuleSet[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Workflow Instances ----
|
||||||
|
|
||||||
|
export interface WorkflowInstance {
|
||||||
|
instance_id: string;
|
||||||
|
workflow_id: string;
|
||||||
|
workflow_version?: number;
|
||||||
|
current_state_id: string;
|
||||||
|
current_state_name?: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityLogEntry {
|
||||||
|
id: number;
|
||||||
|
user_id: string;
|
||||||
|
user_roles: string[];
|
||||||
|
user_groups: string[];
|
||||||
|
activity_id: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
execution_state: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Forms ----
|
||||||
|
|
||||||
|
export type FormFieldType = "text" | "paragraph" | "number";
|
||||||
|
|
||||||
|
export interface FormField {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type: FormFieldType;
|
||||||
|
mapped_activity_field_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormConfig {
|
||||||
|
title: string;
|
||||||
|
fields: FormField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityForm {
|
||||||
|
id: string;
|
||||||
|
workflow_id: string;
|
||||||
|
activity_id: string;
|
||||||
|
form_id: string;
|
||||||
|
device_type: string;
|
||||||
|
form_json: FormConfig;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Views ----
|
||||||
|
|
||||||
|
export interface ActionConfig {
|
||||||
|
label: string;
|
||||||
|
activity_uid: string;
|
||||||
|
workflow_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ViewFieldType =
|
||||||
|
| "global"
|
||||||
|
| "local"
|
||||||
|
| "system-global"
|
||||||
|
| "system-local"
|
||||||
|
| "action";
|
||||||
|
|
||||||
|
export interface ViewField {
|
||||||
|
type: ViewFieldType;
|
||||||
|
field_key: string;
|
||||||
|
output_label: string;
|
||||||
|
activity_id?: string;
|
||||||
|
action?: ActionConfig;
|
||||||
|
data_type: string;
|
||||||
|
is_filter: boolean;
|
||||||
|
is_search: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Column {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
data_type: string;
|
||||||
|
sortable: boolean;
|
||||||
|
filterable: boolean;
|
||||||
|
searchable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SortDir = "asc" | "desc";
|
||||||
|
|
||||||
|
export interface TabularViewParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
sortBy?: string;
|
||||||
|
sortDir?: SortDir;
|
||||||
|
filters?: Record<string, string>;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabularViewResponse {
|
||||||
|
rows: Record<string, unknown>[];
|
||||||
|
total: number;
|
||||||
|
totalPages?: number;
|
||||||
|
page: number;
|
||||||
|
columns: Column[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityEntry {
|
||||||
|
activity_id: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
activity_performed_at: string;
|
||||||
|
performed_by_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstanceReport {
|
||||||
|
workflow_id: string;
|
||||||
|
instance_id: string;
|
||||||
|
workflow_version: number;
|
||||||
|
current_state_id: string;
|
||||||
|
current_state_name: string;
|
||||||
|
global_data: Record<string, unknown>;
|
||||||
|
activities: ActivityEntry[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StateTransition {
|
||||||
|
from_state: string;
|
||||||
|
to_state: string;
|
||||||
|
activity_name: string;
|
||||||
|
performed_by: string;
|
||||||
|
timestamp: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportSection {
|
||||||
|
title: string;
|
||||||
|
fields: { label: string; value: unknown }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetailViewResponse {
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
sections: ReportSection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- SDK-internal ----
|
||||||
|
|
||||||
|
export interface ZinoClientConfig {
|
||||||
|
baseUrl: string;
|
||||||
|
onAuthError?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
status: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
197
src/zino-sdk/views.ts
Normal file
197
src/zino-sdk/views.ts
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
import type { ZinoClient } from "./client";
|
||||||
|
import type {
|
||||||
|
ActivityLogEntry,
|
||||||
|
Column,
|
||||||
|
DetailViewResponse,
|
||||||
|
InstanceReport,
|
||||||
|
ReportSection,
|
||||||
|
StateTransition,
|
||||||
|
TabularViewParams,
|
||||||
|
TabularViewResponse,
|
||||||
|
} from "./types";
|
||||||
|
import {
|
||||||
|
mockDelay,
|
||||||
|
mockTabularResponse,
|
||||||
|
mockInstanceReport,
|
||||||
|
mockAuditLog,
|
||||||
|
} from "./mock";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View and reporting methods — tabular record views, detail views,
|
||||||
|
* and audit trails. Maps to view-service endpoints.
|
||||||
|
*/
|
||||||
|
export class ViewService {
|
||||||
|
private client: ZinoClient;
|
||||||
|
constructor(client: ZinoClient) {
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a paginated, sortable, filterable record view.
|
||||||
|
* Maps to `GET /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
223
src/zino-sdk/workflow.ts
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
import type { ZinoClient } from "./client";
|
||||||
|
import type {
|
||||||
|
Activity,
|
||||||
|
ActivityForm,
|
||||||
|
WorkflowDefinition,
|
||||||
|
WorkflowInstance,
|
||||||
|
} from "./types";
|
||||||
|
import {
|
||||||
|
mockDelay,
|
||||||
|
MOCK_WORKFLOW_DEF,
|
||||||
|
mockWorkflowInstance,
|
||||||
|
mockActivityForm,
|
||||||
|
} from "./mock";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workflow execution methods — start instances, perform activities,
|
||||||
|
* fetch definitions, forms, and instance state.
|
||||||
|
* Maps to core-service and view-service endpoints.
|
||||||
|
*/
|
||||||
|
export class WorkflowService {
|
||||||
|
private client: ZinoClient;
|
||||||
|
constructor(client: ZinoClient) {
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a new workflow instance.
|
||||||
|
* Core-service returns a wrapped response; we extract instance_id.
|
||||||
|
*/
|
||||||
|
async startWorkflow(
|
||||||
|
workflowId: string,
|
||||||
|
activityId: string,
|
||||||
|
data?: Record<string, unknown>,
|
||||||
|
): Promise<WorkflowInstance> {
|
||||||
|
if (this.client.isMock) {
|
||||||
|
await mockDelay();
|
||||||
|
return mockWorkflowInstance(workflowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await this.client.request<{
|
||||||
|
success: boolean;
|
||||||
|
instance_id?: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}>("POST", "/start", {
|
||||||
|
workflow_id: workflowId,
|
||||||
|
activity_id: activityId,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
instance_id: raw.instance_id ?? "",
|
||||||
|
workflow_id: workflowId,
|
||||||
|
current_state_id: "",
|
||||||
|
current_state_name: "",
|
||||||
|
data: raw.data ?? {},
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
} as WorkflowInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute an activity on a running workflow instance.
|
||||||
|
* Core-service returns a wrapped response; we extract instance_id.
|
||||||
|
*/
|
||||||
|
async performActivity(
|
||||||
|
workflowId: string,
|
||||||
|
instanceId: string,
|
||||||
|
activityId: string,
|
||||||
|
data?: Record<string, unknown>,
|
||||||
|
): Promise<WorkflowInstance> {
|
||||||
|
if (this.client.isMock) {
|
||||||
|
await mockDelay();
|
||||||
|
return mockWorkflowInstance(workflowId, instanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await this.client.request<{
|
||||||
|
success: boolean;
|
||||||
|
instance_id?: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}>("POST", "/activity", {
|
||||||
|
workflow_id: workflowId,
|
||||||
|
instance_id: instanceId,
|
||||||
|
activity_id: activityId,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
instance_id: raw.instance_id ?? instanceId,
|
||||||
|
workflow_id: workflowId,
|
||||||
|
current_state_id: "",
|
||||||
|
current_state_name: "",
|
||||||
|
data: raw.data ?? {},
|
||||||
|
created_at: "",
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
} as WorkflowInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch instance state and compute available activities.
|
||||||
|
* Uses /api-docs to get the activity list (no states endpoint exists),
|
||||||
|
* so all non-INIT activities are returned — the server enforces real constraints.
|
||||||
|
*/
|
||||||
|
async getInstanceState(
|
||||||
|
workflowId: string,
|
||||||
|
instanceId: string,
|
||||||
|
): Promise<{
|
||||||
|
instance: WorkflowInstance;
|
||||||
|
availableActivities: Activity[];
|
||||||
|
}> {
|
||||||
|
if (this.client.isMock) {
|
||||||
|
await mockDelay();
|
||||||
|
return {
|
||||||
|
instance: mockWorkflowInstance(workflowId, instanceId),
|
||||||
|
availableActivities: MOCK_WORKFLOW_DEF.activities.filter(
|
||||||
|
(a) => a.type !== "INIT",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [apiDocs, instance] = await Promise.all([
|
||||||
|
this.client.request<Array<{
|
||||||
|
activity_uid: string;
|
||||||
|
activity_name: string;
|
||||||
|
type: string;
|
||||||
|
}>>("GET", `/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
6
tailwind.config.js
Normal 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
19
tsconfig.app.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": false,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
15
tsconfig.node.json
Normal file
15
tsconfig.node.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
8
vite.config.ts
Normal file
8
vite.config.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
base: './',
|
||||||
|
server: { port: 5173 },
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user