Compare commits

...

2 Commits

Author SHA1 Message Date
e0d51159f6 console: play the call recording alongside the transcript
The voice agent uploads a dual-channel WAV of each call to blob storage; until
now the console showed only the transcript's conclusions, not the audio. Add a
player wherever the call is read:

- THE CALL panel on the lead gets an inline "Call recording" player.
- The transcript dialog gets the player above the turns, so a reviewer can
  listen while reading — the words and the audio are the same evidence.

Both read row.call_recording_url (the platform's own field for this, surfaced
on the lead by backend 107) and use preload="none", so a multi-MB WAV is only
fetched when someone actually presses play. The player simply does not render
when a lead has no recording.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 16:19:26 +05:30
bd4ced41ac console: make the renewal-exposure buckets filter the table
The three buckets (Lapsed / Within 7 days / 8 to 30 days) were static counts
above a combined table. They now double as the filter: click one to narrow the
table to that bucket, click again — or use the Clear affordance — to show every
open renewal by soonest due. The active bucket fills with its own tint, an
empty bucket is disabled, and the empty state is filter-aware. One predicate
map drives both the counts and the filter so they cannot drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 16:05:21 +05:30
5 changed files with 135 additions and 15 deletions

View File

@ -42,7 +42,7 @@ function turnsFrom(text) {
return out.filter((t) => t.text) return out.filter((t) => t.text)
} }
export default function CallTranscript({ text, name, onClose }) { export default function CallTranscript({ text, audio, name, onClose }) {
const ref = useRef(null) const ref = useRef(null)
const restore = useRef(null) const restore = useRef(null)
const turns = turnsFrom(text) const turns = turnsFrom(text)
@ -82,6 +82,16 @@ export default function CallTranscript({ text, name, onClose }) {
</button> </button>
</div> </div>
{audio ? (
<div className="conv__audio">
{/* Listen along with the transcript. preload=none so opening the
dialog does not pull a multi-MB WAV until the user hits play. */}
<audio controls preload="none" src={audio}>
Your browser cannot play this recording.
</audio>
</div>
) : null}
<div className="conv__body"> <div className="conv__body">
{turns.length ? turns.map((t, i) => ( {turns.length ? turns.map((t, i) => (
<div key={i} className={'bub bub--' + t.side}> <div key={i} className={'bub bub--' + t.side}>

View File

@ -76,3 +76,13 @@
background: var(--zk-tint); background: var(--zk-tint);
border-radius: var(--r-md); border-radius: var(--r-md);
} }
/* The call recording, played from inside the transcript dialog. */
.conv__audio {
padding: 12px 20px 4px;
border-bottom: 1px solid var(--zk-line-soft);
}
.conv__audio audio {
width: 100%;
height: 38px;
}

View File

@ -838,6 +838,17 @@ blocked ? (
Read what was actually said Read what was actually said
</button> </button>
) : null} ) : null}
{title === 'The call' && row.call_recording_url ? (
/* The recording itself, to play alongside the transcript.
preload=none keeps the multi-MB WAV off the wire until
a person actually presses play. */
<div className="glance__rec">
<span className="glance__rec-l">Call recording</span>
<audio controls preload="none" src={row.call_recording_url}>
Your browser cannot play this recording.
</audio>
</div>
) : null}
</section> </section>
))} ))}
</div> </div>
@ -864,7 +875,7 @@ blocked ? (
<AgentCard agentKey={openAgent} onClose={() => setOpenAgent(null)} /> <AgentCard agentKey={openAgent} onClose={() => setOpenAgent(null)} />
) : null} ) : null}
{showCall ? ( {showCall ? (
<CallTranscript text={row.call_transcript} name={row.customer_name} onClose={() => setShowCall(false)} /> <CallTranscript text={row.call_transcript} audio={row.call_recording_url} name={row.customer_name} onClose={() => setShowCall(false)} />
) : null} ) : null}
{showFile ? ( {showFile ? (

View File

@ -47,6 +47,16 @@ function inr(n) {
return '₹' + Math.round(n).toLocaleString('en-IN') return '₹' + Math.round(n).toLocaleString('en-IN')
} }
/* The renewal-exposure buckets, as predicates on days-to-expiry, so the filter
and the three counts stay one definition. */
const EXPO_BUCKETS = {
lapsed: (d) => d < 0,
week: (d) => d >= 0 && d <= 7,
month: (d) => d > 7 && d <= 30,
}
const EXPO_LABEL = { lapsed: 'Lapsed', week: 'Within 7 days', month: '8 to 30 days' }
function expoLabel(k) { return EXPO_LABEL[k] || '' }
function expiryTone(d) { function expiryTone(d) {
if (d === null) return 'later' if (d === null) return 'later'
if (d < 0) return 'lapsed' if (d < 0) return 'lapsed'
@ -63,6 +73,8 @@ export default function Overview() {
// Filing a lead happens over this page, not instead of it see // Filing a lead happens over this page, not instead of it see
// NewLeadDialog. The overview behind it keeps its counts and its poll. // NewLeadDialog. The overview behind it keeps its counts and its poll.
const [adding, setAdding] = useState(false) const [adding, setAdding] = useState(false)
// Which renewal-exposure bucket the table is narrowed to, or null for all.
const [expo, setExpo] = useState(null)
const state = usePortfolio() const state = usePortfolio()
const m = useMemo(() => { const m = useMemo(() => {
@ -158,6 +170,10 @@ export default function Overview() {
const mine = new Set(visibleStages(roles).map((s) => s.uid)) const mine = new Set(visibleStages(roles).map((s) => s.uid))
// The exposure table, narrowed to the chosen bucket (or the soonest-due
// across all buckets when no filter is set).
const expoRows = expo ? m.attention.filter((r) => EXPO_BUCKETS[expo](r._d)) : m.attention
if (state.status === 'error') { if (state.status === 'error') {
const said = describeError(state.error) const said = describeError(state.error)
return ( return (
@ -302,26 +318,43 @@ export default function Overview() {
</div> </div>
<h2 className="sec">Renewal exposure<span className="sec__hint">open leads by time to expiry</span></h2> <h2 className="sec">Renewal exposure<span className="sec__hint">open leads by time to expiry</span></h2>
<div className="buckets"> {/* The three buckets double as the filter: click one to narrow the
<div className="bucket bucket--lapsed"> table to it, click again (or Clear) to show every open renewal. A
<strong>{m.exposure.lapsed.length}</strong><span>Lapsed</span> bucket with nothing in it is not clickable there is nothing to
</div> show. */}
<div className="bucket bucket--urgent"> <div className="buckets" role="group" aria-label="Filter by time to expiry">
<strong>{m.exposure.week.length}</strong><span>Within 7 days</span> {[
</div> ['lapsed', 'lapsed', m.exposure.lapsed.length, 'Lapsed'],
<div className="bucket bucket--soon"> ['week', 'urgent', m.exposure.week.length, 'Within 7 days'],
<strong>{m.exposure.month.length}</strong><span>8 to 30 days</span> ['month', 'soon', m.exposure.month.length, '8 to 30 days'],
</div> ].map(([key, variant, count, label]) => (
<button
key={key}
type="button"
className={`bucket bucket--${variant}` + (expo === key ? ' is-active' : '')}
aria-pressed={expo === key}
disabled={!count}
onClick={() => setExpo(expo === key ? null : key)}
>
<strong>{count}</strong><span>{label}</span>
</button>
))}
</div> </div>
{m.attention.length ? ( {expo ? (
<button type="button" className="expo__clear" onClick={() => setExpo(null)}>
Showing {expoLabel(expo)} only · Clear filter
</button>
) : null}
{expoRows.length ? (
<div className="gridwrap"> <div className="gridwrap">
<table className="grid grid--tight"> <table className="grid grid--tight">
<thead> <thead>
<tr><th>Lead</th><th>Expiry</th><th>Stage</th><th className="num">Premium</th></tr> <tr><th>Lead</th><th>Expiry</th><th>Stage</th><th className="num">Premium</th></tr>
</thead> </thead>
<tbody> <tbody>
{m.attention.slice(0, 8).map((r) => { {expoRows.slice(0, expo ? 50 : 8).map((r) => {
const id = r.instance_id ?? r.id const id = r.instance_id ?? r.id
return ( return (
<tr <tr
@ -351,7 +384,9 @@ export default function Overview() {
</table> </table>
</div> </div>
) : ( ) : (
<p className="empty">No renewals due within 30 days.</p> <p className="empty">
{expo ? 'No open leads in this bucket.' : 'No renewals due within 30 days.'}
</p>
)} )}
</div> </div>

View File

@ -1059,6 +1059,42 @@
.bucket--soon { border-left-color: var(--zk-blue-mid); } .bucket--soon { border-left-color: var(--zk-blue-mid); }
.bucket--soon strong { color: var(--zk-blue-dark); } .bucket--soon strong { color: var(--zk-blue-dark); }
/* The buckets are filter toggles. Reset the button chrome, keep the card look,
and add hover / pressed / disabled states. */
.bucket {
width: 100%;
font: inherit;
text-align: left;
color: inherit;
cursor: pointer;
transition: background var(--t-fast), border-color var(--t-fast),
box-shadow var(--t-fast), transform var(--t-fast);
}
.bucket:hover:not(:disabled) { box-shadow: var(--sh-sm); }
.bucket:active:not(:disabled) { transform: translateY(1px); }
.bucket:focus-visible { outline: none; box-shadow: var(--ring); }
.bucket:disabled { cursor: default; opacity: 0.55; }
/* Pressed: fill with the bucket's own tint so the active filter is unmistakable. */
.bucket--lapsed.is-active { background: var(--zk-danger-tint); border-color: var(--zk-danger-line); border-left-color: var(--zk-danger); }
.bucket--urgent.is-active { background: var(--zk-amber-tint); border-color: var(--zk-amber-line); border-left-color: var(--zk-amber); }
.bucket--soon.is-active { background: var(--zk-tint-blue); border-color: var(--zk-blue-mid); border-left-color: var(--zk-blue-mid); }
/* The clear affordance under the buckets when a filter is on. */
.expo__clear {
margin: 10px 0 0;
padding: 0;
border: 0;
background: none;
cursor: pointer;
font: inherit;
font-size: var(--fs-2xs);
font-weight: 500;
color: var(--zk-blue-dark);
}
.expo__clear:hover { text-decoration: underline; }
.expo__clear:focus-visible { outline: none; text-decoration: underline; box-shadow: var(--ring); border-radius: var(--r-xs); }
/* Stage distribution. A bar per stage, scaled to the largest enough to see /* Stage distribution. A bar per stage, scaled to the largest enough to see
where the book is banked without pulling in a charting library. */ where the book is banked without pulling in a charting library. */
.dist { display: flex; flex-direction: column; gap: 3px; } .dist { display: flex; flex-direction: column; gap: 3px; }
@ -1691,6 +1727,24 @@
/* An action that belongs to one card, not to the panel. Quiet until hovered: /* An action that belongs to one card, not to the panel. Quiet until hovered:
the notes above it are the answer most of the time, and the transcript is the notes above it are the answer most of the time, and the transcript is
the follow-up question. */ the follow-up question. */
.glance__rec {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.glance__rec-l {
font-size: var(--fs-3xs);
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--zk-grey);
}
.glance__rec audio {
width: 100%;
height: 36px;
}
.glance__act { .glance__act {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;