Wire the console to the platform and make it deployable
The scaffold had the brand right and nothing behind it: the login was a 600ms setTimeout with a TODO, and three pieces of the frontgen deploy contract were missing. - Sign in against POST /usr/login (org_id as a STRING — a number is rejected by the gateway) with the session in a provider; surface the gateway's own message rather than a generic "invalid credentials", because a wrong password and a user without access to this app look identical from here and are not. - Runtime config: the API URL is read from the config.js the server writes at placement, never compiled in, and requireConfigValue throws so a build with no config.js fails loudly instead of calling whichever backend built it. - base: './' plus a router basename taken from <base href>, so one build serves any mount path. - Move the fonts and logo from public/ into src/assets/ — Vite rewrites bundled asset URLs to be relative, while a public/ file referenced as "/fonts/..." stays absolute and 404s under the /zurich-kotak/ mount. - API client for recordview / detailview / form-screens / start / activity, and the pipeline shell whose sidebar is the state machine in the order a lead moves. Queue and lead-file data wait on the record and detail views. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f859fbba6d
commit
9210681488
3
.env.example
Normal file
3
.env.example
Normal file
@ -0,0 +1,3 @@
|
||||
# Local dev only. In a deployed build this is read at runtime from the
|
||||
# config.js the server writes when it places the build.
|
||||
VITE_ZINO_API_URL=https://dev.getzino.in
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -22,3 +22,5 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.env
|
||||
|
||||
105
README.md
105
README.md
@ -1,16 +1,103 @@
|
||||
# React + Vite
|
||||
# Zurich Kotak — Lead Desk
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
Operator console for the Zurich Kotak "Lead to Policy" demo. Leads arrive through
|
||||
three channels — direct, bancassurance and agency — and run one state machine to
|
||||
policy issuance. **The agents do the work; a person appears at one queue.**
|
||||
|
||||
Currently, two official plugins are available:
|
||||
Workflow config is seeded from `sm2/custom-apps/zurich-kotak/` (org **84**,
|
||||
app **536**, workflow `zk_wf_lead`). Design doc:
|
||||
`sm2/app-designs/zurich-kotak/design.html`. Workflow spec PDF:
|
||||
`sm2/custom-apps/zurich-kotak/zurich-kotak-workflow.pdf`.
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
## Run it
|
||||
|
||||
## React Compiler
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # http://localhost:5175
|
||||
npm run build # → dist/
|
||||
```
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
`.env` carries the API host **for local dev only**:
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
```
|
||||
VITE_ZINO_API_URL=https://dev.getzino.in
|
||||
```
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
Logins (all password `ZurichKotak@2026`, org `84`):
|
||||
|
||||
| Email | Role | Can do |
|
||||
|---|---|---|
|
||||
| `sanjay.uw@zurichkotak.example` | SME Underwriter | **Clear / Decline Referral** — the only human decision |
|
||||
| `meera.rm@zurichkotak.example` | Bancassurance RM | Submit a **Partner Bank Lead** |
|
||||
| `arjun.posp@zurichkotak.example` | POSP / Broker | Submit a **Partner Agent Lead** |
|
||||
| `kavya.csr@zurichkotak.example` | Direct / Call centre | Submit a **Self-Serve Lead** |
|
||||
| `deepa.ops@zurichkotak.example` | Operations | Cross-channel visibility |
|
||||
|
||||
Sign in as Sanjay to land in **Referred to Underwriting**, which is where the
|
||||
demo happens.
|
||||
|
||||
## How it talks to the platform
|
||||
|
||||
Everything goes through `src/api/client.js`:
|
||||
|
||||
- `POST /usr/login` — auth. **`org_id` must be a string**; a number returns
|
||||
`cannot unmarshal number into Go struct field LoginRequest.org_id`.
|
||||
- `POST /app/536/view/recordview` — every queue is one record view
|
||||
(`zk-rv-leads`) filtered server-side on `current_state_name`.
|
||||
- `POST /app/536/view/form-screens` then `POST /app/536/activity` — forms are
|
||||
read from the **live** activity schema and submitted straight back.
|
||||
|
||||
### Forms are not defined in this repo
|
||||
|
||||
Whatever `/view/form-screens` returns is what renders — labels, types, select
|
||||
options, which fields are mandatory. Add a field to an activity in Studio,
|
||||
redeploy, and it appears here with no frontend change. That is deliberate: the
|
||||
workflow is the source of truth, and a hardcoded form would quietly diverge
|
||||
from it.
|
||||
|
||||
### Permissions are never enforced here
|
||||
|
||||
The console does not hide a button to stop someone using it. Every permission
|
||||
decision is the workflow's, made server-side on each submission — the platform
|
||||
refuses and this app reports what it said. Two refusals worth knowing:
|
||||
`/activity` answers **403**, `/start` answers **400**, and both carry
|
||||
`permission denied`.
|
||||
|
||||
### The runtime-config contract
|
||||
|
||||
`VITE_ZINO_API_URL` is read at **runtime** from a `config.js` the server writes
|
||||
when it places the build — never compiled in. One artifact is promoted between
|
||||
environments unchanged, so a build-time URL would point every environment at
|
||||
whichever backend happened to build it. `requireConfigValue` throws if it is
|
||||
missing, so a production build with no `config.js` fails loudly rather than
|
||||
calling the wrong backend.
|
||||
|
||||
`vite.config.js` uses `base: './'` and the router takes its basename from the
|
||||
`<base href>` the server writes, so **one build serves any mount path**. Do not
|
||||
reintroduce a build-time base.
|
||||
|
||||
**This is also why the fonts and logo live under `src/assets/` and not
|
||||
`public/`.** Vite rewrites bundled asset URLs to be relative; a `public/` file
|
||||
referenced as `/fonts/…` stays absolute and 404s under the `/zurich-kotak/`
|
||||
mount path. The favicon is the one exception — it stays in `public/brand/` and
|
||||
is referenced relatively from `index.html`.
|
||||
|
||||
## Deployment
|
||||
|
||||
Registered with frontgen by `sm2/custom-apps/zurich-kotak/05_frontgen_project.sql`
|
||||
(slug `zurich-kotak`, repo_name `zurich_kotak`, org 84). A push to `main` builds
|
||||
and serves at **https://preview-dev.getzino.in/zurich-kotak/**.
|
||||
|
||||
The webhook only fires on a **new** push — a push made before the frontgen
|
||||
project row existed matched no project and did nothing.
|
||||
|
||||
## State of the build
|
||||
|
||||
| Piece | Status |
|
||||
|---|---|
|
||||
| Brand — Zurich Sans, palette, logo | done (kept from the original scaffold) |
|
||||
| Sign-in against the real gateway | done |
|
||||
| Runtime config, relative base, router | done |
|
||||
| Pipeline sidebar (mirrors `tbl_wf_states`) | done |
|
||||
| Queue lists | waiting on the `zk-rv-leads` record view |
|
||||
| Lead file, activity forms, attribution panel | waiting on `zk-dv-lead` + form screens |
|
||||
|
||||
@ -2,12 +2,12 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/webp" href="/brand/zurich_logo.webp" />
|
||||
<link rel="icon" type="image/webp" href="./brand/zurich_logo.webp" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Zurich Kotak | Sign in</title>
|
||||
<title>Zurich Kotak | Lead Desk</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
<script type="module" src="./src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
90
package-lock.json
generated
90
package-lock.json
generated
@ -9,7 +9,8 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
@ -627,9 +628,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -647,9 +645,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -667,9 +662,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -687,9 +679,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -707,9 +696,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -727,9 +713,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -1006,6 +989,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@ -1707,9 +1703,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -1731,9 +1724,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -1755,9 +1745,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -1779,9 +1766,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -2082,6 +2066,44 @@
|
||||
"react": "^19.2.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
|
||||
"integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
|
||||
"integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.18.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz",
|
||||
@ -2132,6 +2154,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
|
||||
@ -11,7 +11,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
|
||||
28
src/App.jsx
28
src/App.jsx
@ -1,5 +1,31 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { useZino } from './api/provider.jsx'
|
||||
import Login from './pages/Login.jsx'
|
||||
import Shell from './layout/Shell.jsx'
|
||||
import Pipeline from './screens/Pipeline.jsx'
|
||||
import Lead from './screens/Lead.jsx'
|
||||
|
||||
export default function App() {
|
||||
return <Login />
|
||||
const { isAuthed } = useZino()
|
||||
|
||||
if (!isAuthed) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="*" element={<Login />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Navigate to="/" replace />} />
|
||||
<Route element={<Shell />}>
|
||||
<Route path="/" element={<Navigate to="/stage/zk-state-referred" replace />} />
|
||||
<Route path="/stage/:stageUid" element={<Pipeline />} />
|
||||
<Route path="/lead/:instanceId" element={<Lead />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
159
src/api/client.js
Normal file
159
src/api/client.js
Normal file
@ -0,0 +1,159 @@
|
||||
import { APP_ID, WORKFLOW } from './config'
|
||||
|
||||
const TOKEN_KEY = 'zk_lead_desk_token'
|
||||
const USER_KEY = 'zk_lead_desk_user'
|
||||
|
||||
/**
|
||||
* HTTP client for the Zino gateway.
|
||||
*
|
||||
* Most routes are app-scoped (`/app/536/...`); login is not. The JWT persists
|
||||
* in localStorage so a refresh does not bounce the operator back to the login
|
||||
* screen mid-review.
|
||||
*/
|
||||
export class ZinoClient {
|
||||
constructor(baseUrl, onAuthError) {
|
||||
this.baseUrl = String(baseUrl).replace(/\/+$/, '')
|
||||
this.token = null
|
||||
this.onAuthError = onAuthError
|
||||
if (typeof window !== 'undefined') this.token = localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
setAuthErrorHandler(fn) { this.onAuthError = fn }
|
||||
|
||||
setToken(token) {
|
||||
this.token = token
|
||||
if (typeof window === 'undefined') return
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token)
|
||||
else localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
getToken() { return this.token }
|
||||
|
||||
setStoredUser(user) {
|
||||
if (typeof window === 'undefined') return
|
||||
if (user) localStorage.setItem(USER_KEY, JSON.stringify(user))
|
||||
else localStorage.removeItem(USER_KEY)
|
||||
}
|
||||
|
||||
getStoredUser() {
|
||||
if (typeof window === 'undefined') return null
|
||||
try { return JSON.parse(localStorage.getItem(USER_KEY) || 'null') } catch { return null }
|
||||
}
|
||||
|
||||
async request(method, path, body) {
|
||||
const headers = { 'Content-Type': 'application/json' }
|
||||
if (this.token) headers['Authorization'] = `Bearer ${this.token}`
|
||||
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
if (res.status === 401) {
|
||||
this.setToken(null)
|
||||
this.setStoredUser(null)
|
||||
this.onAuthError?.()
|
||||
throw { status: 401, message: 'Session expired — sign in again' }
|
||||
}
|
||||
if (!res.ok) {
|
||||
let message = res.statusText
|
||||
try {
|
||||
const j = await res.json()
|
||||
message = j.error || j.message || message
|
||||
} catch { /* non-JSON body */ }
|
||||
throw { status: res.status, message }
|
||||
}
|
||||
if (res.status === 204) return undefined
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* org_id must be a STRING. The gateway rejects a number with
|
||||
* "cannot unmarshal number into Go struct field LoginRequest.org_id".
|
||||
*/
|
||||
async login(email, password, orgId) {
|
||||
const res = await this.request('POST', '/usr/login', {
|
||||
email,
|
||||
password,
|
||||
org_id: String(orgId),
|
||||
})
|
||||
if (res?.token) {
|
||||
this.setToken(res.token)
|
||||
this.setStoredUser(res.user || null)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.setToken(null)
|
||||
this.setStoredUser(null)
|
||||
}
|
||||
|
||||
/** Paginated records for a record view, filtered server-side. */
|
||||
recordView(rvUid, params = {}) {
|
||||
return this.request('POST', `/app/${APP_ID}/view/recordview`, {
|
||||
rv_id: rvUid,
|
||||
page: params.page ?? 1,
|
||||
limit: params.limit ?? 50,
|
||||
...(params.search ? { search: params.search } : {}),
|
||||
...(params.sort_by ? { sort_by: params.sort_by, sort_dir: params.sort_dir ?? 'desc' } : {}),
|
||||
...(params.filters ? { search_query: { filters: params.filters } } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
detailView(dvUid, instanceId) {
|
||||
return this.request(
|
||||
'GET',
|
||||
`/app/${APP_ID}/view/detailview/${dvUid}?instance_id=${encodeURIComponent(String(instanceId))}`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The LIVE definition of an activity's form: fields, types, select options,
|
||||
* which are mandatory. Read rather than hardcoded so that adding a field in
|
||||
* Studio and redeploying surfaces it here with no frontend change — the
|
||||
* workflow stays the source of truth.
|
||||
*/
|
||||
formSchema(activityUid, instanceId) {
|
||||
return this.request('POST', `/app/${APP_ID}/view/form-screens`, {
|
||||
activity_id: activityUid,
|
||||
device_type: 'desktop',
|
||||
...(instanceId ? { instance_id: instanceId } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
/** Start a new instance via one of the three INIT activities. */
|
||||
start(activityUid, data) {
|
||||
return this.request('POST', `/app/${APP_ID}/start`, {
|
||||
workflow_uuid: WORKFLOW,
|
||||
activity_id: activityUid,
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform an activity on an existing instance. The workflow refuses what the
|
||||
* signed-in user may not do — a permission denial arrives as 403 here and as
|
||||
* 400 on /start, both carrying "permission denied".
|
||||
*/
|
||||
activity(instanceId, activityUid, data) {
|
||||
return this.request('POST', `/app/${APP_ID}/activity`, {
|
||||
workflow_uuid: WORKFLOW,
|
||||
instance_id: instanceId,
|
||||
activity_id: activityUid,
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
instance(instanceId) {
|
||||
return this.request('POST', `/app/${APP_ID}/instance`, {
|
||||
workflow_uuid: WORKFLOW,
|
||||
instance_id: instanceId,
|
||||
})
|
||||
}
|
||||
|
||||
audit(instanceId) {
|
||||
return this.request('GET', `/view/audit?instance_id=${encodeURIComponent(String(instanceId))}`)
|
||||
}
|
||||
}
|
||||
45
src/api/config.js
Normal file
45
src/api/config.js
Normal file
@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Environment-scoped identifiers for the Zurich Kotak "Lead to Policy" demo.
|
||||
*
|
||||
* Workflow config is seeded from sm2/custom-apps/zurich-kotak/ — org 84,
|
||||
* app 536, workflow zk_wf_lead (110001 v1).
|
||||
*/
|
||||
export const ORG_ID = '84'
|
||||
export const APP_ID = '536'
|
||||
export const WORKFLOW = 'zk_wf_lead'
|
||||
|
||||
/**
|
||||
* The pipeline, in the order a lead actually moves.
|
||||
*
|
||||
* Mirrors workflow.tbl_wf_states by hand. It is duplicated rather than fetched
|
||||
* because the sidebar has to render its ORDER, and the API returns states as a
|
||||
* set with no canonical sequence. Keep in step with 02_workflow.sql.
|
||||
*/
|
||||
export const STAGES = [
|
||||
{ uid: 'zk-state-new', name: 'New Lead', kind: 'work' },
|
||||
{ uid: 'zk-state-qualified', name: 'Qualified', kind: 'work' },
|
||||
{ uid: 'zk-state-contacted', name: 'Contacted', kind: 'work' },
|
||||
{ uid: 'zk-state-risk', name: 'Risk Captured', kind: 'work' },
|
||||
{ uid: 'zk-state-quoted', name: 'Quoted', kind: 'work' },
|
||||
{ uid: 'zk-state-accepted', name: 'Proposal Accepted', kind: 'work' },
|
||||
{ uid: 'zk-state-kyc', name: 'KYC Verified', kind: 'work' },
|
||||
// The only human queue in the whole machine.
|
||||
{ uid: 'zk-state-referred', name: 'Referred to Underwriting', kind: 'human' },
|
||||
{ uid: 'zk-state-cleared', name: 'Underwriting Cleared', kind: 'work' },
|
||||
{ uid: 'zk-state-payment', name: 'Payment Pending', kind: 'work' },
|
||||
{ uid: 'zk-state-issued', name: 'Policy Issued', kind: 'work' },
|
||||
{ uid: 'zk-state-onboarded', name: 'Onboarded', kind: 'end' },
|
||||
{ uid: 'zk-state-lost', name: 'Lost / Dropped', kind: 'end' },
|
||||
{ uid: 'zk-state-declined', name: 'Declined', kind: 'end' },
|
||||
]
|
||||
|
||||
/** Channel presentation. source_channel is data on the instance, never a branch. */
|
||||
export const CHANNELS = {
|
||||
direct: { label: 'Direct', short: 'D' },
|
||||
bancassurance: { label: 'Bancassurance', short: 'B' },
|
||||
agency: { label: 'Agency', short: 'A' },
|
||||
}
|
||||
|
||||
/** View source-uids. Seeded by sm2/custom-apps/zurich-kotak/04_views.sql. */
|
||||
export const RV_LEADS = 'zk-rv-leads'
|
||||
export const DV_LEAD = 'zk-dv-lead'
|
||||
51
src/api/provider.jsx
Normal file
51
src/api/provider.jsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'
|
||||
import { ZinoClient } from './client'
|
||||
|
||||
const ZinoContext = createContext(null)
|
||||
|
||||
/**
|
||||
* Holds the client and the session.
|
||||
*
|
||||
* The stored user is restored on boot so a refresh does not flash the login
|
||||
* screen. It restores an IDENTITY, never a permission set: every permission
|
||||
* decision is the workflow's, made server-side on each submission. Nothing here
|
||||
* hides a button to enforce a rule — the platform refuses, and the console
|
||||
* reports what it said.
|
||||
*/
|
||||
export function ZinoProvider({ baseUrl, children }) {
|
||||
const clientRef = useRef(null)
|
||||
if (!clientRef.current) clientRef.current = new ZinoClient(baseUrl)
|
||||
const client = clientRef.current
|
||||
|
||||
const [user, setUser] = useState(() => (client.getToken() ? client.getStoredUser() : null))
|
||||
|
||||
const signOut = useCallback(() => {
|
||||
client.logout()
|
||||
setUser(null)
|
||||
}, [client])
|
||||
|
||||
client.setAuthErrorHandler(() => setUser(null))
|
||||
|
||||
const signIn = useCallback(
|
||||
async (email, password, orgId) => {
|
||||
const res = await client.login(email, password, orgId)
|
||||
const u = res?.user || { email }
|
||||
setUser(u)
|
||||
return u
|
||||
},
|
||||
[client],
|
||||
)
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ client, user, signIn, signOut, isAuthed: Boolean(user && client.getToken()) }),
|
||||
[client, user, signIn, signOut],
|
||||
)
|
||||
|
||||
return <ZinoContext.Provider value={value}>{children}</ZinoContext.Provider>
|
||||
}
|
||||
|
||||
export function useZino() {
|
||||
const ctx = useContext(ZinoContext)
|
||||
if (!ctx) throw new Error('useZino must be used inside <ZinoProvider>')
|
||||
return ctx
|
||||
}
|
||||
BIN
src/assets/brand/zurich_logo.webp
Normal file
BIN
src/assets/brand/zurich_logo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@ -1,21 +1,21 @@
|
||||
/* ── Zurich Kotak brand typeface ───────────────────────────── */
|
||||
@font-face {
|
||||
font-family: 'Zurich Sans';
|
||||
src: url('/fonts/ZurichSans-Light.otf') format('opentype');
|
||||
src: url('./assets/fonts/ZurichSans-Light.otf') format('opentype');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Zurich Sans';
|
||||
src: url('/fonts/ZurichSans-Regular.otf') format('opentype');
|
||||
src: url('./assets/fonts/ZurichSans-Regular.otf') format('opentype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Zurich Sans';
|
||||
src: url('/fonts/ZurichSans-Medium.otf') format('opentype');
|
||||
src: url('./assets/fonts/ZurichSans-Medium.otf') format('opentype');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
|
||||
51
src/layout/Shell.css
Normal file
51
src/layout/Shell.css
Normal file
@ -0,0 +1,51 @@
|
||||
.shell { min-height: 100vh; display: flex; flex-direction: column; background: var(--zk-tint); }
|
||||
|
||||
.shell__top {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 24px; padding: 0 24px; height: 64px; background: var(--zk-white);
|
||||
border-bottom: 1px solid var(--zk-line); position: sticky; top: 0; z-index: 10;
|
||||
}
|
||||
.shell__brand { display: flex; align-items: center; gap: 14px; }
|
||||
.shell__brand img { height: 30px; width: auto; display: block; }
|
||||
.shell__brand div { display: flex; flex-direction: column; line-height: 1.25; padding-left: 14px; border-left: 1px solid var(--zk-line); }
|
||||
.shell__brand strong { font-size: .95rem; font-weight: 500; color: var(--zk-ink); }
|
||||
.shell__brand span { font-size: .74rem; color: var(--zk-muted); }
|
||||
|
||||
.shell__who { display: flex; align-items: center; gap: 16px; }
|
||||
.shell__user { display: flex; flex-direction: column; text-align: right; line-height: 1.3; }
|
||||
.shell__user strong { font-size: .85rem; font-weight: 500; }
|
||||
.shell__user span { font-size: .72rem; color: var(--zk-muted); }
|
||||
.shell__signout {
|
||||
font: inherit; font-size: .8rem; padding: 7px 14px; border-radius: 4px; cursor: pointer;
|
||||
background: var(--zk-white); color: var(--zk-blue); border: 1px solid var(--zk-line);
|
||||
}
|
||||
.shell__signout:hover { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); }
|
||||
|
||||
.shell__body { display: flex; flex: 1; min-height: 0; align-items: stretch; }
|
||||
|
||||
.shell__nav {
|
||||
width: 244px; flex: none; background: var(--zk-white);
|
||||
border-right: 1px solid var(--zk-line); padding: 18px 12px 28px;
|
||||
display: flex; flex-direction: column; gap: 1px;
|
||||
}
|
||||
.shell__navlabel {
|
||||
margin: 0 0 8px; padding: 0 10px; font-size: .66rem; font-weight: 500;
|
||||
letter-spacing: .1em; text-transform: uppercase; color: var(--zk-grey);
|
||||
}
|
||||
.shell__navlabel--closed { margin-top: 22px; }
|
||||
|
||||
.shell__stage {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
padding: 8px 10px; border-radius: 4px; text-decoration: none;
|
||||
color: var(--zk-ink); font-size: .84rem; border-left: 2px solid transparent;
|
||||
}
|
||||
.shell__stage:hover { background: var(--zk-tint); }
|
||||
.shell__stage.is-active { background: var(--zk-tint-blue); border-left-color: var(--zk-blue); color: var(--zk-blue-dark); font-weight: 500; }
|
||||
/* The one queue where the machine stops and a person decides. */
|
||||
.shell__stage.is-human .shell__stagename { font-weight: 500; }
|
||||
.shell__badge {
|
||||
font-size: .6rem; letter-spacing: .08em; text-transform: uppercase;
|
||||
padding: 2px 6px; border-radius: 3px; background: var(--zk-blue); color: var(--zk-white);
|
||||
}
|
||||
|
||||
.shell__main { flex: 1; min-width: 0; padding: 26px 28px 60px; }
|
||||
71
src/layout/Shell.jsx
Normal file
71
src/layout/Shell.jsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom'
|
||||
import { useZino } from '../api/provider.jsx'
|
||||
import { STAGES } from '../api/config.js'
|
||||
import logo from '../assets/brand/zurich_logo.webp'
|
||||
import './Shell.css'
|
||||
|
||||
/**
|
||||
* The sidebar IS the pipeline, in the order a lead moves — so the shape of the
|
||||
* process is legible before any record is opened. The one human queue is marked
|
||||
* as such, because it is the only place the machine stops.
|
||||
*/
|
||||
export default function Shell() {
|
||||
const { user, signOut } = useZino()
|
||||
const work = STAGES.filter((s) => s.kind !== 'end')
|
||||
const closed = STAGES.filter((s) => s.kind === 'end')
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<header className="shell__top">
|
||||
<div className="shell__brand">
|
||||
<img src={logo} alt="Zurich Kotak General Insurance" />
|
||||
<div>
|
||||
<strong>Lead Desk</strong>
|
||||
<span>Lead to Policy</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shell__who">
|
||||
<div className="shell__user">
|
||||
<strong>{user?.name || user?.email || 'Signed in'}</strong>
|
||||
<span>{user?.email}</span>
|
||||
</div>
|
||||
<button type="button" className="shell__signout" onClick={signOut}>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="shell__body">
|
||||
<nav className="shell__nav" aria-label="Pipeline">
|
||||
<p className="shell__navlabel">Pipeline</p>
|
||||
{work.map((s) => (
|
||||
<NavLink
|
||||
key={s.uid}
|
||||
to={`/stage/${s.uid}`}
|
||||
className={({ isActive }) =>
|
||||
'shell__stage' + (isActive ? ' is-active' : '') + (s.kind === 'human' ? ' is-human' : '')
|
||||
}
|
||||
>
|
||||
<span className="shell__stagename">{s.name}</span>
|
||||
{s.kind === 'human' ? <span className="shell__badge">you</span> : null}
|
||||
</NavLink>
|
||||
))}
|
||||
<p className="shell__navlabel shell__navlabel--closed">Closed</p>
|
||||
{closed.map((s) => (
|
||||
<NavLink
|
||||
key={s.uid}
|
||||
to={`/stage/${s.uid}`}
|
||||
className={({ isActive }) => 'shell__stage' + (isActive ? ' is-active' : '')}
|
||||
>
|
||||
<span className="shell__stagename">{s.name}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<main className="shell__main">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
25
src/main.jsx
25
src/main.jsx
@ -1,10 +1,31 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.jsx'
|
||||
import { ZinoProvider } from './api/provider.jsx'
|
||||
import { basePath, requireConfigValue } from './runtimeConfig.js'
|
||||
import './index.css'
|
||||
|
||||
/**
|
||||
* The API base URL is read at RUNTIME, from the config.js the server writes
|
||||
* when it places the build — never compiled in. One artifact is promoted
|
||||
* between environments unchanged, so a build-time URL would point every
|
||||
* environment at whichever backend happened to build it.
|
||||
*
|
||||
* requireConfigValue throws rather than falling back, so a production build
|
||||
* with no config.js fails loudly on the first paint instead of quietly calling
|
||||
* the wrong backend.
|
||||
*/
|
||||
const baseUrl = requireConfigValue('VITE_ZINO_API_URL')
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
{/* basename comes from the <base href> the server wrote, so one build
|
||||
serves any mount path without a rebuild. */}
|
||||
<BrowserRouter basename={basePath()}>
|
||||
<ZinoProvider baseUrl={baseUrl}>
|
||||
<App />
|
||||
</ZinoProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import logo from '/brand/zurich_logo.webp'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useZino } from '../api/provider.jsx'
|
||||
import { ORG_ID } from '../api/config.js'
|
||||
import logo from '../assets/brand/zurich_logo.webp'
|
||||
import './Login.css'
|
||||
|
||||
const FEATURES = [
|
||||
@ -41,6 +44,8 @@ export default function Login() {
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const { signIn } = useZino()
|
||||
const navigate = useNavigate()
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault()
|
||||
@ -53,10 +58,15 @@ export default function Login() {
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
// TODO: wire to the low-code backend auth endpoint
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
} catch {
|
||||
setError('We could not sign you in. Please try again.')
|
||||
// org_id must be a STRING — the gateway rejects a number with
|
||||
// "cannot unmarshal number into Go struct field LoginRequest.org_id".
|
||||
await signIn(email.trim(), password, ORG_ID)
|
||||
navigate('/', { replace: true })
|
||||
} catch (err) {
|
||||
// Surface what the gateway actually said. "Invalid credentials" would
|
||||
// hide the two failures that look identical from here and are not:
|
||||
// a wrong password, and a user with no access to this app.
|
||||
setError(err?.message || 'We could not sign you in. Please try again.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
||||
41
src/runtimeConfig.js
Normal file
41
src/runtimeConfig.js
Normal file
@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Runtime configuration, written by the server as `config.js` when it places a
|
||||
* build — never baked in.
|
||||
*
|
||||
* One build is promoted between environments unchanged, so an API URL compiled
|
||||
* at build time would be the *source* environment's URL everywhere it lands.
|
||||
*
|
||||
* SAME_ORIGIN exists because an empty string is falsy and could never survive
|
||||
* the `||` chain, which is exactly what "use the current origin" has to do.
|
||||
*/
|
||||
export function resolveConfigValue(key, fallback = '') {
|
||||
const runtime = (window.__RUNTIME_CONFIG__ || {})[key]
|
||||
// Gated on DEV: a production build with no config.js must fail loudly rather
|
||||
// than quietly calling whichever backend happened to build it.
|
||||
const devFallback = import.meta.env.DEV ? (import.meta.env[key] ?? '') : ''
|
||||
const value = runtime || devFallback || fallback
|
||||
return value === 'SAME_ORIGIN' ? window.location.origin.replace(/\/$/, '') : value
|
||||
}
|
||||
|
||||
export function requireConfigValue(key) {
|
||||
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, read from the `<base href>` the
|
||||
* server writes into index.html. Drives the router basename, so one build
|
||||
* serves any mount path without a rebuild.
|
||||
*
|
||||
* `import.meta.env.BASE_URL` is NOT usable here — with Vite's relative base it
|
||||
* resolves to "./".
|
||||
*/
|
||||
export function basePath() {
|
||||
return new URL(document.baseURI).pathname
|
||||
}
|
||||
23
src/screens/Lead.jsx
Normal file
23
src/screens/Lead.jsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import './screens.css'
|
||||
|
||||
/** The lead file. Built once the detail view is seeded. */
|
||||
export default function Lead() {
|
||||
const { instanceId } = useParams()
|
||||
return (
|
||||
<section>
|
||||
<header className="page__head">
|
||||
<div>
|
||||
<h1 className="page__title">Lead {instanceId}</h1>
|
||||
<p className="page__sub">Evidence, attribution and the referral file.</p>
|
||||
</div>
|
||||
<Link className="grid__link" to="/">Back to the queue</Link>
|
||||
</header>
|
||||
<div className="notice">
|
||||
<strong>Not built yet.</strong>
|
||||
<p>The lead file reads the <code>zk-dv-lead</code> detail view and the live
|
||||
activity forms. Both arrive with the next app-config pass.</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
108
src/screens/Pipeline.jsx
Normal file
108
src/screens/Pipeline.jsx
Normal file
@ -0,0 +1,108 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useZino } from '../api/provider.jsx'
|
||||
import { CHANNELS, RV_LEADS, STAGES } from '../api/config.js'
|
||||
import './screens.css'
|
||||
|
||||
/**
|
||||
* One record view drives every queue; the stage is a server-side filter on
|
||||
* current_state_name. Filtering server-side rather than fetching everything and
|
||||
* narrowing in the browser is what keeps a queue honest once there are more
|
||||
* leads than one page.
|
||||
*/
|
||||
export default function Pipeline() {
|
||||
const { stageUid } = useParams()
|
||||
const { client } = useZino()
|
||||
const stage = STAGES.find((s) => s.uid === stageUid)
|
||||
|
||||
const [state, setState] = useState({ status: 'loading', rows: [], error: null })
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setState({ status: 'loading', rows: [], error: null })
|
||||
client
|
||||
.recordView(RV_LEADS, {
|
||||
limit: 100,
|
||||
filters: [{ field_key: 'current_state_name', value: stage?.name ?? '' }],
|
||||
})
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
const rows = res?.data ?? res?.rows ?? res?.records ?? []
|
||||
setState({ status: 'ready', rows, error: null })
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return
|
||||
setState({ status: 'error', rows: [], error: err })
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [client, stageUid, stage?.name])
|
||||
|
||||
if (!stage) return <p className="empty">Unknown stage.</p>
|
||||
|
||||
return (
|
||||
<section>
|
||||
<header className="page__head">
|
||||
<div>
|
||||
<h1 className="page__title">{stage.name}</h1>
|
||||
<p className="page__sub">
|
||||
{stage.kind === 'human'
|
||||
? 'The only queue in the machine that waits for a person.'
|
||||
: 'Handled by the agents; shown here for visibility.'}
|
||||
</p>
|
||||
</div>
|
||||
{state.status === 'ready' ? (
|
||||
<span className="page__count">{state.rows.length}</span>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{state.status === 'loading' ? <p className="empty">Loading…</p> : null}
|
||||
|
||||
{state.status === 'error' ? (
|
||||
<div className="notice">
|
||||
<strong>This queue has no record view yet.</strong>
|
||||
<p>
|
||||
The console reads every queue from the <code>{RV_LEADS}</code> record view.
|
||||
It is not seeded yet, so there is nothing to list. Everything else —
|
||||
sign-in, routing and the pipeline itself — is live.
|
||||
</p>
|
||||
<p className="notice__detail">{state.error?.status} {state.error?.message}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.status === 'ready' && state.rows.length === 0 ? (
|
||||
<p className="empty">No leads at this stage.</p>
|
||||
) : null}
|
||||
|
||||
{state.status === 'ready' && state.rows.length > 0 ? (
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lead</th><th>Customer</th><th>Channel</th>
|
||||
<th>Product</th><th className="num">Premium</th><th>Attribution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{state.rows.map((r) => {
|
||||
const id = r.instance_id ?? r.id
|
||||
const ch = CHANNELS[r.source_channel] || { label: r.source_channel || '—' }
|
||||
return (
|
||||
<tr key={id}>
|
||||
<td><Link className="grid__link" to={`/lead/${id}`}>{r.lead_ref || id}</Link></td>
|
||||
<td>{r.customer_name || '—'}<div className="grid__sub">{r.entity_name || ''}</div></td>
|
||||
<td><span className="chip">{ch.label}</span></td>
|
||||
<td>{r.product_line === 'motor' ? 'Motor' : 'SME Package'}</td>
|
||||
<td className="num">{r.quoted_premium ? Number(r.quoted_premium).toLocaleString('en-IN') : '—'}</td>
|
||||
<td>
|
||||
{r.attribution_status === 'contested'
|
||||
? <span className="chip chip--warn">Contested</span>
|
||||
: <span className="grid__sub">{r.attribution_status || '—'}</span>}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
50
src/screens/screens.css
Normal file
50
src/screens/screens.css
Normal file
@ -0,0 +1,50 @@
|
||||
.page__head {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: 24px; margin-bottom: 20px;
|
||||
}
|
||||
.page__title { margin: 0; font-size: 1.5rem; font-weight: 500; letter-spacing: -.01em; color: var(--zk-ink); }
|
||||
.page__sub { margin: 3px 0 0; font-size: .84rem; color: var(--zk-muted); }
|
||||
.page__count {
|
||||
font-size: 1.35rem; font-weight: 500; color: var(--zk-blue);
|
||||
background: var(--zk-white); border: 1px solid var(--zk-line);
|
||||
border-radius: 6px; padding: 6px 16px; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.empty { color: var(--zk-muted); font-size: .88rem; padding: 28px 2px; }
|
||||
|
||||
.notice {
|
||||
background: var(--zk-white); border: 1px solid var(--zk-line);
|
||||
border-left: 3px solid var(--zk-blue); border-radius: 0 6px 6px 0; padding: 16px 20px;
|
||||
max-width: 60ch;
|
||||
}
|
||||
.notice strong { display: block; font-weight: 500; margin-bottom: 6px; }
|
||||
.notice p { margin: 0 0 6px; font-size: .86rem; color: var(--zk-muted); line-height: 1.55; }
|
||||
.notice__detail { font-size: .76rem !important; color: var(--zk-grey) !important; }
|
||||
.notice code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8em;
|
||||
background: var(--zk-tint); border: 1px solid var(--zk-line-soft);
|
||||
border-radius: 3px; padding: 1px 4px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
width: 100%; border-collapse: collapse; background: var(--zk-white);
|
||||
border: 1px solid var(--zk-line); border-radius: 6px; overflow: hidden; font-size: .86rem;
|
||||
}
|
||||
.grid th {
|
||||
text-align: left; font-size: .68rem; font-weight: 500; letter-spacing: .08em;
|
||||
text-transform: uppercase; color: var(--zk-muted); padding: 10px 14px;
|
||||
background: var(--zk-tint); border-bottom: 1px solid var(--zk-line); white-space: nowrap;
|
||||
}
|
||||
.grid td { padding: 11px 14px; border-bottom: 1px solid var(--zk-line-soft); vertical-align: top; }
|
||||
.grid tbody tr:last-child td { border-bottom: none; }
|
||||
.grid tbody tr:hover { background: var(--zk-tint); }
|
||||
.grid .num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.grid__sub { font-size: .76rem; color: var(--zk-muted); margin-top: 2px; }
|
||||
.grid__link { color: var(--zk-blue); text-decoration: none; font-weight: 500; }
|
||||
.grid__link:hover { text-decoration: underline; }
|
||||
|
||||
.chip {
|
||||
display: inline-block; font-size: .72rem; padding: 2px 8px; border-radius: 3px;
|
||||
background: var(--zk-tint-blue); color: var(--zk-blue-dark); border: 1px solid var(--zk-blue-light);
|
||||
}
|
||||
.chip--warn { background: #fdf1e7; color: #93500f; border-color: #f0c69a; }
|
||||
@ -1,7 +1,16 @@
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
// Relative base: emitted asset refs become "./assets/…", so the <base href> the
|
||||
// server writes at placement decides where they resolve. One build therefore
|
||||
// serves any mount path. Do NOT reintroduce a build-time base — the artifact is
|
||||
// promoted between environments unchanged and would stop being placeable.
|
||||
//
|
||||
// This is also why the fonts and the logo live under src/ and not public/:
|
||||
// Vite rewrites bundled asset URLs to be relative, while a public/ file
|
||||
// referenced as "/fonts/…" stays absolute and 404s under a mount path.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: './',
|
||||
server: { port: 5175 },
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user