zurich_kotak_pwa/src/screens/Lead.jsx
Yashas 3bc7d4c355 Everything the console can do, on the phone
The phone app shipped with the API layer and the timeline copied from the
console but none of its dialogs, so several things it appeared to offer did
nothing at all:

  - Tapping a WhatsApp exchange in the trail called an onOpenChat that was
    never passed. The entry rendered, said "Open the conversation — all 7
    messages", and there was no conversation to open.
  - No screen had a New lead button. The entry activity, its permissions and
    its form were all present and unreachable.
  - An agent chip opened nothing; the call transcript and the recording were
    unreachable; `call_transcript` was rendered as a field value, which is two
    thousand characters cut at a hundred and fifty.
  - The record showed its first three groups and truncated every long value
    mid-sentence. There was no way to see the other sixty-odd fields.
  - The action bar rendered only the stage's `do` actions, so Mark Lost, the
    re-quote, the reminders and every recovery lever were unreachable — four
    stages offered a partner agent one button and no way to drop a lead the
    system was working.
  - `nudgeFor` was called as nudgeFor(stage.uid, roles) rather than
    nudgeFor(stage, lead), so `stage.uid` was undefined, the switch fell
    through, and the way out of a stalled lead was never offered.
  - The journey rail marked every step left of the current one as done, which
    claims steps that never happened: a lead whose documents arrived from
    Qualified skips Contacted, and a callback sends it backwards.

The dialogs are COPIED from the console rather than rewritten — the
conversation, the call, the agent card, the lead file, the new-lead form, and
the support desk — for the same reason api/ and Timeline.jsx already were: the
functionality has to be identical, and a second implementation is a second
account of the same lead.

What makes that work on a phone is one CSS block rather than six components.
They all render the console's prose__scrim / prose__dlg chrome, so app.css
re-points that chrome at phone width into a bottom sheet: full width, rounded
top, a grab handle via ::before so the copied markup is untouched, and
env(safe-area-inset-bottom) under the last row. Every dialog in the app becomes
phone-native at once, including the two ClampText was already opening. Above
700px the console's centred dialog is kept, which is right for a tablet.

Also here: the secondary actions get a sheet of their own, grouped by what they
ARE (optional, "lead not moving?", the way out) rather than listed as equals;
the record's long values fold instead of being cut, and the whole file opens
with a filter; the rail reads the audit for which steps this lead actually
entered; the overview grows the sign-off band for the role that owns it; and
the drawer grows Ask the desk for ops.

Verified: build clean, lint unchanged at the app's own baseline of 15, every
class the copied dialogs use resolves in the bundled CSS, and every dialog's
prop contract matches its call site. Not verified in a browser — no browser
tooling in this session.

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

579 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useRef, useState } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { useZino } from '../api/provider.jsx'
import ActivityForm from '../components/ActivityForm.jsx'
import AgentCard from '../components/AgentCard.jsx'
import CallTranscript from '../components/CallTranscript.jsx'
import ClampText from '../components/ClampText.jsx'
import Conversation from '../components/Conversation.jsx'
import LeadFileDialog from '../components/LeadFileDialog.jsx'
import Timeline from '../components/Timeline.jsx'
import { APP_ID, DV_LEAD, STAGES, STALL_AFTER_MS, blockedOn, nudgeFor, phaseOf } from '../api/config.js'
import { actionsFor, rolesOf } from '../api/permissions.js'
import { describeError } from '../api/errors.js'
import { buildThread } from '../api/thread.js'
import { BLOCKER, GROUPS, LONG, MONEY, expiryOf, filesOf, fmt, inrShort, label } from '../api/lead.js'
/**
* One lead, on a phone.
*
* The desktop puts the story in a wide column with the record beside it. Here
* everything is one column, ordered by how often it is needed: what this lead
* wants from you, the four numbers, where it is in the journey, what happened,
* then the record. The action itself is pinned to the bottom of the screen —
* on a phone the thing you came to do must be reachable without scrolling back.
*
* WHAT IS ONE TAP AWAY RATHER THAN ON THE PAGE. Everything the console opens in
* a dialog is opened here as a bottom sheet, from the same components: the
* WhatsApp thread, the call, an agent's card, the complete record, and the
* actions that are not this stage's step. A phone cannot show them at once and
* must not therefore lack them — a lead that can only be advanced and never
* dropped is a worse tool than the desktop, not a smaller one.
*/
/** Who is holding it, in the words the header uses. */
function holdsOf(stage) {
if (!stage) return null
switch (stage.kind) {
case 'auto': return { tone: 'blue', label: 'With the AI' }
case 'customer': return { tone: 'teal', label: 'With the customer' }
case 'needs': return { tone: 'amber', label: stage.approval ? 'Awaiting your approval' : 'Waiting on a person' }
case 'waiting': return { tone: 'grey', label: 'In nurture' }
case 'end': return { tone: 'grey', label: 'Closed' }
default: return null
}
}
/* The journey, as the desktop rail states it. The side states — Parked,
Referred, Needs a Person, Lost, Declined — are deliberately not on it: they
are departures from the path rather than points along it, and a row that
included them would suggest every lead passes through. When the lead is in
one, the rail says so instead. */
const PATH = ['zk-state-new', 'zk-state-qualified', 'zk-state-contacted', 'zk-state-docs',
'zk-state-quoted', 'zk-state-payment', 'zk-state-issued', 'zk-state-onboarded']
const STEP = { 'zk-state-new': 'Filed', 'zk-state-qualified': 'Called', 'zk-state-contacted': 'Contacted',
'zk-state-docs': 'Documents', 'zk-state-quoted': 'Quoted', 'zk-state-payment': 'Payment',
'zk-state-issued': 'Issued', 'zk-state-onboarded': 'Onboarded' }
function PhoneGlyph() {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M3.4 2.8h2.4l1.2 3-1.5 1a7.5 7.5 0 0 0 3.7 3.7l1-1.5 3 1.2v2.4a1 1 0 0 1-1.1 1A10.6 10.6 0 0 1 2.4 3.9a1 1 0 0 1 1-1.1Z"
fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
</svg>
)
}
export default function Lead() {
const { instanceId } = useParams()
const { client, user } = useZino()
const roles = rolesOf(user)
const navigate = useNavigate()
const location = useLocation()
const [note, setNote] = useState(location.state?.message ?? null)
const [row, setRow] = useState(null)
const [err, setErr] = useState(null)
const [audit, setAudit] = useState(null)
const [labels, setLabels] = useState({})
const [open, setOpen] = useState(null) // the activity whose form is up
// The sheets. `chat` carries which exchange to open the thread at — an
// episode id from the trail, or null for "at the newest message".
const [chat, setChat] = useState(null)
const [openAgent, setOpenAgent] = useState(null)
const [showCall, setShowCall] = useState(false)
const [showFile, setShowFile] = useState(false)
const [more, setMore] = useState(false)
const railRef = useRef(null)
// Sampled on a tick rather than read during render, so the "nothing for N
// minutes" line is stable within a paint.
const [now, setNow] = useState(() => Date.now())
const load = useCallback((quiet = false) => {
if (!quiet) setErr(null)
// Best-effort and never awaited with the record: a failed audit call must
// not blank the lead, and a slow one must not hold up the fields.
client.audit(instanceId)
.then((r) => setAudit(Array.isArray(r) ? r : (r?.data ?? [])))
.catch(() => { /* keep whatever is on screen */ })
return client.detailView(DV_LEAD, instanceId)
.then((r) => {
setRow(r?.data ?? r?.record ?? r)
// The detail view ships an output_label per field, so a field renamed
// in Studio is renamed here without a release.
const fields = r?.config?.fields
if (Array.isArray(fields)) {
setLabels(Object.fromEntries(
fields.filter((f) => f.field_key && f.output_label).map((f) => [f.field_key, f.output_label]),
))
}
})
.catch((e) => { if (!quiet) setErr(e) })
}, [client, instanceId])
useEffect(() => { load() }, [load])
// The AI works this lead in the background, so the page has to keep up with
// it. Twelve seconds is the compromise between "it moved" and battery; the
// poll is quiet, so a dropped request leaves the screen exactly as it is.
//
// Not while a form is up: a poll that lands mid-submission re-renders the
// sheet under whoever is typing in it.
useEffect(() => {
if (open) return undefined
const id = setInterval(() => { load(true); setNow(Date.now()) }, 12000)
return () => clearInterval(id)
}, [load, open])
// The rail scrolls, so the step the lead is actually ON has to be brought
// into view; otherwise a lead at Payment opens showing Filed.
useEffect(() => {
const el = railRef.current?.querySelector('.is-here')
el?.scrollIntoView({ block: 'nearest', inline: 'center' })
}, [row?.current_state_name])
if (err) {
return (
<div className="page">
<div className="notice">
<strong>Unable to load this lead.</strong>
<p>{describeError(err).title} · {err?.status} {err?.message}</p>
</div>
</div>
)
}
if (!row) {
return <div className="page"><div className="skel" aria-busy="true"><i /><i /><i /></div></div>
}
const stateName = row.current_state_name || ''
const stage = phaseOf(STAGES.find((s) => s.name === stateName), row)
const holds = holdsOf(stage)
const blocked = stage?.kind === 'auto' ? blockedOn(row) : null
// "How long has this been still?" is read from the clock, which makes it
// impure in a render body. It is recomputed on every poll instead — which is
// also the only moment it can have changed.
//
// nudgeFor takes the STAGE and the LEAD. Called with a uid and the role list,
// as it was, `stage.uid` is undefined, the switch falls through, and the way
// out of a stalled lead was never offered on the phone at all.
const stalled = !blocked && stage?.kind === 'auto' && now - Date.parse(row.updated_at || 0) > STALL_AFTER_MS
? { mins: Math.round((now - Date.parse(row.updated_at || 0)) / 60000), nudge: nudgeFor(stage, row) }
: null
/* WHAT EACH ACTION IS, not merely what this role may press. `do` is the step
the stage is waiting on, `again` a bounded loop, `force` an AI's own job
offered only so a stalled lead can be pushed by hand, `exit` is Mark Lost.
The phone rendered only `do`, so four stages offered a partner agent one
button and no way to drop a lead the system was working. */
const all = actionsFor(roles, stage?.uid)
// Once the documents are in, the upload is not the step and not an
// alternative to it: it is recovery, for a wrong file or one more.
const docsDone = stage?.after === 'documents received'
// Recording an acceptance twice is not a loop — the customer accepted at a
// stated time and the lead carries the reference.
const accepted = Boolean(row.acceptance_ref)
const shown = all
.filter((a) => !(accepted && a.uid === 'zk-act-accept'))
.map((a) => (docsDone && a.uid === 'zk-act-collect-docs'
? { ...a, role: 'force', label: 'Replace or add a document', by: 'you' }
: a))
const step = shown.filter((a) => a.role === 'do')
const again = shown.filter((a) => a.role === 'again')
const force = shown.filter((a) => a.role === 'force')
const exit = shown.filter((a) => a.role === 'exit')
const primary = step[0] ?? null
const secondary = [...again, ...force, ...exit]
const hasBar = Boolean(primary || blocked || stalled?.nudge || secondary.length)
const e = expiryOf(row.renewal_due_date)
const premium = inrShort(row.quoted_premium)
const commission = inrShort(row.commission_amount)
const conf = Number(row.ai_recommendation_confidence)
const dueTone = e ? (e.tone === 'lapsed' ? 'red' : e.tone === 'urgent' ? 'amber' : '') : ''
/* PASSED IS READ FROM THE AUDIT, NOT ASSUMED FROM THE ORDER. A lead can skip
— documents uploaded from Qualified carry one straight past Contacted —
and can go backwards. Marking everything left of the current step as done
claimed steps that never happened. */
const visited = new Set((audit || []).map((r) => r.execution_state).filter((v) => PATH.includes(v)))
if (stage?.uid) visited.add(stage.uid)
const here = PATH.indexOf(stage?.uid)
const offPath = here < 0
// The record, grouped, empty groups dropped. The same rows feed the card
// below and the full-file sheet, so the two cannot disagree about a value.
const groups = GROUPS
// `v !== null`, the same test the console uses, rather than "has files or a
// truthy value": the full-file sheet measures v.length to decide what is
// prose, and a row admitted on its files alone with a null value throws
// there rather than here.
.map(([title, keys]) => [title, keys.map((k) => [k, fmt(k, row[k]), filesOf(row[k])]).filter(([, v]) => v !== null)])
.filter(([, rows]) => rows.length)
const fieldCount = groups.reduce((n, [, rows]) => n + rows.length, 0)
const glance = groups.slice(0, 3)
// Counted off the THREAD, so the button and the sheet agree: scanning the
// audit rows counts the platform's own repeats, one submission being
// recorded up to three times.
const chatTurns = buildThread(audit).turns.length
const actionSheet = (uid) => { setMore(false); setOpen(uid) }
return (
<div className={'page' + (hasBar ? ' page--acts' : '')}>
<header className="lhead">
<button
type="button" className="back" aria-label="Back"
onClick={() => (location.key === 'default' ? navigate('/') : navigate(-1))}
>
<svg viewBox="0 0 16 16" fill="none"><path d="M9.5 3.5 5 8l4.5 4.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>
</button>
<div className="lhead__id">
<h1>{row.customer_name || row.lead_ref || `Lead ${instanceId}`}</h1>
<p>
{row.lead_ref || `Lead ${instanceId}`}
{row.product_line ? ` · ${row.product_line === 'motor' ? 'Motor' : 'SME'}` : ''}
</p>
{/* The stage and its holder sit UNDER the name, not beside it: a
customer name is as long as it is, and squeezing a pill in next to
it wrapped "Priya Raghavan" onto two lines to make room. */}
<div className="lhead__st">
<span>{stateName}</span>
{holds ? <span className={'pill pill--' + holds.tone}><span className={'dot dot--' + holds.tone} />{holds.label}</span> : null}
</div>
</div>
</header>
{note ? (
<div className="said" role="status">
<p>{note}</p>
<button type="button" onClick={() => setNote(null)} aria-label="Dismiss">×</button>
</div>
) : null}
{/* ONE banner. Whatever this lead needs, it says it once, here. */}
{blocked ? (
<div className="banner banner--amber" role="status">
<h3><span className="dot dot--amber" />Stopped: {blocked.what}</h3>
<p>{blocked.fix} The agents pick it up again on their own once it is there.</p>
</div>
) : stalled ? (
<div className="banner banner--red" role="status">
<h3><span className="dot dot--red" />Nothing for {stalled.mins} minutes</h3>
<p>
{stage.by} has not come back. That is usually the model provider being slow, not a
problem with this lead the work so far is safe.
{stalled.nudge ? ` Running ${stalled.nudge.label.toLowerCase()} wakes ${stalled.nudge.by} to try again.` : ''}
</p>
</div>
) : stage?.uid === 'zk-state-review' ? (
<div className="banner banner--amber" role="status">
<h3><span className="dot dot--amber" />The AI stopped and asked for a person</h3>
{row.review_reason ? <p className="banner__said">{row.review_reason}</p> : null}
<p>{BLOCKER[row.review_blocker] ?? 'It could not complete its step.'} Nothing is running on this lead.</p>
</div>
) : primary ? (
<div className="banner banner--amber" role="status">
<h3><span className="dot dot--amber" />Your turn: {primary.label}</h3>
<p>This lead is waiting on you; the AI carries on as soon as it is done.</p>
</div>
) : stage?.kind === 'customer' && stage.doing ? (
<div className="banner banner--teal" role="status">
<h3><span className="dot dot--teal" />{stage.doing}</h3>
<p>Their answer comes back on its own. Nothing is waiting on you.</p>
</div>
) : stage?.kind === 'auto' ? (
<div className="banner banner--blue" role="status">
<h3><span className="dot dot--blue" />{stage.doing}</h3>
<p>Handled by {stage.by}. Usually done within two minutes; this refreshes itself.</p>
</div>
) : null}
<section className="card facts" style={{ padding: '4px 0' }}>
<div className="fact">
<span>Premium</span>
<b className={premium ? '' : 'is-none'}>{premium ?? 'Not yet rated'}</b>
<small>{premium ? 'quoted, incl. GST' : 'once Rating has run'}</small>
</div>
<div className="fact">
<span>Commission</span>
<b className={commission ? '' : 'is-none'}>{commission ?? '—'}</b>
<small>{row.commission_rate_pct ? row.commission_rate_pct + '% · on issue' : 'on placement'}</small>
</div>
<div className="fact">
<span>AI confidence</span>
{Number.isFinite(conf) && conf > 0 ? (
<>
<b>{conf}%</b>
<span className="fact__tr" aria-hidden="true"><i style={{ width: Math.min(100, conf) + '%' }} /></span>
<small>on the cover advice</small>
</>
) : (<><b className="is-none"></b><small>once the Advisor has spoken</small></>)}
</div>
<div className="fact">
<span>Renewal due</span>
<b className={dueTone ? 'due--' + dueTone : ''}>{e ? e.label : '—'}</b>
<small>{e ? e.on : 'no date on file'}</small>
</div>
</section>
<section className="card steps" style={{ padding: '15px 0 13px' }}>
<div className="steps__t">
<strong>Journey</strong>
<span>{offPath ? stateName : `Step ${here + 1} of ${PATH.length}`}</span>
{holds ? <span className={'pill pill--' + holds.tone}>{holds.label}</span> : null}
</div>
<div className="steps__rail" ref={railRef}>
{PATH.map((uid, i) => {
// Ahead of a lead that has left the path is unknowable — a referred
// risk may never see payment — so nothing is called done there.
const been = visited.has(uid)
const isHere = uid === stage?.uid
return (
<div key={uid} className={'step' + (isHere ? ' is-here' : been ? ' is-done' : '')}>
<i /><span>{i + 1}. {STEP[uid]}</span>
</div>
)
})}
</div>
</section>
<section className="card" style={{ marginTop: 12 }}>
<h3 className="card__h">What happened</h3>
<p className="card__s">Every step on this lead, who took it, and what they wrote.</p>
{/* The conversation, reachable from the head as well as from its own
entry in the trail: on a lead worked over two days the exchange is
a long scroll down. */}
{chatTurns ? (
<button type="button" className="kv__call" style={{ marginBottom: 12 }} onClick={() => setChat({ anchor: null })}>
<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>
WhatsApp conversation
<em style={{ fontStyle: 'normal', color: 'var(--zk-grey)', fontWeight: 400 }}>
{chatTurns} message{chatTurns === 1 ? '' : 's'}
</em>
</button>
) : null}
<Timeline
rows={audit}
customerName={row.customer_name}
onOpenAgent={setOpenAgent}
onOpenChat={(episode) => setChat({ anchor: episode ?? null })}
onOpenCall={() => setShowCall(true)}
/>
</section>
{groups.length ? (
<section className="card" style={{ marginTop: 12 }}>
<h3 className="card__h">The record</h3>
<p className="card__s">The customer, the risk, and what this stage turns on.</p>
<div className="kv">
{glance.map(([title, rows]) => (
<section className="kv__g" key={title}>
<h4>{title}</h4>
{rows.map(([k, v, files]) => (
<div className="kv__r" key={k}>
<dt>{labels[k] ?? label(k)}</dt>
{files.length ? (
<dd>
{files.map((f) => (
<a key={f.uuid} className="kv__file" target="_blank" rel="noreferrer"
href={`${client.baseUrl}/app/${APP_ID}/view/files/${f.uuid}/preview`}>
{f.original_name || 'Document'}
</a>
))}
</dd>
) : k === 'call_transcript' ? (
/* A transcript is not a value. Printed as one it was two
thousand characters cut at a hundred and fifty. */
<dd>
<button type="button" className="kv__call" onClick={() => setShowCall(true)}>
<PhoneGlyph />Read what was said
</button>
</dd>
) : (
<dd className={MONEY.has(k) ? 'kv__num' : undefined}>
{/* Folded rather than cut: a stage reason runs to a
paragraph, and half a sentence teaches the reader
that this panel is unreliable. */}
{v.length > LONG
? <ClampText text={v} title={labels[k] ?? label(k)} lines={3} />
: v}
</dd>
)}
</div>
))}
</section>
))}
</div>
{fieldCount ? (
<button type="button" className="kv__all" onClick={() => setShowFile(true)}>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M3 3.5h10M3 8h10M3 12.5h6" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
Open the full lead file
<b>{fieldCount} fields</b>
</button>
) : null}
</section>
) : null}
{/* THE ACTION, pinned. On a phone the thing you came to do must be
reachable from wherever you have scrolled to — and so must the way
out, which is what the overflow button holds. */}
{hasBar ? (
<div className="actbar">
{blocked ? (
<button type="button" className="btn btn--navy" onClick={() => setOpen(blocked.via)}>
Open Collect Documents
</button>
) : stalled?.nudge ? (
<button type="button" className="btn btn--navy" onClick={() => setOpen(stalled.nudge.uid)}>
{stalled.nudge.label}
</button>
) : primary ? (
<>
<button type="button" className="btn btn--navy" onClick={() => setOpen(primary.uid)}>{primary.label}</button>
{step.slice(1).map((a) => (
<button key={a.uid} type="button" className="btn btn--danger" onClick={() => setOpen(a.uid)}>{a.label}</button>
))}
</>
) : (
/* Nothing is owed by this role, so the bar carries only the way
out — never dressed as the thing to do. */
<button type="button" className="btn btn--ghost" onClick={() => setMore(true)}>Other actions</button>
)}
{(primary || blocked || stalled?.nudge) && secondary.length ? (
<button
type="button" className="btn btn--ghost btn--more"
aria-label="Other actions" onClick={() => setMore(true)}
>
<svg viewBox="0 0 16 16" aria-hidden="true" style={{ width: 18, height: 18 }}>
<circle cx="8" cy="3.2" r="1.4" fill="currentColor" />
<circle cx="8" cy="8" r="1.4" fill="currentColor" />
<circle cx="8" cy="12.8" r="1.4" fill="currentColor" />
</svg>
</button>
) : null}
</div>
) : null}
{/* The actions that are not this stage's step, by what they are. */}
{more ? (
<>
<div className="scrim" style={{ zIndex: 60 }} role="presentation" onClick={() => setMore(false)} />
<div className="sheet" role="dialog" aria-label="Other actions">
<span className="sheet__grab" aria-hidden="true" />
<div className="sheet__head">
<div>
<h2>Other actions</h2>
<p>{stateName}</p>
</div>
<button type="button" className="sheet__x" aria-label="Close" onClick={() => setMore(false)}>×</button>
</div>
<div className="sheet__body">
<div className="acts">
{again.length ? (
<>
<h4 className="acts__h">Optional</h4>
{again.map((a) => (
<button key={a.uid} type="button" className="act" onClick={() => actionSheet(a.uid)}>
{a.label}<em>{a.by}</em>
</button>
))}
</>
) : null}
{force.length ? (
<>
<h4 className="acts__h">Lead not moving?</h4>
<p className="acts__why">
These normally run by themselves. Use one only if this lead has been
sitting longer than it should.
</p>
{force.map((a) => (
<button key={a.uid} type="button" className="act" onClick={() => actionSheet(a.uid)}>
{a.label}<em>{a.by}</em>
</button>
))}
</>
) : null}
{exit.length ? (
<>
<h4 className="acts__h">Close it</h4>
{exit.map((a) => (
<button key={a.uid} type="button" className="act act--exit" onClick={() => actionSheet(a.uid)}>
{a.label}<em>{a.by}</em>
</button>
))}
</>
) : null}
</div>
</div>
</div>
</>
) : null}
{open ? (
<>
<div className="scrim" style={{ zIndex: 60 }} role="presentation" onClick={() => setOpen(null)} />
<div className="sheet" role="dialog" aria-label="Action">
<span className="sheet__grab" aria-hidden="true" />
<div className="sheet__head">
<div>
<h2>{shown.find((a) => a.uid === open)?.label ?? 'Action'}</h2>
<p>{row.customer_name || row.lead_ref}</p>
</div>
<button type="button" className="sheet__x" aria-label="Close" onClick={() => setOpen(null)}>×</button>
</div>
<div className="sheet__body">
<ActivityForm
activityUid={open}
instanceId={Number(instanceId)}
lead={row}
onCancel={() => setOpen(null)}
onStale={() => { setOpen(null); setNote('Stage changed. The actions have been refreshed.'); load() }}
onDone={(res) => { setOpen(null); setNote(res?.message ?? null); load() }}
/>
</div>
</div>
</>
) : null}
{chat ? (
<Conversation
rows={audit}
name={row.customer_name}
anchor={chat.anchor}
onClose={() => setChat(null)}
/>
) : null}
{openAgent ? <AgentCard agentKey={openAgent} onClose={() => setOpenAgent(null)} /> : null}
{showCall ? (
<CallTranscript
text={row.call_transcript}
audio={row.call_recording_url}
name={row.customer_name}
onClose={() => setShowCall(false)}
/>
) : null}
{showFile ? (
<LeadFileDialog
groups={groups}
labels={labels}
label={label}
money={MONEY}
longAt={LONG}
fileHref={(f) => `${client.baseUrl}/app/${APP_ID}/view/files/${f.uuid}/preview`}
onClose={() => setShowFile(false)}
/>
) : null}
</div>
)
}