mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
331 lines
16 KiB
TypeScript
331 lines
16 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import type { FC } from 'react';
|
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
import {
|
|
faPhoneArrowDown,
|
|
faPhoneArrowUp,
|
|
faPhoneXmark,
|
|
faPlay,
|
|
faPause,
|
|
faMagnifyingGlass,
|
|
} from '@fortawesome/pro-duotone-svg-icons';
|
|
|
|
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
|
import { Table } from '@/components/application/table/table';
|
|
import { Badge } from '@/components/base/badges/badges';
|
|
import { Button } from '@/components/base/buttons/button';
|
|
import { Input } from '@/components/base/input/input';
|
|
import { Select } from '@/components/base/select/select';
|
|
import { PageHeader } from '@/components/layout/page-header';
|
|
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
|
import { formatShortDate, formatPhone } from '@/lib/format';
|
|
// cx removed — no longer used after SLA column removal
|
|
import { useData } from '@/providers/data-provider';
|
|
import { useAuth } from '@/providers/auth-provider';
|
|
import { PaginationCardDefault } from '@/components/application/pagination/pagination';
|
|
import type { Call, CallDirection, CallDisposition } from '@/types/entities';
|
|
|
|
type FilterKey = 'all' | 'inbound' | 'outbound' | 'missed';
|
|
|
|
const allFilterItems = [
|
|
{ id: 'all' as const, label: 'All Calls' },
|
|
{ id: 'inbound' as const, label: 'Inbound' },
|
|
{ id: 'outbound' as const, label: 'Outbound' },
|
|
{ id: 'missed' as const, label: 'Missed' },
|
|
];
|
|
|
|
const agentFilterItems = [
|
|
{ id: 'all' as const, label: 'All Calls' },
|
|
{ id: 'inbound' as const, label: 'Inbound' },
|
|
{ id: 'outbound' as const, label: 'Outbound' },
|
|
];
|
|
|
|
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
|
|
APPOINTMENT_BOOKED: { label: 'Appt Booked', color: 'success' },
|
|
APPOINTMENT_RESCHEDULED: { label: 'Appt Rescheduled', color: 'warning' },
|
|
APPOINTMENT_CANCELLED: { label: 'Appt Cancelled', color: 'error' },
|
|
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
|
|
INFO_PROVIDED: { label: 'Info Provided', color: 'blue-light' },
|
|
NO_ANSWER: { label: 'No Answer', color: 'warning' },
|
|
WRONG_NUMBER: { label: 'Wrong Number', color: 'gray' },
|
|
NOT_INTERESTED: { label: 'Not Interested', color: 'error' },
|
|
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
|
|
CALL_DROPPED: { label: 'Call Dropped', color: 'gray' },
|
|
};
|
|
|
|
const formatDuration = (seconds: number | null): string => {
|
|
if (seconds === null || seconds === 0) return '\u2014';
|
|
if (seconds < 60) return `${seconds}s`;
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = seconds % 60;
|
|
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
|
|
};
|
|
|
|
const DirectionIcon: FC<{ direction: CallDirection | null; status: Call['callStatus'] }> = ({ direction, status }) => {
|
|
if (status === 'MISSED') {
|
|
return <FontAwesomeIcon icon={faPhoneXmark} className="size-4 text-fg-error-secondary" />;
|
|
}
|
|
if (direction === 'OUTBOUND') {
|
|
return <FontAwesomeIcon icon={faPhoneArrowUp} className="size-4 text-fg-brand-secondary" />;
|
|
}
|
|
return <FontAwesomeIcon icon={faPhoneArrowDown} className="size-4 text-fg-success-secondary" />;
|
|
};
|
|
|
|
const RecordingPlayer: FC<{ url: string }> = ({ url }) => {
|
|
const audioRef = useRef<HTMLAudioElement>(null);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
|
|
const togglePlay = () => {
|
|
const audio = audioRef.current;
|
|
if (!audio) return;
|
|
if (isPlaying) {
|
|
audio.pause();
|
|
setIsPlaying(false);
|
|
} else {
|
|
audio.play().then(() => setIsPlaying(true)).catch(() => {});
|
|
}
|
|
};
|
|
|
|
const handleEnded = () => setIsPlaying(false);
|
|
|
|
return (
|
|
<>
|
|
<audio ref={audioRef} src={url} preload="none" onEnded={handleEnded} />
|
|
<Button
|
|
size="sm"
|
|
color="tertiary"
|
|
iconLeading={
|
|
<FontAwesomeIcon
|
|
icon={isPlaying ? faPause : faPlay}
|
|
data-icon
|
|
className="size-3.5"
|
|
/>
|
|
}
|
|
onClick={togglePlay}
|
|
aria-label={isPlaying ? 'Pause recording' : 'Play recording'}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const PAGE_SIZE = 20;
|
|
|
|
export const CallHistoryPage = () => {
|
|
const { calls, leads } = useData();
|
|
const { user, isAdmin } = useAuth();
|
|
const [search, setSearch] = useState('');
|
|
const [filter, setFilter] = useState<FilterKey>('all');
|
|
const [page, setPage] = useState(1);
|
|
|
|
const leadNameMap = useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
for (const lead of leads) {
|
|
if (lead.id && lead.contactName) {
|
|
const name = `${lead.contactName.firstName ?? ''} ${lead.contactName.lastName ?? ''}`.trim();
|
|
if (name) map.set(lead.id, name);
|
|
}
|
|
}
|
|
return map;
|
|
}, [leads]);
|
|
|
|
// Agent sees only their own calls; supervisor sees all
|
|
const agentConfig = localStorage.getItem('helix_agent_config');
|
|
const myAgentId = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
|
|
|
|
const filteredCalls = useMemo(() => {
|
|
let result = [...calls].sort((a, b) => {
|
|
const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
|
const dateB = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
|
return dateB - dateA;
|
|
});
|
|
|
|
// CC agent: filter to own calls only.
|
|
// Match on the authoritative agent relation (set by CDR enrichment)
|
|
// or the raw agentName for unenriched rows. Chain names like
|
|
// "RamaiahAdmin -> GlobalHealthX" are split — last segment is
|
|
// the final handler. Missed calls have no handler and are excluded
|
|
// from the agent's personal history (they belong on the Missed
|
|
// Calls queue).
|
|
if (!isAdmin && myAgentId) {
|
|
const myId = myAgentId.toLowerCase();
|
|
result = result.filter((c) => {
|
|
// Missed calls have no handler — exclude from agent history
|
|
if (c.callStatus === 'MISSED') return false;
|
|
// Authoritative: agent relation from CDR enrichment
|
|
if (c.agent?.ozonetelAgentId?.toLowerCase() === myId) return true;
|
|
// Fallback: parse chain in agentName, match last segment
|
|
if (c.agentName) {
|
|
const segments = c.agentName.split('->').map(s => s.trim().toLowerCase());
|
|
const finalHandler = segments[segments.length - 1];
|
|
if (finalHandler === myId) return true;
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
|
|
if (filter === 'inbound') result = result.filter((c) => c.callDirection === 'INBOUND' && c.callStatus !== 'MISSED');
|
|
else if (filter === 'outbound') result = result.filter((c) => c.callDirection === 'OUTBOUND');
|
|
else if (filter === 'missed') result = result.filter((c) => c.callStatus === 'MISSED');
|
|
|
|
if (search.trim()) {
|
|
const q = search.toLowerCase();
|
|
result = result.filter((c) => {
|
|
const name = c.leadName ?? leadNameMap.get(c.leadId ?? '') ?? '';
|
|
const phone = c.callerNumber?.[0]?.number ?? '';
|
|
const agent = c.agentName ?? '';
|
|
return name.toLowerCase().includes(q) || phone.includes(q) || agent.toLowerCase().includes(q);
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}, [calls, filter, search, leadNameMap, isAdmin, myAgentId, user.id]);
|
|
|
|
const completedCount = filteredCalls.filter((c) => c.callStatus === 'COMPLETED').length;
|
|
const missedCount = filteredCalls.filter((c) => c.callStatus === 'MISSED').length;
|
|
|
|
const totalPages = Math.max(1, Math.ceil(filteredCalls.length / PAGE_SIZE));
|
|
const pagedCalls = filteredCalls.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
|
|
|
useEffect(() => { setPage(1); }, [filter, search]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
return (
|
|
<div className="flex flex-1 flex-col overflow-hidden">
|
|
<PageHeader
|
|
title={isAdmin ? 'Call History' : 'My Call History'}
|
|
badge={filteredCalls.length}
|
|
subtitle={isAdmin ? `${completedCount} completed \u00B7 ${missedCount} missed` : `${completedCount} completed`}
|
|
infoText={isAdmin ? 'All calls across all agents with recordings and dispositions.' : 'Your answered inbound and outbound calls.'}
|
|
controls={
|
|
<>
|
|
<div className="w-44">
|
|
<Select
|
|
size="sm"
|
|
placeholder="All Calls"
|
|
selectedKey={filter}
|
|
onSelectionChange={(key) => setFilter(key as FilterKey)}
|
|
items={isAdmin ? allFilterItems : agentFilterItems}
|
|
aria-label="Filter calls"
|
|
>
|
|
{(item) => (
|
|
<Select.Item id={item.id} label={item.label}>
|
|
{item.label}
|
|
</Select.Item>
|
|
)}
|
|
</Select>
|
|
</div>
|
|
<div className="w-56">
|
|
<Input
|
|
placeholder="Search calls..."
|
|
icon={SearchLg}
|
|
size="sm"
|
|
value={search}
|
|
onChange={(value) => setSearch(value)}
|
|
aria-label="Search calls"
|
|
/>
|
|
</div>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
|
|
{filteredCalls.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-16">
|
|
<h3 className="text-sm font-semibold text-primary">No calls found</h3>
|
|
<p className="text-sm text-tertiary mt-1">
|
|
{search ? 'Try a different search term' : 'No call history available yet.'}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<Table.Header>
|
|
<Table.Head label="TYPE" className="w-14" isRowHeader />
|
|
<Table.Head label="CALLER" />
|
|
<Table.Head label="PHONE" />
|
|
<Table.Head label="DURATION" className="w-24" />
|
|
<Table.Head label="OUTCOME" />
|
|
{/* Agent columns — only visible for supervisor */}
|
|
{isAdmin && <Table.Head label="AGENT" />}
|
|
{isAdmin && <Table.Head label="RECORDING" className="w-24" />}
|
|
<Table.Head label="TIME" />
|
|
</Table.Header>
|
|
<Table.Body items={pagedCalls}>
|
|
{(call) => {
|
|
const phoneRaw = call.callerNumber?.[0]?.number ?? '';
|
|
const patientName = call.leadName ?? leadNameMap.get(call.leadId ?? '') ?? (phoneRaw ? formatPhone({ number: phoneRaw, callingCode: '+91' }) : 'Unknown');
|
|
const dispositionCfg = call.disposition !== null ? dispositionConfig[call.disposition] : null;
|
|
|
|
return (
|
|
<Table.Row id={call.id}>
|
|
<Table.Cell>
|
|
<DirectionIcon direction={call.callDirection} status={call.callStatus} />
|
|
</Table.Cell>
|
|
<Table.Cell>
|
|
<span className="text-sm font-medium text-primary truncate max-w-[160px] block">
|
|
{patientName}
|
|
</span>
|
|
</Table.Cell>
|
|
<Table.Cell>
|
|
{phoneRaw ? (
|
|
<PhoneActionCell
|
|
phoneNumber={phoneRaw}
|
|
displayNumber={formatPhone({ number: phoneRaw, callingCode: '+91' })}
|
|
/>
|
|
) : (
|
|
<span className="text-sm text-quaternary">{'\u2014'}</span>
|
|
)}
|
|
</Table.Cell>
|
|
<Table.Cell>
|
|
<span className="text-sm text-secondary whitespace-nowrap">
|
|
{formatDuration(call.durationSeconds)}
|
|
</span>
|
|
</Table.Cell>
|
|
<Table.Cell>
|
|
{dispositionCfg ? (
|
|
<Badge size="sm" color={dispositionCfg.color} type="pill-color">
|
|
{dispositionCfg.label}
|
|
</Badge>
|
|
) : (
|
|
<span className="text-sm text-quaternary">{'\u2014'}</span>
|
|
)}
|
|
</Table.Cell>
|
|
{isAdmin && (
|
|
<Table.Cell>
|
|
<span className="text-sm text-secondary">
|
|
{call.agent?.name ?? call.agentName ?? '\u2014'}
|
|
</span>
|
|
</Table.Cell>
|
|
)}
|
|
{isAdmin && (
|
|
<Table.Cell>
|
|
{call.recordingUrl ? (
|
|
<RecordingPlayer url={call.recordingUrl} />
|
|
) : (
|
|
<span className="text-xs text-quaternary">{'\u2014'}</span>
|
|
)}
|
|
</Table.Cell>
|
|
)}
|
|
<Table.Cell>
|
|
<span className="text-sm text-tertiary whitespace-nowrap">
|
|
{call.startedAt ? formatShortDate(call.startedAt) : '\u2014'}
|
|
</span>
|
|
</Table.Cell>
|
|
</Table.Row>
|
|
);
|
|
}}
|
|
</Table.Body>
|
|
</Table>
|
|
)}
|
|
</div>
|
|
{totalPages > 1 && (
|
|
<div className="shrink-0 border-t border-secondary px-6 py-3">
|
|
<PaginationCardDefault
|
|
page={page}
|
|
total={totalPages}
|
|
onPageChange={setPage}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|