mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
feat: supervisor module — team performance, live monitor, master data pages
- Admin sidebar restructured: Supervisor + Data & Reports + Admin groups - Team Performance (PP-5): 6 sections — KPIs, call trends, agent table, time breakdown, NPS/conversion, performance alerts - Live Call Monitor (PP-6): polling active calls, KPI cards, action buttons - Call Recordings: filtered call table with inline audio player - Missed Calls: supervisor view with status tabs and SLA tracking Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
167
src/pages/call-recordings.tsx
Normal file
167
src/pages/call-recordings.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { faMagnifyingGlass, faPlay, faPause } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faIcon } from '@/lib/icon-wrapper';
|
||||
|
||||
const SearchLg = faIcon(faMagnifyingGlass);
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Table } from '@/components/application/table/table';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { formatPhone } from '@/lib/format';
|
||||
|
||||
type RecordingRecord = {
|
||||
id: string;
|
||||
direction: string | null;
|
||||
callStatus: string | null;
|
||||
callerNumber: { primaryPhoneNumber: string } | null;
|
||||
agentName: string | null;
|
||||
startedAt: string | null;
|
||||
durationSec: number | null;
|
||||
disposition: string | null;
|
||||
recording: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
|
||||
};
|
||||
|
||||
const QUERY = `{ calls(first: 200, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||
id direction callStatus callerNumber { primaryPhoneNumber }
|
||||
agentName startedAt durationSec disposition
|
||||
recording { primaryLinkUrl primaryLinkLabel }
|
||||
} } } }`;
|
||||
|
||||
const formatDate = (iso: string): string =>
|
||||
new Date(iso).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
|
||||
const formatDuration = (sec: number | null): string => {
|
||||
if (!sec) return '—';
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const RecordingPlayer = ({ url }: { url: string }) => {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
|
||||
const toggle = () => {
|
||||
if (!audioRef.current) return;
|
||||
if (playing) { audioRef.current.pause(); } else { audioRef.current.play(); }
|
||||
setPlaying(!playing);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={toggle} className="flex size-7 items-center justify-center rounded-full bg-brand-solid text-white hover:opacity-90 transition duration-100 ease-linear">
|
||||
<FontAwesomeIcon icon={playing ? faPause : faPlay} className="size-3" />
|
||||
</button>
|
||||
<audio ref={audioRef} src={url} onEnded={() => setPlaying(false)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CallRecordingsPage = () => {
|
||||
const [calls, setCalls] = useState<RecordingRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
apiClient.graphql<{ calls: { edges: Array<{ node: RecordingRecord }> } }>(QUERY, undefined, { silent: true })
|
||||
.then(data => {
|
||||
const withRecordings = data.calls.edges
|
||||
.map(e => e.node)
|
||||
.filter(c => c.recording?.primaryLinkUrl);
|
||||
setCalls(withRecordings);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search.trim()) return calls;
|
||||
const q = search.toLowerCase();
|
||||
return calls.filter(c =>
|
||||
(c.agentName ?? '').toLowerCase().includes(q) ||
|
||||
(c.callerNumber?.primaryPhoneNumber ?? '').includes(q),
|
||||
);
|
||||
}, [calls, search]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar title="Call Recordings" />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-secondary px-6 py-3">
|
||||
<span className="text-sm text-tertiary">{filtered.length} recordings</span>
|
||||
<div className="w-56">
|
||||
<Input placeholder="Search agent or phone..." icon={SearchLg} size="sm" value={search} onChange={setSearch} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 pt-3">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-sm text-tertiary">Loading recordings...</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-sm text-quaternary">{search ? 'No matching recordings' : 'No call recordings found'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table size="sm">
|
||||
<Table.Header>
|
||||
<Table.Head label="Agent" isRowHeader />
|
||||
<Table.Head label="Caller" />
|
||||
<Table.Head label="Type" className="w-20" />
|
||||
<Table.Head label="Date" className="w-28" />
|
||||
<Table.Head label="Duration" className="w-20" />
|
||||
<Table.Head label="Disposition" className="w-32" />
|
||||
<Table.Head label="Recording" className="w-24" />
|
||||
</Table.Header>
|
||||
<Table.Body items={filtered}>
|
||||
{(call) => {
|
||||
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
|
||||
const dirLabel = call.direction === 'INBOUND' ? 'In' : 'Out';
|
||||
const dirColor = call.direction === 'INBOUND' ? 'blue' : 'brand';
|
||||
|
||||
return (
|
||||
<Table.Row id={call.id}>
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-primary">{call.agentName || '—'}</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{phone ? (
|
||||
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
|
||||
) : <span className="text-xs text-quaternary">—</span>}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge size="sm" color={dirColor} type="pill-color">{dirLabel}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-primary">{call.startedAt ? formatDate(call.startedAt) : '—'}</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-primary">{formatDuration(call.durationSec)}</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{call.disposition ? (
|
||||
<Badge size="sm" color="gray" type="pill-color">
|
||||
{call.disposition.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())}
|
||||
</Badge>
|
||||
) : <span className="text-xs text-quaternary">—</span>}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{call.recording?.primaryLinkUrl && (
|
||||
<RecordingPlayer url={call.recording.primaryLinkUrl} />
|
||||
)}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user