console: ask the support desk without leaving the console
An "Ask the desk" panel over the console, for ops only. The desk is a platform Support App bound to app 536: it reads the deployed config and the live book through read-only tools and answers with evidence. It is a different kind of thing from the five AI employees — they do the work and write to the workflow; this one only reads, to answer a person's question. No new auth. The desk is an ordinary interactive agent, a conversation is a session and a question is a message on it, and the token this console already holds is accepted on those routes as-is. Ops only, deliberately: the desk reads the WHOLE book — every partner, every premium, every commission — so a partner agent must not open it. The platform enforces it per user; the button just isn't drawn for anyone else. The panel renders the markdown the desk actually uses — headings, lists, bold, code and tables. Tables earn their place: asked anything countable it replies with one, and rendered as raw pipes a correct answer reads like a broken one. Verified end to end against the database: its stage counts matched all ten states exactly, and its account of why ZK-2026-01040 sits with the underwriter names both PAN numbers correctly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
ac3dc2965a
commit
73b6ff7eb0
@ -68,6 +68,37 @@ export class ZinoClient {
|
||||
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".
|
||||
|
||||
@ -34,6 +34,21 @@ export const WORKFLOW = 'zk_wf_lead'
|
||||
* error: the queue returns nothing, correctly, for a name no lead is in. That
|
||||
* is the failure mode this table has, and it is silent.
|
||||
*/
|
||||
/**
|
||||
* The support desk this console can ask questions of.
|
||||
*
|
||||
* A Support App on the platform: an AI desk bound to app 536 that reads the
|
||||
* deployed config and the live book through read-only tools and answers with
|
||||
* evidence. It is org-scoped and lives outside this app, so its id is config
|
||||
* here rather than something the console can derive.
|
||||
*
|
||||
* Access is granted per user on the platform (usersystem.tbl_org_user_agents)
|
||||
* and the console additionally gates the panel to ops — see ASK_DESK_ROLES.
|
||||
* Both have to agree; the platform's grant is the one that actually enforces.
|
||||
*/
|
||||
export const SUPPORT_AGENT_ID = 29648
|
||||
export const ASK_DESK_ROLES = ['ops_admin']
|
||||
|
||||
export const STAGES = [
|
||||
{ uid: 'zk-state-new', name: 'New Lead', kind: 'auto', doing: 'Checking the lead', by: 'Intake AI' },
|
||||
{ uid: 'zk-state-qualified', name: 'Awaiting Contact', kind: 'auto', doing: 'Calling the customer', by: 'the voice agent' },
|
||||
|
||||
121
src/components/AskDesk.css
Normal file
121
src/components/AskDesk.css
Normal file
@ -0,0 +1,121 @@
|
||||
/* Ask the desk — a panel over the console, not a page of its own. Whoever
|
||||
opens it is in the middle of something; it should be dismissible in one
|
||||
press and leave the work behind it visible. */
|
||||
|
||||
.desk__scrim {
|
||||
position: fixed; inset: 0; z-index: 60;
|
||||
background: rgba(11, 42, 91, .32);
|
||||
display: flex; justify-content: flex-end;
|
||||
}
|
||||
|
||||
.desk {
|
||||
width: min(560px, 100%); height: 100%;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--zk-card);
|
||||
border-left: 1px solid var(--zk-line);
|
||||
box-shadow: -18px 0 48px rgba(11, 42, 91, .18);
|
||||
animation: desk-in .2s var(--ease);
|
||||
}
|
||||
@keyframes desk-in { from { transform: translateX(24px); opacity: .6; } to { transform: none; opacity: 1; } }
|
||||
@media (prefers-reduced-motion: reduce) { .desk { animation: none; } }
|
||||
|
||||
.desk__head {
|
||||
display: flex; align-items: flex-start; gap: 16px;
|
||||
padding: 18px 22px 14px; border-bottom: 1px solid var(--zk-line-soft); flex: none;
|
||||
}
|
||||
.desk__head h2 { margin: 0; font-size: var(--fs-xl); font-weight: 700; color: var(--zk-ink); }
|
||||
.desk__head p { margin: 3px 0 0; font-size: var(--fs-xs); color: var(--zk-muted); max-width: 46ch; }
|
||||
.desk__x {
|
||||
margin-left: auto; flex: none; width: 32px; height: 32px; border-radius: 50%;
|
||||
border: 1px solid var(--zk-line); background: #fff; color: var(--zk-muted);
|
||||
font-size: 20px; line-height: 1; cursor: pointer;
|
||||
}
|
||||
.desk__x:hover { background: var(--zk-tint); color: var(--zk-ink); }
|
||||
|
||||
.desk__feed {
|
||||
flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
|
||||
padding: 18px 22px; display: flex; flex-direction: column; gap: 16px;
|
||||
}
|
||||
|
||||
/* Nothing asked yet: say what it is for, and offer four real questions. */
|
||||
.desk__empty p { margin: 0 0 12px; font-size: var(--fs-sm); color: var(--zk-muted); }
|
||||
.desk__sugg { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
|
||||
.desk__sugg button {
|
||||
font: inherit; font-size: var(--fs-xs); text-align: left; cursor: pointer;
|
||||
padding: 9px 14px; border-radius: var(--r-sm);
|
||||
border: 1px solid var(--zk-line); background: #fff; color: var(--zk-ink);
|
||||
}
|
||||
.desk__sugg button:hover:not(:disabled) { border-color: var(--zk-blue-light); background: var(--zk-tint-blue); color: var(--zk-navy); }
|
||||
.desk__sugg button:disabled { opacity: .5; cursor: default; }
|
||||
|
||||
.desk__row { display: flex; }
|
||||
.desk__row--user { justify-content: flex-end; }
|
||||
|
||||
/* The question, as the operator wrote it. */
|
||||
.desk__q {
|
||||
margin: 0; max-width: 82%;
|
||||
padding: 10px 14px; border-radius: var(--r-md) var(--r-md) var(--r-xs) var(--r-md);
|
||||
background: var(--zk-navy); color: #fff; font-size: var(--fs-sm); line-height: 1.5;
|
||||
}
|
||||
|
||||
/* The answer. Left-aligned and unboxed, because it is prose to be read, not a
|
||||
message in a chat to be scanned. */
|
||||
.desk__a { max-width: 100%; font-size: var(--fs-sm); color: var(--zk-ink); }
|
||||
.desk__a p { margin: 0 0 10px; line-height: 1.55; }
|
||||
.desk__a h4 { margin: 14px 0 6px; font-size: var(--fs-md); font-weight: 700; }
|
||||
.desk__a ul, .desk__a ol { margin: 0 0 10px; padding-left: 20px; }
|
||||
.desk__a li { margin-bottom: 5px; line-height: 1.5; }
|
||||
.desk__a code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em;
|
||||
background: var(--zk-tint); padding: 1px 5px; border-radius: 4px;
|
||||
}
|
||||
.desk__a strong { font-weight: 700; }
|
||||
|
||||
/* What it actually ran to answer — its working, not just its conclusion. */
|
||||
.desk__tools { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
|
||||
.desk__tools span {
|
||||
font-size: var(--fs-3xs); color: var(--zk-muted);
|
||||
background: var(--zk-line-soft); padding: 2px 8px; border-radius: var(--r-pill);
|
||||
}
|
||||
|
||||
.desk__wait { display: flex; align-items: center; gap: 10px; font-size: var(--fs-xs); color: var(--zk-muted); }
|
||||
.desk__dots { display: inline-flex; gap: 4px; }
|
||||
.desk__dots i { width: 6px; height: 6px; border-radius: 50%; background: var(--zk-blue); animation: desk-blink 1.2s infinite; }
|
||||
.desk__dots i:nth-child(2) { animation-delay: .2s; }
|
||||
.desk__dots i:nth-child(3) { animation-delay: .4s; }
|
||||
@keyframes desk-blink { 0%, 60%, 100% { opacity: .25; } 30% { opacity: 1; } }
|
||||
@media (prefers-reduced-motion: reduce) { .desk__dots i { animation: none; opacity: .6; } }
|
||||
|
||||
.desk__err {
|
||||
border: 1px solid var(--zk-danger-line); background: var(--zk-danger-tint);
|
||||
border-radius: var(--r-md); padding: 12px 14px; display: flex; flex-direction: column; gap: 3px;
|
||||
}
|
||||
.desk__err strong { font-size: var(--fs-sm); color: var(--zk-danger-ink); }
|
||||
.desk__err span { font-size: var(--fs-2xs); color: var(--zk-muted); }
|
||||
|
||||
.desk__ask {
|
||||
flex: none; display: flex; gap: 10px; align-items: flex-end;
|
||||
padding: 14px 22px 18px; border-top: 1px solid var(--zk-line-soft); background: #fff;
|
||||
}
|
||||
.desk__ask textarea {
|
||||
flex: 1; resize: none; font: inherit; font-size: var(--fs-sm); color: var(--zk-ink);
|
||||
padding: 10px 12px; border: 1px solid var(--zk-line); border-radius: var(--r-sm); background: #fff;
|
||||
}
|
||||
.desk__ask textarea:focus { outline: none; border-color: var(--zk-blue); box-shadow: var(--ring); }
|
||||
.desk__ask textarea:disabled { background: var(--zk-tint); color: var(--zk-grey); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.desk { width: 100%; border-left: 0; }
|
||||
.desk__head, .desk__feed, .desk__ask { padding-left: 16px; padding-right: 16px; }
|
||||
}
|
||||
|
||||
/* Ask the desk for anything countable and it answers with a table. */
|
||||
.desk__tablewrap { overflow-x: auto; margin: 0 0 12px; }
|
||||
.desk__a table { border-collapse: collapse; width: 100%; font-size: var(--fs-xs); }
|
||||
.desk__a th {
|
||||
text-align: left; font-weight: 600; color: var(--zk-grey); white-space: nowrap;
|
||||
padding: 7px 12px 7px 0; border-bottom: 1px solid var(--zk-line);
|
||||
}
|
||||
.desk__a td { padding: 7px 12px 7px 0; border-bottom: 1px solid var(--zk-line-soft); }
|
||||
.desk__a th:last-child, .desk__a td:last-child { padding-right: 0; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.desk__a tr:last-child td { border-bottom: 0; }
|
||||
261
src/components/AskDesk.jsx
Normal file
261
src/components/AskDesk.jsx
Normal file
@ -0,0 +1,261 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { SUPPORT_AGENT_ID } from '../api/config.js'
|
||||
import { useZino } from '../api/provider.jsx'
|
||||
import { describeError } from '../api/errors.js'
|
||||
import './AskDesk.css'
|
||||
|
||||
/**
|
||||
* Ask the desk.
|
||||
*
|
||||
* A support desk on the platform, bound to this app, that reads the deployed
|
||||
* config and the live book through read-only tools and answers with evidence.
|
||||
* It is a different kind of thing from the five AI employees: they DO the work
|
||||
* and write to the workflow; this one only reads, and only to answer a person's
|
||||
* question. Nothing it says changes a lead.
|
||||
*
|
||||
* The panel is deliberately thin. The desk is an ordinary interactive agent on
|
||||
* the platform, so a conversation is a session and a question is a message on
|
||||
* it; the console's own token is accepted on those routes as-is.
|
||||
*
|
||||
* Answers take twenty to forty seconds because it runs real queries first, so
|
||||
* the waiting state says what it is doing rather than showing a spinner.
|
||||
*/
|
||||
|
||||
const SUGGESTIONS = [
|
||||
'Which leads have not moved in over 12 hours?',
|
||||
'How many leads are in each stage right now?',
|
||||
'What reason did the AI give for each underwriting referral?',
|
||||
'Total written premium, and how many policies were issued?',
|
||||
]
|
||||
|
||||
/* The desk answers in markdown. Rather than carry a parser for five features,
|
||||
this handles exactly what it uses: headings, bullets, numbered steps, tables,
|
||||
bold and inline code. Anything else falls through as text, which is the right
|
||||
failure — an unrendered asterisk is legible; a crashed panel is not.
|
||||
|
||||
Tables earn their place: asked for anything countable the desk replies with
|
||||
one, and rendered as raw pipes a correct answer reads like a broken one. */
|
||||
function inline(text, key) {
|
||||
const parts = String(text).split(/(\*\*[^*]+\*\*|`[^`]+`)/g)
|
||||
return parts.map((p, i) => {
|
||||
if (p.startsWith('**') && p.endsWith('**')) return <strong key={`${key}-${i}`}>{p.slice(2, -2)}</strong>
|
||||
if (p.startsWith('`') && p.endsWith('`')) return <code key={`${key}-${i}`}>{p.slice(1, -1)}</code>
|
||||
return p
|
||||
})
|
||||
}
|
||||
|
||||
/** `| a | b |` -> ['a','b']; a separator row (`|---|`) has no content. */
|
||||
const cells = (line) => line.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim())
|
||||
const isRow = (line) => /^\s*\|.*\|\s*$/.test(line)
|
||||
const isRule = (line) => /^\s*\|[\s:|-]+\|\s*$/.test(line)
|
||||
|
||||
function Rich({ text }) {
|
||||
const lines = String(text || '').split('\n')
|
||||
const out = []
|
||||
let list = null
|
||||
let rows = null
|
||||
|
||||
const flush = () => {
|
||||
if (rows) {
|
||||
const [head, ...body] = rows
|
||||
out.push(
|
||||
<div className="desk__tablewrap" key={`t${out.length}`}>
|
||||
<table>
|
||||
<thead><tr>{head.map((c, i) => <th key={i}>{inline(c, `h${i}`)}</th>)}</tr></thead>
|
||||
<tbody>
|
||||
{body.map((r, ri) => (
|
||||
<tr key={ri}>{r.map((c, ci) => <td key={ci}>{inline(c, `${ri}-${ci}`)}</td>)}</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
rows = null
|
||||
}
|
||||
if (!list) return
|
||||
out.push(list.ordered
|
||||
? <ol key={`l${out.length}`}>{list.items.map((t, i) => <li key={i}>{inline(t, `${out.length}-${i}`)}</li>)}</ol>
|
||||
: <ul key={`l${out.length}`}>{list.items.map((t, i) => <li key={i}>{inline(t, `${out.length}-${i}`)}</li>)}</ul>)
|
||||
list = null
|
||||
}
|
||||
|
||||
lines.forEach((raw, i) => {
|
||||
const line = raw.trimEnd()
|
||||
const bullet = line.match(/^\s*[-*]\s+(.*)$/)
|
||||
const numbered = line.match(/^\s*\d+[.)]\s+(.*)$/)
|
||||
const heading = line.match(/^#{1,4}\s+(.*)$/)
|
||||
if (isRow(line)) {
|
||||
// A rule under the header is markdown's alignment row, not data. The
|
||||
// desk sometimes omits it and sometimes leaves a blank line between
|
||||
// rows, so neither is treated as the end of the table.
|
||||
if (!isRule(line)) {
|
||||
if (!rows) { flush(); rows = [] }
|
||||
rows.push(cells(line))
|
||||
}
|
||||
} else if (rows && line.trim() === '') {
|
||||
/* hold the table open across a blank line */
|
||||
} else if (bullet) {
|
||||
if (!list || list.ordered) { flush(); list = { ordered: false, items: [] } }
|
||||
list.items.push(bullet[1])
|
||||
} else if (numbered) {
|
||||
if (!list || !list.ordered) { flush(); list = { ordered: true, items: [] } }
|
||||
list.items.push(numbered[1])
|
||||
} else if (heading) {
|
||||
flush(); out.push(<h4 key={i}>{inline(heading[1], i)}</h4>)
|
||||
} else if (line.trim() === '') {
|
||||
flush()
|
||||
} else {
|
||||
flush(); out.push(<p key={i}>{inline(line, i)}</p>)
|
||||
}
|
||||
})
|
||||
flush()
|
||||
return <>{out}</>
|
||||
}
|
||||
|
||||
/** The tools a turn ran, as chips — the desk's working, not just its answer. */
|
||||
function toolsOf(row) {
|
||||
if (!row) return []
|
||||
const raw = row.metadata?.tool_calls ?? row.metadata?.api_calls ?? row.metadata?.tools
|
||||
const list = Array.isArray(raw) ? raw : []
|
||||
return list.map((t) => (typeof t === 'string' ? t : t?.name || t?.tool_name)).filter(Boolean)
|
||||
}
|
||||
|
||||
export default function AskDesk({ onClose }) {
|
||||
const { client } = useZino()
|
||||
const [sessionId, setSessionId] = useState(null)
|
||||
const [rows, setRows] = useState([])
|
||||
const [text, setText] = useState('')
|
||||
const [asking, setAsking] = useState(false)
|
||||
const [err, setErr] = useState(null)
|
||||
const feedRef = useRef(null)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
// One conversation per opening of the panel. Reusing the last one would make
|
||||
// the desk answer this week's question against last week's context, and the
|
||||
// transcript stays on the platform either way.
|
||||
useEffect(() => {
|
||||
let dead = false
|
||||
client.deskStartSession(SUPPORT_AGENT_ID)
|
||||
.then((s) => { if (!dead) setSessionId(s?.id ?? s?.data?.id ?? null) })
|
||||
.catch((e) => { if (!dead) setErr(e) })
|
||||
return () => { dead = true }
|
||||
}, [client])
|
||||
|
||||
useEffect(() => {
|
||||
const el = feedRef.current
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}, [rows, asking])
|
||||
|
||||
useEffect(() => { inputRef.current?.focus() }, [sessionId])
|
||||
|
||||
const ask = useCallback(async (question) => {
|
||||
const q = String(question ?? '').trim()
|
||||
if (!q || !sessionId || asking) return
|
||||
setErr(null)
|
||||
setText('')
|
||||
// The question shows immediately; the answer replaces the waiting line.
|
||||
setRows((prev) => [...prev, { key: 'q' + Date.now(), role: 'user', content: q }])
|
||||
setAsking(true)
|
||||
try {
|
||||
const res = await client.deskAsk(sessionId, q)
|
||||
const answer = res?.agent_message
|
||||
const called = toolsOf(res?.call_api_message)
|
||||
setRows((prev) => [...prev, {
|
||||
key: 'a' + (answer?.id ?? Date.now()),
|
||||
role: 'agent',
|
||||
content: answer?.content ?? 'The desk answered with nothing at all.',
|
||||
tools: called,
|
||||
}])
|
||||
} catch (e) {
|
||||
setErr(e)
|
||||
} finally {
|
||||
setAsking(false)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [client, sessionId, asking])
|
||||
|
||||
return (
|
||||
<div className="desk__scrim" role="presentation" onClick={onClose}>
|
||||
<aside
|
||||
className="desk" role="dialog" aria-label="Ask the desk"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="desk__head">
|
||||
<div>
|
||||
<h2>Ask the desk</h2>
|
||||
<p>Reads the book and answers with evidence. It cannot change anything.</p>
|
||||
</div>
|
||||
<button type="button" className="desk__x" onClick={onClose} aria-label="Close">×</button>
|
||||
</header>
|
||||
|
||||
<div className="desk__feed" ref={feedRef}>
|
||||
{rows.length === 0 && !asking ? (
|
||||
<div className="desk__empty">
|
||||
<p>Ask about a lead, a queue, or the book as a whole. Try:</p>
|
||||
<div className="desk__sugg">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button key={s} type="button" disabled={!sessionId} onClick={() => ask(s)}>{s}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{rows.map((r) => (
|
||||
<div key={r.key} className={'desk__row desk__row--' + r.role}>
|
||||
{r.role === 'user'
|
||||
? <p className="desk__q">{r.content}</p>
|
||||
: (
|
||||
<div className="desk__a">
|
||||
<Rich text={r.content} />
|
||||
{r.tools?.length ? (
|
||||
<div className="desk__tools">
|
||||
{r.tools.map((t, i) => <span key={t + i}>{t}</span>)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{asking ? (
|
||||
<div className="desk__row desk__row--agent">
|
||||
<div className="desk__wait">
|
||||
<span className="desk__dots" aria-hidden="true"><i /><i /><i /></span>
|
||||
Reading the book — this takes half a minute.
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{err ? (
|
||||
<div className="desk__err" role="alert">
|
||||
<strong>{describeError(err).title}</strong>
|
||||
<span>{err?.message}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="desk__ask"
|
||||
onSubmit={(e) => { e.preventDefault(); ask(text) }}
|
||||
>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={text}
|
||||
rows={2}
|
||||
placeholder={sessionId ? 'Ask a question…' : 'Opening a conversation…'}
|
||||
disabled={!sessionId || asking}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Enter sends; Shift+Enter is a new line. A question is usually
|
||||
// one line, and reaching for a button every time is friction.
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); ask(text) }
|
||||
}}
|
||||
/>
|
||||
<button type="submit" className="btn btn--primary" disabled={!sessionId || asking || !text.trim()}>
|
||||
Ask
|
||||
</button>
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -191,3 +191,22 @@
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.shell__nav { transition: none; }
|
||||
}
|
||||
|
||||
/* The support desk, reachable from anywhere in the console. Ops only — the
|
||||
button is not drawn for anyone else, and the platform refuses them anyway. */
|
||||
.mast__desk {
|
||||
margin-left: auto;
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 6px 14px; border-radius: var(--r-pill);
|
||||
border: 1px solid rgba(255, 255, 255, .28); background: transparent; color: #fff;
|
||||
font: inherit; font-size: var(--fs-xs); font-weight: 600; cursor: pointer;
|
||||
transition: background var(--t-fast), border-color var(--t-fast);
|
||||
}
|
||||
.mast__desk svg { width: 14px; height: 14px; }
|
||||
.mast__desk:hover { background: rgba(255, 255, 255, .12); border-color: rgba(255, 255, 255, .5); }
|
||||
.mast__desk:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(255, 255, 255, .28); }
|
||||
/* With the desk button present it owns the auto margin, so the user menu must
|
||||
not also claim one or the two separate to opposite ends. */
|
||||
.mast__desk ~ .mast__user { margin-left: 0; }
|
||||
|
||||
@media (max-width: 900px) { .mast__desk span, .mast__desk { font-size: 0; gap: 0; padding: 8px; } .mast__desk svg { font-size: initial; } }
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { NavLink, Outlet } from 'react-router-dom'
|
||||
import { NAV_GROUPS, STAGES } from '../api/config.js'
|
||||
import { ASK_DESK_ROLES, NAV_GROUPS, STAGES } from '../api/config.js'
|
||||
import { rolesOf, visibleStages } from '../api/permissions.js'
|
||||
import { useStageCounts } from '../api/portfolio.jsx'
|
||||
import { useZino } from '../api/provider.jsx'
|
||||
import AskDesk from '../components/AskDesk.jsx'
|
||||
import UserMenu from './UserMenu.jsx'
|
||||
import './Shell.css'
|
||||
|
||||
@ -35,6 +36,12 @@ export default function Shell() {
|
||||
// because a menu that stays open over the page you just chose is a menu you
|
||||
// have to dismiss twice. Desktop never reads this — the rail is always there.
|
||||
const [navOpen, setNavOpen] = useState(false)
|
||||
// The support desk reads the WHOLE book — every partner, every premium — so
|
||||
// it is an ops tool, not something a partner agent may open. The platform
|
||||
// enforces this too (the desk is assigned per user); this only decides
|
||||
// whether the button is drawn.
|
||||
const [deskOpen, setDeskOpen] = useState(false)
|
||||
const canAskDesk = ASK_DESK_ROLES.some((r) => roles.includes(r))
|
||||
// Closing on the link rather than on the route: a menu that stays open over
|
||||
// the page you just chose is a menu you have to dismiss twice. Keyed off the
|
||||
// anchor so opening the Closed fold — which is not navigation — leaves it up.
|
||||
@ -87,6 +94,15 @@ export default function Shell() {
|
||||
</div>
|
||||
<span className="mast__sep" aria-hidden="true" />
|
||||
<div className="mast__app">Lead Desk <span>Lead to policy</span></div>
|
||||
{canAskDesk ? (
|
||||
<button type="button" className="mast__desk" onClick={() => setDeskOpen(true)}>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M2.5 3.5h11v7h-6l-3 2.5v-2.5h-2z" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.3" strokeLinejoin="round" />
|
||||
</svg>
|
||||
Ask the desk
|
||||
</button>
|
||||
) : null}
|
||||
<div className="mast__user"><UserMenu /></div>
|
||||
</header>
|
||||
|
||||
@ -136,6 +152,8 @@ export default function Shell() {
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{deskOpen ? <AskDesk onClose={() => setDeskOpen(false)} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user