mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +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:
178
src/pages/live-monitor.tsx
Normal file
178
src/pages/live-monitor.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faHeadset } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { Table } from '@/components/application/table/table';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
|
||||
type ActiveCall = {
|
||||
ucid: string;
|
||||
agentId: string;
|
||||
callerNumber: string;
|
||||
callType: string;
|
||||
startTime: string;
|
||||
status: 'active' | 'on-hold';
|
||||
};
|
||||
|
||||
const formatDuration = (startTime: string): string => {
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - new Date(startTime).getTime()) / 1000));
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const KpiCard = ({ value, label }: { value: string | number; label: string }) => (
|
||||
<div className="flex flex-1 flex-col items-center justify-center rounded-xl border border-secondary bg-primary py-6">
|
||||
<p className="text-3xl font-bold text-primary">{value}</p>
|
||||
<p className="text-xs text-tertiary mt-1">{label}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const LiveMonitorPage = () => {
|
||||
const [activeCalls, setActiveCalls] = useState<ActiveCall[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tick, setTick] = useState(0);
|
||||
const { leads } = useData();
|
||||
|
||||
// Poll active calls every 5 seconds
|
||||
useEffect(() => {
|
||||
const fetchCalls = () => {
|
||||
apiClient.get<ActiveCall[]>('/api/supervisor/active-calls', { silent: true })
|
||||
.then(setActiveCalls)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
fetchCalls();
|
||||
const interval = setInterval(fetchCalls, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Tick every second to update duration counters
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setTick(t => t + 1), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const onHold = activeCalls.filter(c => c.status === 'on-hold').length;
|
||||
const avgDuration = useMemo(() => {
|
||||
if (activeCalls.length === 0) return '0:00';
|
||||
const totalSec = activeCalls.reduce((sum, c) => {
|
||||
return sum + Math.max(0, Math.floor((Date.now() - new Date(c.startTime).getTime()) / 1000));
|
||||
}, 0);
|
||||
const avg = Math.floor(totalSec / activeCalls.length);
|
||||
return `${Math.floor(avg / 60)}:${(avg % 60).toString().padStart(2, '0')}`;
|
||||
}, [activeCalls, tick]);
|
||||
|
||||
// Match caller to lead
|
||||
const resolveCallerName = (phone: string): string | null => {
|
||||
if (!phone) return null;
|
||||
const clean = phone.replace(/\D/g, '');
|
||||
const lead = leads.find(l => {
|
||||
const lp = (l.contactPhone?.[0]?.number ?? '').replace(/\D/g, '');
|
||||
return lp && (lp.endsWith(clean) || clean.endsWith(lp));
|
||||
});
|
||||
if (lead) {
|
||||
return `${lead.contactName?.firstName ?? ''} ${lead.contactName?.lastName ?? ''}`.trim() || null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar title="Live Call Monitor" subtitle="Listen, whisper, or barge into active calls" />
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||
{/* KPI Cards */}
|
||||
<div className="px-6 pt-5">
|
||||
<div className="flex gap-4">
|
||||
<KpiCard value={activeCalls.length} label="Active Calls" />
|
||||
<KpiCard value={onHold} label="On Hold" />
|
||||
<KpiCard value={avgDuration} label="Avg Duration" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Calls Table */}
|
||||
<div className="px-6 pt-6">
|
||||
<h3 className="text-sm font-semibold text-secondary mb-3">Active Calls</h3>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-sm text-tertiary">Loading...</p>
|
||||
</div>
|
||||
) : activeCalls.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center rounded-xl border border-secondary bg-primary">
|
||||
<FontAwesomeIcon icon={faHeadset} className="size-12 text-fg-quaternary mb-4" />
|
||||
<p className="text-sm font-medium text-secondary">No active calls</p>
|
||||
<p className="text-xs text-tertiary mt-1">Active calls will appear here in real-time</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table size="sm">
|
||||
<Table.Header>
|
||||
<Table.Head label="Agent" isRowHeader />
|
||||
<Table.Head label="Caller" />
|
||||
<Table.Head label="Type" className="w-16" />
|
||||
<Table.Head label="Duration" className="w-20" />
|
||||
<Table.Head label="Status" className="w-24" />
|
||||
<Table.Head label="Actions" className="w-48" />
|
||||
</Table.Header>
|
||||
<Table.Body items={activeCalls}>
|
||||
{(call) => {
|
||||
const callerName = resolveCallerName(call.callerNumber);
|
||||
const typeLabel = call.callType === 'InBound' ? 'In' : 'Out';
|
||||
const typeColor = call.callType === 'InBound' ? 'blue' : 'brand';
|
||||
|
||||
return (
|
||||
<Table.Row id={call.ucid}>
|
||||
<Table.Cell>
|
||||
<span className="text-sm font-medium text-primary">{call.agentId}</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div>
|
||||
{callerName && <span className="text-sm font-medium text-primary block">{callerName}</span>}
|
||||
<span className="text-xs text-tertiary">{call.callerNumber}</span>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge size="sm" color={typeColor} type="pill-color">{typeLabel}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className="text-sm font-mono text-primary">{formatDuration(call.startTime)}</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge size="sm" color={call.status === 'on-hold' ? 'warning' : 'success'} type="pill-color">
|
||||
{call.status}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" color="secondary" isDisabled title="Coming soon — pending Ozonetel API">Listen</Button>
|
||||
<Button size="sm" color="secondary" isDisabled title="Coming soon — pending Ozonetel API">Whisper</Button>
|
||||
<Button size="sm" color="primary-destructive" isDisabled title="Coming soon — requires supervisor SIP extension">Barge</Button>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Monitoring hint */}
|
||||
{activeCalls.length > 0 && (
|
||||
<div className="px-6 pt-6 pb-8">
|
||||
<div className="flex flex-col items-center justify-center py-8 rounded-xl border border-secondary bg-secondary_alt text-center">
|
||||
<FontAwesomeIcon icon={faHeadset} className="size-8 text-fg-quaternary mb-3" />
|
||||
<p className="text-sm text-secondary">Select "Listen" on any active call to start monitoring</p>
|
||||
<p className="text-xs text-tertiary mt-1">Agent will not be notified during listen mode</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user