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>
268 lines
9.0 KiB
JavaScript
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))}`)
|
|
}
|
|
}
|