zurich_kotak_pwa/src/api/client.js
Yashas 6455579cc2 The Lead Desk, written for a phone
A separate app from the desktop console, talking to the same platform with
the same client. Not a breakpoint on the old one: the two answer different
questions. The console answers "what is the state of the book?" across a
wide grid; a phone answers "what needs me, and what happened to this one?"
in a column you scroll with a thumb.

The old console at 390px showed why. Tables scrolled sideways, nine queues
stacked above the content ate the first screenful, and the action you came
to perform sat below the fold.

So every row of every table is a CARD — customer, stage, when it is due,
who is holding it, and one line about what is happening. Everything else is
one tap away. The queues live in a drawer. The action a lead is waiting on
is pinned to the bottom of the screen, where a thumb already is.

Shared with the console, because a divergence would be two apps disagreeing
about the same lead: the whole api/ layer, ActivityForm, and Timeline —
whose audit-row merging (one submission writes three rows) is hard-won and
must not be reimplemented twice.

Written fresh: the shell, the three screens, and the stylesheet.

The colour rule is unchanged and is the product in four colours: blue the
AI holds it, amber a person, teal the customer, red risk and nothing else.

A PWA, so Add to Home Screen gives a full-screen app; the layout pads for
the notch and the home indicator.

Verified at 402x874: login, overview, drawer, a queue, and a lead, with no
horizontal overflow on any of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 16:50:48 +05:30

268 lines
9.0 KiB
JavaScript

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()
}
/* ── The support desk ────────────────────────────────────────────────────
Not app-scoped: these are the platform's own agent routes, and the token
this console already holds is accepted on them as-is. A conversation is a
session; a question is a message on it. */
/** This operator's conversations with the desk, newest first. */
deskSessions(agentId) {
return this.request('GET', `/api/agent-sessions?agent_id=${agentId}`)
}
/** Open a new conversation. */
deskStartSession(agentId) {
return this.request('POST', '/api/agent-sessions', { agent_id: agentId })
}
/** The transcript of one conversation. */
deskMessages(sessionId) {
return this.request('GET', `/api/agent-sessions/${sessionId}/messages`)
}
/**
* Ask. Answers with the rows the backend persisted for the turn —
* { user_message, call_api_message, agent_message } — not a bare string.
*
* These take twenty to forty seconds: the desk runs real queries against the
* book before it answers. The caller has to say so, or the panel looks hung.
*/
deskAsk(sessionId, content) {
return this.request('POST', `/api/agent-sessions/${sessionId}/messages`, { content })
}
/**
* 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.
*
* The POST body is NOT the same shape as the GET query params: the view is
* named by `rv_template_uid` (not `rv_id`, which is the GET spelling), and
* paging/sort/filters all live INSIDE `search_query`. Sending them at the top
* level returns `400 Missing param: rv_template_uid (body)`.
*/
recordView(rvUid, params = {}) {
return this.request('POST', `/app/${APP_ID}/view/recordview`, {
rv_template_uid: rvUid,
search_query: {
page: params.page ?? 1,
limit: params.limit ?? 50,
sort_by: params.sort_by ?? '',
sort_dir: params.sort_dir ?? 'desc',
search: params.search ?? '',
filters: (params.filters ?? []).map((f) => ({
field_key: f.field_key,
value: f.value,
value2: '',
data_type: f.data_type ?? 'string',
})),
},
})
}
/**
* Rows only. The response key differs by source type — workflow views return
* `data`, rdbms views have been seen returning `records`.
*/
async rows(rvUid, params = {}) {
const r = await this.recordView(rvUid, params)
return r?.data ?? r?.records ?? r?.rows ?? []
}
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,
})
}
/**
* Upload a file and get back a reference — {uuid, blob_path, original_name,
* mime_type}. The field context matters: the backend resolves the field's
* own config (allowed types, size limit, ocr_config) server-side from it and
* ignores anything the client claims.
*/
async uploadFile(file, ctx) {
const form = new FormData()
form.append('file', file)
// workflow_uuid is REQUIRED and is fixed for this app, so it is taken from
// config rather than from ctx. Passing it optionally is how the first
// version shipped: the upload sent activity_id, field_id and instance_id
// and got back `Missing required params: workflow_uuid`.
form.append('workflow_uuid', WORKFLOW)
if (ctx.activityId) form.append('activity_id', ctx.activityId)
if (ctx.fieldId) form.append('field_id', ctx.fieldId)
if (ctx.instanceId) form.append('instance_id', String(ctx.instanceId))
const headers = {}
if (this.token) headers['Authorization'] = `Bearer ${this.token}`
const res = await fetch(`${this.baseUrl}/app/${APP_ID}/upload`, { method: 'POST', headers, body: form })
if (!res.ok) {
let message = res.statusText
try { const j = await res.json(); message = j.error || j.message || message } catch { /* non-JSON */ }
throw { status: res.status, message }
}
return res.json()
}
/**
* Extract from an ALREADY-UPLOADED file. Sends a reference, not the bytes —
* the document crosses the wire once, survives a reload, and re-extracting
* costs no re-upload. The ocr_config (which fields to pull, where they map)
* is resolved server-side from the deployed workflow; anything the client
* sends is ignored.
*/
ocrExtract(fileRef, ctx) {
return this.request('POST', `/app/${APP_ID}/ocr-extract`, {
workflow_uuid: WORKFLOW,
activity_id: ctx.activityId,
field_id: ctx.fieldId,
instance_id: ctx.instanceId || undefined,
files: [fileRef],
})
}
instance(instanceId) {
return this.request('POST', `/app/${APP_ID}/instance`, {
workflow_uuid: WORKFLOW,
instance_id: instanceId,
})
}
/**
* The instance's audit trail: one entry per activity performed, with WHO
* performed it and the data they wrote.
*
* Must be the APP-SCOPED path. The bare `/view/audit` is not an API route at
* all — it falls through to the SPA and returns HTML with a 200, which
* parses as a JSON error rather than an HTTP one.
*/
audit(instanceId) {
return this.request('GET', `/app/${APP_ID}/view/audit?instance_id=${encodeURIComponent(String(instanceId))}`)
}
}