console: the desk goes full screen, keeps its conversations, and draws
Three changes to Ask the desk. FULL SCREEN. It was a 560px side panel, which was wrong for what the desk returns: a fifteen-step timeline, a table of every stalled lead, a paragraph naming both PAN numbers. Reading those down a gutter while the page behind sat unused was the panel fighting its own content. Now one readable column with the conversation list beside it, and Escape to leave. CONVERSATIONS. The platform already kept them — every question opens a session and a ticket — but nothing in the console showed them, so an investigation could not be returned to. The rail lists them, newest first, and opening one replays the transcript. CHARTS. The platform has no chart widget and the desk holds no widget tools — its fourteen tools are plain API reads, so everything it says arrives as text. What it does do, reliably, when asked anything countable, is answer with a markdown table. So the chart is that table, drawn: bars by default, a share ring on request, and every bar checkable against the row beside it. Inline SVG, no library. Two details worth keeping: Indian short forms are parsed for magnitude, so "₹3.96 L" sorts above "₹22,000" instead of below it; and a totals row is excluded, because charting it draws one bar as long as all the others put together. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
141b1b2452
commit
f47463f099
@ -1,121 +1,200 @@
|
||||
/* 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. */
|
||||
/* Ask the desk — full screen.
|
||||
|
||||
.desk__scrim {
|
||||
position: fixed; inset: 0; z-index: 60;
|
||||
background: rgba(11, 42, 91, .32);
|
||||
display: flex; justify-content: flex-end;
|
||||
}
|
||||
It was a 560px side panel, which was wrong for what the desk actually
|
||||
returns: a timeline of fifteen steps, a table of every stalled lead, a
|
||||
paragraph naming both PAN numbers. Reading those down a gutter while the
|
||||
page behind sat unused was the panel fighting the content. Full screen, one
|
||||
readable column, and the conversation list alongside. */
|
||||
|
||||
.desk {
|
||||
width: min(560px, 100%); height: 100%;
|
||||
position: fixed; inset: 0; z-index: 60;
|
||||
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);
|
||||
background: var(--zk-tint);
|
||||
animation: desk-in .16s var(--ease);
|
||||
}
|
||||
@keyframes desk-in { from { transform: translateX(24px); opacity: .6; } to { transform: none; opacity: 1; } }
|
||||
@keyframes desk-in { from { opacity: .4; } to { 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;
|
||||
/* ── Bar ─────────────────────────────────────────────────────────────────── */
|
||||
.desk__bar {
|
||||
flex: none; display: flex; align-items: center; gap: 16px;
|
||||
padding: 12px 22px; background: var(--zk-navy); color: #fff;
|
||||
}
|
||||
.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__id { min-width: 0; flex: 1; }
|
||||
.desk__id h2 { margin: 0; font-size: var(--fs-lg); font-weight: 700; }
|
||||
.desk__id p { margin: 2px 0 0; font-size: var(--fs-2xs); opacity: .72; }
|
||||
.desk__x, .desk__rails {
|
||||
flex: none; width: 34px; height: 34px; display: grid; place-items: center;
|
||||
border: 0; border-radius: 8px; background: transparent; color: #fff; cursor: pointer;
|
||||
}
|
||||
.desk__x:hover { background: var(--zk-tint); color: var(--zk-ink); }
|
||||
.desk__x svg, .desk__rails svg { width: 18px; height: 18px; }
|
||||
.desk__x:hover, .desk__rails:hover { background: rgba(255,255,255,.14); }
|
||||
.desk__x:focus-visible, .desk__rails:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(255,255,255,.3); }
|
||||
.desk__rails { display: none; margin-left: -6px; } /* narrow screens only */
|
||||
.desk__new {
|
||||
flex: none; font: inherit; font-size: var(--fs-xs); font-weight: 600; cursor: pointer;
|
||||
padding: 7px 15px; border-radius: var(--r-pill);
|
||||
border: 1px solid rgba(255,255,255,.3); background: transparent; color: #fff;
|
||||
}
|
||||
.desk__new:hover { background: rgba(255,255,255,.12); }
|
||||
|
||||
.desk__feed {
|
||||
flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
|
||||
padding: 18px 22px; display: flex; flex-direction: column; gap: 16px;
|
||||
/* ── Frame ───────────────────────────────────────────────────────────────── */
|
||||
.desk__body { flex: 1; min-height: 0; display: flex; }
|
||||
|
||||
.desk__rail {
|
||||
flex: none; width: 260px; background: #fff; border-right: 1px solid var(--zk-line);
|
||||
overflow-y: auto; padding: 14px 12px; display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
.desk__railh {
|
||||
font-size: var(--fs-3xs); font-weight: 700; letter-spacing: .08em; text-transform: uppercase;
|
||||
color: var(--zk-grey); padding: 4px 8px 8px;
|
||||
}
|
||||
.desk__railnone { margin: 4px 8px; font-size: var(--fs-2xs); color: var(--zk-grey); line-height: 1.5; }
|
||||
.desk__sess {
|
||||
display: block; width: 100%; text-align: left; cursor: pointer;
|
||||
padding: 9px 10px; border-radius: 8px; border: 1px solid transparent; background: transparent;
|
||||
font: inherit; color: var(--zk-ink);
|
||||
}
|
||||
.desk__sess:hover { background: var(--zk-tint); }
|
||||
.desk__sess.is-on { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); }
|
||||
.desk__sesst {
|
||||
display: block; font-size: var(--fs-xs); font-weight: 600; line-height: 1.35;
|
||||
overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||
}
|
||||
.desk__sessw { display: block; font-size: var(--fs-3xs); color: var(--zk-grey); margin-top: 3px; }
|
||||
|
||||
.desk__main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.desk__feed { flex: 1; min-height: 0; overflow-y: auto; padding: 28px 24px 8px; }
|
||||
/* One column, near 70ch, because the answers are prose and prose has a measure. */
|
||||
.desk__col { max-width: 760px; margin: 0 auto; }
|
||||
|
||||
/* ── Turns ───────────────────────────────────────────────────────────────── */
|
||||
.desk__turn { margin-bottom: 26px; }
|
||||
.desk__turn--user { display: flex; justify-content: flex-end; }
|
||||
.desk__q {
|
||||
margin: 0; max-width: 78%; padding: 11px 16px;
|
||||
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;
|
||||
}
|
||||
.desk__a { font-size: var(--fs-sm); color: var(--zk-ink); }
|
||||
.desk__a p { margin: 0 0 11px; line-height: 1.6; }
|
||||
.desk__a h4 { margin: 20px 0 8px; font-size: var(--fs-md); font-weight: 700; }
|
||||
.desk__a ul, .desk__a ol { margin: 0 0 12px; padding-left: 22px; }
|
||||
.desk__a li { margin-bottom: 5px; line-height: 1.55; }
|
||||
.desk__a blockquote {
|
||||
margin: 0 0 12px; padding: 9px 15px; border-left: 3px solid var(--zk-amber-bar);
|
||||
background: var(--zk-amber-tint); border-radius: 0 var(--r-sm) var(--r-sm) 0;
|
||||
}
|
||||
.desk__a code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em;
|
||||
background: var(--zk-tint); padding: 1px 5px; border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 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__tablewrap { overflow-x: auto; margin: 0 0 14px; }
|
||||
.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: 8px 14px 8px 0; border-bottom: 1px solid var(--zk-line);
|
||||
}
|
||||
.desk__a td { padding: 8px 14px 8px 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; }
|
||||
|
||||
.desk__tools { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||
.desk__tools span {
|
||||
font-size: var(--fs-3xs); color: var(--zk-muted);
|
||||
background: var(--zk-line-soft); padding: 2px 9px; border-radius: var(--r-pill);
|
||||
}
|
||||
|
||||
/* ── Empty state ─────────────────────────────────────────────────────────── */
|
||||
.desk__empty { padding: 32px 0 8px; }
|
||||
.desk__empty h3 { margin: 0 0 6px; font-size: var(--fs-xl); font-weight: 700; color: var(--zk-ink); }
|
||||
.desk__empty > p { margin: 0 0 20px; font-size: var(--fs-sm); color: var(--zk-muted); max-width: 60ch; }
|
||||
.desk__sugg { display: flex; flex-direction: column; gap: 9px; 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);
|
||||
padding: 10px 16px; 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; } }
|
||||
@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;
|
||||
border-radius: var(--r-md); padding: 12px 15px; 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;
|
||||
}
|
||||
/* ── Ask ─────────────────────────────────────────────────────────────────── */
|
||||
.desk__ask { flex: none; border-top: 1px solid var(--zk-line); background: #fff; padding: 14px 24px 18px; }
|
||||
.desk__askcol { display: flex; gap: 12px; align-items: flex-end; }
|
||||
.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;
|
||||
padding: 12px 14px; border: 1px solid var(--zk-line); border-radius: var(--r-sm);
|
||||
background: #fff; min-height: 46px; max-height: 160px;
|
||||
}
|
||||
.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; }
|
||||
/* ── Chart drawn from the answer's own table ─────────────────────────────── */
|
||||
.dchart {
|
||||
margin: 0 0 14px; padding: 15px 18px;
|
||||
border: 1px solid var(--zk-line); border-radius: var(--r-md); background: #fff;
|
||||
}
|
||||
.dchart__head {
|
||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
font-size: var(--fs-3xs); color: var(--zk-grey); margin-bottom: 14px;
|
||||
}
|
||||
.dchart__toggle { margin-left: auto; display: inline-flex; border: 1px solid var(--zk-line); border-radius: var(--r-pill); overflow: hidden; }
|
||||
.dchart__toggle button {
|
||||
font: inherit; font-size: var(--fs-3xs); font-weight: 600; cursor: pointer;
|
||||
padding: 4px 12px; border: 0; background: #fff; color: var(--zk-muted);
|
||||
}
|
||||
.dchart__toggle button.is-on { background: var(--zk-navy); color: #fff; }
|
||||
|
||||
/* 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);
|
||||
.dchart__bars { display: flex; flex-direction: column; gap: 8px; }
|
||||
.dchart__row { display: grid; grid-template-columns: minmax(96px, 30%) 1fr auto; gap: 12px; align-items: center; }
|
||||
.dchart__lab { font-size: var(--fs-2xs); color: var(--zk-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dchart__track { height: 16px; background: var(--zk-line-soft); border-radius: 3px; overflow: hidden; }
|
||||
.dchart__fill { display: block; height: 100%; border-radius: 3px; }
|
||||
.dchart__val { font-size: var(--fs-2xs); font-variant-numeric: tabular-nums; color: var(--zk-muted); white-space: nowrap; }
|
||||
.dchart__cap { margin: 10px 0 0; font-size: var(--fs-3xs); color: var(--zk-grey); }
|
||||
|
||||
.dchart__donut { display: flex; gap: 22px; align-items: center; flex-wrap: wrap; }
|
||||
.dchart__donut svg { width: 160px; height: 160px; flex: none; }
|
||||
.dchart__total { font-size: 21px; font-weight: 700; fill: var(--zk-ink); font-variant-numeric: tabular-nums; }
|
||||
.dchart__totlab { font-size: 10px; fill: var(--zk-grey); letter-spacing: .06em; text-transform: uppercase; }
|
||||
.dchart__key { list-style: none; margin: 0; padding: 0; flex: 1; min-width: 200px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.dchart__key li { display: grid; grid-template-columns: 10px 1fr auto auto; gap: 10px; align-items: center; font-size: var(--fs-2xs); }
|
||||
.dchart__sw { width: 10px; height: 10px; border-radius: 2px; }
|
||||
.dchart__keylab { color: var(--zk-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dchart__keyval { color: var(--zk-muted); font-variant-numeric: tabular-nums; }
|
||||
.dchart__keypct { color: var(--zk-grey); font-variant-numeric: tabular-nums; min-width: 34px; text-align: right; }
|
||||
|
||||
/* ── Narrow ──────────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 900px) {
|
||||
.desk__rails { display: grid; }
|
||||
.desk__id p { display: none; }
|
||||
.desk__new { font-size: 0; padding: 8px; }
|
||||
.desk__new::before { content: '+'; font-size: 17px; font-weight: 700; }
|
||||
.desk__rail {
|
||||
position: absolute; top: 58px; bottom: 0; left: 0; z-index: 2;
|
||||
transform: translateX(-100%); transition: transform .18s var(--ease);
|
||||
box-shadow: 8px 0 28px rgba(11,42,91,.14);
|
||||
}
|
||||
.desk__rail.is-open { transform: none; }
|
||||
.desk__body { position: relative; }
|
||||
.desk__feed { padding: 20px 16px 8px; }
|
||||
.desk__ask { padding: 12px 16px 16px; }
|
||||
.dchart__row { grid-template-columns: minmax(72px, 34%) 1fr auto; }
|
||||
}
|
||||
.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; }
|
||||
@media (prefers-reduced-motion: reduce) { .desk__rail { transition: none; } }
|
||||
|
||||
@ -1,197 +1,282 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, 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 DeskChart, { chartableFrom } from './DeskChart.jsx'
|
||||
import './AskDesk.css'
|
||||
|
||||
/**
|
||||
* Ask the desk.
|
||||
* Ask the desk — the support desk, full screen.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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.
|
||||
* Full screen rather than a side panel, because the answers are long — a
|
||||
* timeline, a table of every stalled lead — and reading them in a 560px gutter
|
||||
* meant scrolling a column while the page behind it sat unused. The
|
||||
* conversation list comes with it: an investigation is worth returning to, and
|
||||
* every conversation opens a ticket on the platform whether or not anyone
|
||||
* reads it back.
|
||||
*/
|
||||
|
||||
const SUGGESTIONS = [
|
||||
'Which leads have not moved in over 12 hours?',
|
||||
'How many leads are in each stage right now?',
|
||||
'Which leads have not moved in over 12 hours?',
|
||||
'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. */
|
||||
/* ── The desk answers in markdown. This renders the five things it uses:
|
||||
headings, lists, 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. ── */
|
||||
function inline(text, key) {
|
||||
const parts = String(text).split(/(\*\*[^*]+\*\*|`[^`]+`)/g)
|
||||
return parts.map((p, i) => {
|
||||
return String(text).split(/(\*\*[^*]+\*\*|`[^`]+`)/g).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 }) {
|
||||
/** Parses one answer into blocks, so a table can be drawn as well as printed. */
|
||||
function parse(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}`}>
|
||||
const flushList = () => {
|
||||
if (!list) return
|
||||
out.push({ t: list.ordered ? 'ol' : 'ul', items: list.items })
|
||||
list = null
|
||||
}
|
||||
const flushTable = () => {
|
||||
if (!rows) return
|
||||
out.push({ t: 'table', head: rows[0], body: rows.slice(1) })
|
||||
rows = null
|
||||
}
|
||||
const flush = () => { flushTable(); flushList() }
|
||||
|
||||
lines.forEach((raw) => {
|
||||
const line = raw.trimEnd()
|
||||
const bullet = line.match(/^\s*[-*]\s+(.*)$/)
|
||||
const numbered = line.match(/^\s*\d+[.)]\s+(.*)$/)
|
||||
const heading = line.match(/^#{1,4}\s+(.*)$/)
|
||||
const quote = line.match(/^\s*>\s?(.*)$/)
|
||||
|
||||
if (isRow(line)) {
|
||||
if (!isRule(line)) { flushList(); if (!rows) rows = []; rows.push(cells(line)) }
|
||||
} else if (rows && line.trim() === '') {
|
||||
/* hold the table open across a blank line */
|
||||
} else if (bullet) {
|
||||
flushTable()
|
||||
if (!list || list.ordered) { flushList(); list = { ordered: false, items: [] } }
|
||||
list.items.push(bullet[1])
|
||||
} else if (numbered) {
|
||||
flushTable()
|
||||
if (!list || !list.ordered) { flushList(); list = { ordered: true, items: [] } }
|
||||
list.items.push(numbered[1])
|
||||
} else if (heading) {
|
||||
flush(); out.push({ t: 'h', text: heading[1] })
|
||||
} else if (quote) {
|
||||
flush(); out.push({ t: 'quote', text: quote[1] })
|
||||
} else if (line.trim() === '') {
|
||||
flush()
|
||||
} else {
|
||||
flush(); out.push({ t: 'p', text: line })
|
||||
}
|
||||
})
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
function Answer({ text }) {
|
||||
const blocks = useMemo(() => parse(text), [text])
|
||||
// Chart type is per-table and remembered while the answer is on screen.
|
||||
const [kinds, setKinds] = useState({})
|
||||
|
||||
return (
|
||||
<>
|
||||
{blocks.map((b, i) => {
|
||||
if (b.t === 'h') return <h4 key={i}>{inline(b.text, i)}</h4>
|
||||
if (b.t === 'p') return <p key={i}>{inline(b.text, i)}</p>
|
||||
if (b.t === 'quote') return <blockquote key={i}>{inline(b.text, i)}</blockquote>
|
||||
if (b.t === 'ul') return <ul key={i}>{b.items.map((t, j) => <li key={j}>{inline(t, `${i}-${j}`)}</li>)}</ul>
|
||||
if (b.t === 'ol') return <ol key={i}>{b.items.map((t, j) => <li key={j}>{inline(t, `${i}-${j}`)}</li>)}</ol>
|
||||
if (b.t === 'table') {
|
||||
const chart = chartableFrom(b.head, b.body)
|
||||
return (
|
||||
<div key={i}>
|
||||
{chart ? (
|
||||
<DeskChart
|
||||
data={chart}
|
||||
kind={kinds[i] || 'bar'}
|
||||
onKind={(k) => setKinds((p) => ({ ...p, [i]: k }))}
|
||||
/>
|
||||
) : null}
|
||||
<div className="desk__tablewrap">
|
||||
<table>
|
||||
<thead><tr>{head.map((c, i) => <th key={i}>{inline(c, `h${i}`)}</th>)}</tr></thead>
|
||||
<thead><tr>{b.head.map((c, j) => <th key={j}>{inline(c, `h${j}`)}</th>)}</tr></thead>
|
||||
<tbody>
|
||||
{body.map((r, ri) => (
|
||||
{b.body.map((r, ri) => (
|
||||
<tr key={ri}>{r.map((c, ci) => <td key={ci}>{inline(c, `${ri}-${ci}`)}</td>)}</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</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}</>
|
||||
return null
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** The tools a turn ran, as chips — the desk's working, not just its answer. */
|
||||
/** The tools a turn ran — 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)
|
||||
const raw = row?.metadata?.tool_calls ?? row?.metadata?.api_calls ?? row?.metadata?.tools
|
||||
return (Array.isArray(raw) ? raw : [])
|
||||
.map((t) => (typeof t === 'string' ? t : t?.name || t?.tool_name)).filter(Boolean)
|
||||
}
|
||||
|
||||
const when = (iso) => {
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d)) return ''
|
||||
const mins = Math.round((Date.now() - d.getTime()) / 60000)
|
||||
if (mins < 1) return 'just now'
|
||||
if (mins < 60) return mins + 'm ago'
|
||||
if (mins < 1440) return Math.round(mins / 60) + 'h ago'
|
||||
return d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
export default function AskDesk({ onClose }) {
|
||||
const { client } = useZino()
|
||||
const [sessions, setSessions] = useState(null)
|
||||
const [sessionId, setSessionId] = useState(null)
|
||||
const [rows, setRows] = useState([])
|
||||
const [text, setText] = useState('')
|
||||
const [asking, setAsking] = useState(false)
|
||||
const [err, setErr] = useState(null)
|
||||
const [railOpen, setRailOpen] = useState(false)
|
||||
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 }
|
||||
const loadSessions = useCallback(() => {
|
||||
client.deskSessions(SUPPORT_AGENT_ID)
|
||||
.then((r) => setSessions(Array.isArray(r) ? r : (r?.data ?? [])))
|
||||
.catch(() => setSessions([]))
|
||||
}, [client])
|
||||
|
||||
useEffect(() => { loadSessions() }, [loadSessions])
|
||||
|
||||
// Escape closes, because a full-screen panel with no way out by keyboard is
|
||||
// a trap for anyone who opened it by accident.
|
||||
useEffect(() => {
|
||||
const onKey = (e) => { if (e.key === 'Escape' && !asking) onClose?.() }
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [onClose, asking])
|
||||
|
||||
useEffect(() => {
|
||||
const el = feedRef.current
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}, [rows, asking])
|
||||
|
||||
useEffect(() => { inputRef.current?.focus() }, [sessionId])
|
||||
const openSession = useCallback((id) => {
|
||||
setSessionId(id); setRows([]); setErr(null); setRailOpen(false)
|
||||
client.deskMessages(id)
|
||||
.then((r) => {
|
||||
const msgs = Array.isArray(r) ? r : (r?.data ?? [])
|
||||
setRows(msgs
|
||||
.filter((m) => m.role === 'user' || m.role === 'agent')
|
||||
.map((m) => ({ key: 'm' + m.id, role: m.role === 'user' ? 'user' : 'agent', content: m.content, tools: [] })))
|
||||
})
|
||||
.catch(setErr)
|
||||
}, [client])
|
||||
|
||||
const newSession = useCallback(async () => {
|
||||
setErr(null); setRows([]); setSessionId(null); setRailOpen(false)
|
||||
try {
|
||||
const s = await client.deskStartSession(SUPPORT_AGENT_ID)
|
||||
setSessionId(s?.id ?? s?.data?.id ?? null)
|
||||
inputRef.current?.focus()
|
||||
} catch (e) { setErr(e) }
|
||||
}, [client])
|
||||
|
||||
// One conversation is opened on arrival, so the first question needs no setup.
|
||||
useEffect(() => { newSession() }, [newSession])
|
||||
|
||||
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.
|
||||
setErr(null); setText('')
|
||||
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,
|
||||
tools: toolsOf(res?.call_api_message),
|
||||
}])
|
||||
} catch (e) {
|
||||
setErr(e)
|
||||
} finally {
|
||||
setAsking(false)
|
||||
inputRef.current?.focus()
|
||||
loadSessions()
|
||||
} catch (e) { setErr(e) } finally {
|
||||
setAsking(false); inputRef.current?.focus()
|
||||
}
|
||||
}, [client, sessionId, asking])
|
||||
}, [client, sessionId, asking, loadSessions])
|
||||
|
||||
return (
|
||||
<div className="desk__scrim" role="presentation" onClick={onClose}>
|
||||
<aside
|
||||
className="desk" role="dialog" aria-label="Ask the desk"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
<div className="desk" role="dialog" aria-label="Ask the desk" aria-modal="true">
|
||||
<header className="desk__bar">
|
||||
<button
|
||||
type="button" className="desk__rails" aria-label="Conversations"
|
||||
aria-expanded={railOpen} onClick={() => setRailOpen((v) => !v)}
|
||||
>
|
||||
<header className="desk__head">
|
||||
<div>
|
||||
<svg viewBox="0 0 18 18" aria-hidden="true"><path d="M3 4.5h12M3 9h12M3 13.5h8" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" /></svg>
|
||||
</button>
|
||||
<div className="desk__id">
|
||||
<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>
|
||||
<button type="button" className="desk__new" onClick={newSession}>New question</button>
|
||||
<button type="button" className="desk__x" onClick={onClose} aria-label="Close">
|
||||
<svg viewBox="0 0 18 18" aria-hidden="true"><path d="M4.5 4.5l9 9M13.5 4.5l-9 9" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="desk__body">
|
||||
<aside className={'desk__rail' + (railOpen ? ' is-open' : '')} aria-label="Past conversations">
|
||||
<span className="desk__railh">Conversations</span>
|
||||
{sessions === null ? <p className="desk__railnone">Loading…</p>
|
||||
: sessions.length === 0 ? <p className="desk__railnone">Nothing yet. Every question you ask is kept here.</p>
|
||||
: sessions.map((s) => (
|
||||
<button
|
||||
key={s.id} type="button"
|
||||
className={'desk__sess' + (s.id === sessionId ? ' is-on' : '')}
|
||||
onClick={() => openSession(s.id)}
|
||||
>
|
||||
<span className="desk__sesst">{s.title || 'Untitled question'}</span>
|
||||
<span className="desk__sessw">{when(s.created_at)}</span>
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
<main className="desk__main">
|
||||
<div className="desk__feed" ref={feedRef}>
|
||||
<div className="desk__col">
|
||||
{rows.length === 0 && !asking ? (
|
||||
<div className="desk__empty">
|
||||
<p>Ask about a lead, a queue, or the book as a whole. Try:</p>
|
||||
<h3>What would you like to know?</h3>
|
||||
<p>Ask about a lead, a queue, or the book as a whole. Anything countable comes back as a table — and a chart.</p>
|
||||
<div className="desk__sugg">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button key={s} type="button" disabled={!sessionId} onClick={() => ask(s)}>{s}</button>
|
||||
@ -201,12 +286,12 @@ export default function AskDesk({ onClose }) {
|
||||
) : null}
|
||||
|
||||
{rows.map((r) => (
|
||||
<div key={r.key} className={'desk__row desk__row--' + r.role}>
|
||||
<div key={r.key} className={'desk__turn desk__turn--' + r.role}>
|
||||
{r.role === 'user'
|
||||
? <p className="desk__q">{r.content}</p>
|
||||
: (
|
||||
<div className="desk__a">
|
||||
<Rich text={r.content} />
|
||||
<Answer text={r.content} />
|
||||
{r.tools?.length ? (
|
||||
<div className="desk__tools">
|
||||
{r.tools.map((t, i) => <span key={t + i}>{t}</span>)}
|
||||
@ -218,7 +303,7 @@ export default function AskDesk({ onClose }) {
|
||||
))}
|
||||
|
||||
{asking ? (
|
||||
<div className="desk__row desk__row--agent">
|
||||
<div className="desk__turn desk__turn--agent">
|
||||
<div className="desk__wait">
|
||||
<span className="desk__dots" aria-hidden="true"><i /><i /><i /></span>
|
||||
Reading the book — this takes half a minute.
|
||||
@ -233,29 +318,27 @@ export default function AskDesk({ onClose }) {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="desk__ask"
|
||||
onSubmit={(e) => { e.preventDefault(); ask(text) }}
|
||||
>
|
||||
<form className="desk__ask" onSubmit={(e) => { e.preventDefault(); ask(text) }}>
|
||||
<div className="desk__col desk__askcol">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={text}
|
||||
rows={2}
|
||||
ref={inputRef} value={text} rows={1}
|
||||
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.
|
||||
// Enter sends; Shift+Enter is a new line. A question is
|
||||
// usually one line, and reaching for a button every time is
|
||||
// friction on the thing this panel exists for.
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); ask(text) }
|
||||
}}
|
||||
/>
|
||||
<button type="submit" className="btn btn--primary" disabled={!sessionId || asking || !text.trim()}>
|
||||
Ask
|
||||
</button>
|
||||
<button type="submit" className="btn btn--primary" disabled={!sessionId || asking || !text.trim()}>Ask</button>
|
||||
</div>
|
||||
</form>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
136
src/components/DeskChart.jsx
Normal file
136
src/components/DeskChart.jsx
Normal file
@ -0,0 +1,136 @@
|
||||
/**
|
||||
* A chart drawn from the desk's own answer.
|
||||
*
|
||||
* The platform has no chart widget and the desk holds no widget tools — its
|
||||
* fourteen tools are plain API reads, so everything it says comes back as text.
|
||||
* What it DOES do, reliably, when asked anything countable, is answer with a
|
||||
* markdown table. So the chart here is not a second source: it is that table,
|
||||
* drawn. Every bar can be checked against the row beside it.
|
||||
*
|
||||
* Inline SVG rather than a charting library: two shapes, one scale, and a
|
||||
* library would be more bytes than the console's whole chart vocabulary.
|
||||
*/
|
||||
|
||||
/** A cell like "₹3.96 L", "69%", "1,204" or "12" as a number, or null. */
|
||||
function toNumber(raw) {
|
||||
const s = String(raw ?? '').trim()
|
||||
if (!s) return null
|
||||
// Indian short forms carry their own multiplier and would otherwise sort as 3.96 < 12.
|
||||
const cr = /([\d.,]+)\s*Cr/i.exec(s)
|
||||
if (cr) return parseFloat(cr[1].replace(/,/g, '')) * 1e7
|
||||
const lakh = /([\d.,]+)\s*L\b/i.exec(s)
|
||||
if (lakh) return parseFloat(lakh[1].replace(/,/g, '')) * 1e5
|
||||
const plain = s.replace(/[₹$,%\s]/g, '').replace(/,/g, '')
|
||||
const n = Number(plain)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Can this table be drawn? It needs a label column, one numeric column, and at
|
||||
* least two rows — one bar is a fact, not a chart.
|
||||
*/
|
||||
/* A totals row is the sum of the others, so charting it draws one bar as long
|
||||
as all the rest put together, or a slice that swallows half the ring. The
|
||||
desk adds one to most count tables. */
|
||||
const isTotalRow = (label) => /^\**\s*(total|sum|overall|all)\b/i.test(String(label ?? '').trim())
|
||||
|
||||
export function chartableFrom(head, allRows) {
|
||||
const rows = allRows.filter((r) => !isTotalRow(r[0]))
|
||||
if (!head?.length || rows.length < 2) return null
|
||||
// The rightmost column that parses as a number on most rows is the measure.
|
||||
for (let c = head.length - 1; c >= 1; c--) {
|
||||
const vals = rows.map((r) => toNumber(r[c]))
|
||||
const good = vals.filter((v) => v !== null && v >= 0)
|
||||
if (good.length >= Math.max(2, Math.ceil(rows.length * 0.8))) {
|
||||
const points = rows
|
||||
.map((r, i) => ({ label: String(r[0] ?? '').trim(), value: vals[i], raw: String(r[c] ?? '') }))
|
||||
.filter((p) => p.label && p.value !== null)
|
||||
if (points.length < 2) return null
|
||||
if (points.every((p) => p.value === 0)) return null
|
||||
return { measure: head[c], points }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/* The console's colour rule has meaning attached to it, so a chart of arbitrary
|
||||
categories cannot borrow it. This is a neutral sequence in the same family —
|
||||
ordered, so the biggest slice is always the deepest. */
|
||||
const SERIES = ['#1F55A6', '#4E7FC4', '#7CA0D4', '#137A66', '#4FA08D', '#A86A0B', '#D99A2B', '#7A8699']
|
||||
|
||||
function Bars({ points, measure }) {
|
||||
const max = Math.max(...points.map((p) => p.value))
|
||||
return (
|
||||
<div className="dchart__bars">
|
||||
{points.map((p, i) => (
|
||||
<div className="dchart__row" key={p.label + i}>
|
||||
<span className="dchart__lab" title={p.label}>{p.label}</span>
|
||||
<span className="dchart__track">
|
||||
<span className="dchart__fill" style={{ width: Math.max(1.5, (p.value / max) * 100) + '%', background: SERIES[i % SERIES.length] }} />
|
||||
</span>
|
||||
<span className="dchart__val">{p.raw}</span>
|
||||
</div>
|
||||
))}
|
||||
<p className="dchart__cap">{measure}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Donut({ points, measure }) {
|
||||
const total = points.reduce((t, p) => t + p.value, 0)
|
||||
if (!total) return <Bars points={points} measure={measure} />
|
||||
const R = 62, C = 2 * Math.PI * R
|
||||
let offset = 0
|
||||
return (
|
||||
<div className="dchart__donut">
|
||||
<svg viewBox="0 0 160 160" role="img" aria-label={`${measure} by share`}>
|
||||
<g transform="rotate(-90 80 80)">
|
||||
{points.map((p, i) => {
|
||||
const frac = p.value / total
|
||||
const dash = `${frac * C} ${C - frac * C}`
|
||||
const el = (
|
||||
<circle
|
||||
key={p.label + i} cx="80" cy="80" r={R} fill="none"
|
||||
stroke={SERIES[i % SERIES.length]} strokeWidth="26"
|
||||
strokeDasharray={dash} strokeDashoffset={-offset}
|
||||
/>
|
||||
)
|
||||
offset += frac * C
|
||||
return el
|
||||
})}
|
||||
</g>
|
||||
<text x="80" y="76" textAnchor="middle" className="dchart__total">{total.toLocaleString('en-IN')}</text>
|
||||
<text x="80" y="94" textAnchor="middle" className="dchart__totlab">total</text>
|
||||
</svg>
|
||||
<ul className="dchart__key">
|
||||
{points.map((p, i) => (
|
||||
<li key={p.label + i}>
|
||||
<span className="dchart__sw" style={{ background: SERIES[i % SERIES.length] }} />
|
||||
<span className="dchart__keylab">{p.label}</span>
|
||||
<span className="dchart__keyval">{p.raw}</span>
|
||||
<span className="dchart__keypct">{Math.round((p.value / total) * 100)}%</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DeskChart({ data, kind, onKind }) {
|
||||
const { points, measure } = data
|
||||
// A share only means something when the parts make a whole. Counts by stage
|
||||
// do; a column of average premiums does not, and a donut of averages is a
|
||||
// lie told in colour — so the toggle offers it, and bars stay the default.
|
||||
return (
|
||||
<figure className="dchart">
|
||||
<figcaption className="dchart__head">
|
||||
<span>{measure} · {points.length} rows</span>
|
||||
<span className="dchart__toggle" role="group" aria-label="Chart type">
|
||||
<button type="button" className={kind === 'bar' ? 'is-on' : ''} onClick={() => onKind('bar')}>Bars</button>
|
||||
<button type="button" className={kind === 'donut' ? 'is-on' : ''} onClick={() => onKind('donut')}>Share</button>
|
||||
</span>
|
||||
</figcaption>
|
||||
{kind === 'donut' ? <Donut points={points} measure={measure} /> : <Bars points={points} measure={measure} />}
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user