Files
helix-engage/src/pages/missed-calls.tsx
saridsa2 e6b2208077 feat: disposition modal, persistent top bar, pagination, QA fixes
- DispositionModal: single modal for all call endings. Dismissable (agent can resume call).
  Agent clicks End → modal → select reason → hangup + dispose. Caller disconnects → same modal.
- One call screen: CallWidget stripped to ringing notification + auto-redirect to Call Desk.
- Persistent top bar in AppShell: agent status toggle + network indicator on all pages.
- Network indicator always visible (Connected/Unstable/No connection).
- Pagination: Untitled UI PaginationCardDefault on Call History + Appointments (20/page).
- Pinned table headers/footers: sticky column headers, scrollable body, pinned pagination.
  Applied to Call Desk worklist, Call History, Appointments, Call Recordings, Missed Calls.
- "Patient" → "Caller" column label in Call History.
- Offline → Ready toggle enabled.
- Profile status dot reflects Ozonetel state.
- NavAccountCard: popover placement top, View Profile + Account Settings restored.
- WIP pages for /profile and /account-settings.
- Enquiry form PHONE_INQUIRY → PHONE enum fix.
- Force Ready / View Profile / Account Settings removed then restored properly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:44:48 +05:30

189 lines
9.8 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
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 { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
import { TopBar } from '@/components/layout/top-bar';
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
import { apiClient } from '@/lib/api-client';
import { formatPhone, formatDateTimeShort } from '@/lib/format';
type MissedCallRecord = {
id: string;
callerNumber: { primaryPhoneNumber: string } | null;
agentName: string | null;
startedAt: string | null;
callsourcenumber: string | null;
callbackstatus: string | null;
missedcallcount: number | null;
callbackattemptedat: string | null;
};
type StatusTab = 'all' | 'PENDING_CALLBACK' | 'CALLBACK_ATTEMPTED' | 'CALLBACK_COMPLETED';
const QUERY = `{ calls(first: 200, filter: {
callStatus: { eq: MISSED }
}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id callerNumber { primaryPhoneNumber } agentName
startedAt callsourcenumber callbackstatus missedcallcount callbackattemptedat
} } } }`;
const formatDate = (iso: string): string => formatDateTimeShort(iso);
const computeSla = (dateStr: string): { label: string; color: 'success' | 'warning' | 'error' } => {
const minutes = Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000));
if (minutes < 15) return { label: `${minutes}m`, color: 'success' };
if (minutes < 30) return { label: `${minutes}m`, color: 'warning' };
if (minutes < 60) return { label: `${minutes}m`, color: 'error' };
const hours = Math.floor(minutes / 60);
if (hours < 24) return { label: `${hours}h ${minutes % 60}m`, color: 'error' };
return { label: `${Math.floor(hours / 24)}d`, color: 'error' };
};
const STATUS_LABELS: Record<string, string> = {
PENDING_CALLBACK: 'Pending',
CALLBACK_ATTEMPTED: 'Attempted',
CALLBACK_COMPLETED: 'Completed',
WRONG_NUMBER: 'Wrong Number',
INVALID: 'Invalid',
};
const STATUS_COLORS: Record<string, 'warning' | 'brand' | 'success' | 'error' | 'gray'> = {
PENDING_CALLBACK: 'warning',
CALLBACK_ATTEMPTED: 'brand',
CALLBACK_COMPLETED: 'success',
WRONG_NUMBER: 'error',
INVALID: 'gray',
};
export const MissedCallsPage = () => {
const [calls, setCalls] = useState<MissedCallRecord[]>([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<StatusTab>('all');
const [search, setSearch] = useState('');
useEffect(() => {
apiClient.graphql<{ calls: { edges: Array<{ node: MissedCallRecord }> } }>(QUERY, undefined, { silent: true })
.then(data => setCalls(data.calls.edges.map(e => e.node)))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const statusCounts = useMemo(() => {
const counts: Record<string, number> = {};
for (const c of calls) {
const s = c.callbackstatus ?? 'PENDING_CALLBACK';
counts[s] = (counts[s] ?? 0) + 1;
}
return counts;
}, [calls]);
const filtered = useMemo(() => {
let rows = calls;
if (tab === 'PENDING_CALLBACK') rows = rows.filter(c => c.callbackstatus === 'PENDING_CALLBACK' || !c.callbackstatus);
else if (tab === 'CALLBACK_ATTEMPTED') rows = rows.filter(c => c.callbackstatus === 'CALLBACK_ATTEMPTED');
else if (tab === 'CALLBACK_COMPLETED') rows = rows.filter(c => c.callbackstatus === 'CALLBACK_COMPLETED' || c.callbackstatus === 'WRONG_NUMBER');
if (search.trim()) {
const q = search.toLowerCase();
rows = rows.filter(c =>
(c.callerNumber?.primaryPhoneNumber ?? '').includes(q) ||
(c.agentName ?? '').toLowerCase().includes(q),
);
}
return rows;
}, [calls, tab, search]);
const tabItems = [
{ id: 'all' as const, label: 'All', badge: calls.length > 0 ? String(calls.length) : undefined },
{ id: 'PENDING_CALLBACK' as const, label: 'Pending', badge: statusCounts.PENDING_CALLBACK ? String(statusCounts.PENDING_CALLBACK) : undefined },
{ id: 'CALLBACK_ATTEMPTED' as const, label: 'Attempted', badge: statusCounts.CALLBACK_ATTEMPTED ? String(statusCounts.CALLBACK_ATTEMPTED) : undefined },
{ id: 'CALLBACK_COMPLETED' as const, label: 'Completed', badge: (statusCounts.CALLBACK_COMPLETED || statusCounts.WRONG_NUMBER) ? String((statusCounts.CALLBACK_COMPLETED ?? 0) + (statusCounts.WRONG_NUMBER ?? 0)) : undefined },
];
return (
<>
<TopBar title="Missed Calls" />
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-6 pt-3 pb-0.5">
<Tabs selectedKey={tab} onSelectionChange={(key) => setTab(key as StatusTab)}>
<TabList items={tabItems} type="underline" size="sm">
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
</TabList>
</Tabs>
<div className="w-56 shrink-0 pb-1">
<Input placeholder="Search phone or agent..." icon={SearchLg} size="sm" value={search} onChange={setSearch} />
</div>
</div>
<div className="flex flex-1 flex-col overflow-hidden px-4 pt-3">
{loading ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading missed calls...</p>
</div>
) : filtered.length === 0 ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-quaternary">{search ? 'No matching calls' : 'No missed calls'}</p>
</div>
) : (
<Table size="sm">
<Table.Header>
<Table.Head label="Caller" isRowHeader />
<Table.Head label="Date / Time" className="w-36" />
<Table.Head label="Branch" className="w-32" />
<Table.Head label="Agent" className="w-28" />
<Table.Head label="Count" className="w-16" />
<Table.Head label="Status" className="w-28" />
<Table.Head label="SLA" className="w-24" />
</Table.Header>
<Table.Body items={filtered}>
{(call) => {
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
const status = call.callbackstatus ?? 'PENDING_CALLBACK';
const sla = call.startedAt ? computeSla(call.startedAt) : null;
return (
<Table.Row id={call.id}>
<Table.Cell>
{phone ? (
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
) : <span className="text-xs text-quaternary">Unknown</span>}
</Table.Cell>
<Table.Cell>
<span className="text-sm text-primary">{call.startedAt ? formatDate(call.startedAt) : '—'}</span>
</Table.Cell>
<Table.Cell>
<span className="text-xs text-tertiary">{call.callsourcenumber || '—'}</span>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-primary">{call.agentName || '—'}</span>
</Table.Cell>
<Table.Cell>
{call.missedcallcount && call.missedcallcount > 1 ? (
<Badge size="sm" color="warning" type="pill-color">{call.missedcallcount}x</Badge>
) : <span className="text-xs text-quaternary">1</span>}
</Table.Cell>
<Table.Cell>
<Badge size="sm" color={STATUS_COLORS[status] ?? 'gray'} type="pill-color">
{STATUS_LABELS[status] ?? status}
</Badge>
</Table.Cell>
<Table.Cell>
{sla && <Badge size="sm" color={sla.color} type="pill-color">{sla.label}</Badge>}
</Table.Cell>
</Table.Row>
);
}}
</Table.Body>
</Table>
)}
</div>
</div>
</>
);
};