mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
feat: Contacts page + P360 for all tabs + dynamic column toggle + slot flicker fix
Contacts page:
- New /contacts route — shows leads with source=PHONE/WALK_IN/REFERRAL
- Leads page now excludes those sources (campaign-sourced only)
- Sidebar: Contacts nav item added for all roles; Leads added for cc-agent
- Same LeadTable + pagination + CSV export pattern as All Leads
P360 context panel for all worklist tabs (#6-10):
- WorklistPanel: onSelectLead → onSelectItem (generic WorklistSelection)
- call-desk: handleSelectItem builds ContextPanelSubject for any row type
- ContextPanelSubject type replaces (lead as any).patientId casts
- Highlight tracks row.id (mc-*/fu-*/lead-*) not lead.id
Dynamic column toggle (blank-screen fix):
- missed-calls + call-recordings refactored to React Aria dynamic
collections API (Table.Header columns={} + Table.Row columns={})
- Fixes "Cell count must match column count" crash on column hide
- Row-header column metadata in columnDefs instead of hardcoded JSX
Slot flickering fix (#2):
- Removed clinic + timeSlot from slot-fetch useEffect deps (circular
loop: effect sets clinic → clinic in deps → re-fires)
- Memoized timeSlotSelectItems
Other:
- GlobalSearch hidden (stale appointment state on navigation)
- Branch column: shows campaign name from relation, falls back to DID
- formatSource maps PHONE → "Phone"
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -52,8 +52,13 @@ export const AllLeadsPage = () => {
|
||||
const statusFilter: LeadStatus | undefined = tab === 'new' ? 'NEW' : undefined;
|
||||
const myLeadsOnly = tab === 'my-leads';
|
||||
|
||||
// Exclude organic contact sources — those live on the Contacts page.
|
||||
// Leads page shows campaign-sourced / marketing-qualified leads only.
|
||||
const CONTACT_SOURCES = useMemo(() => new Set(['PHONE', 'WALK_IN', 'REFERRAL'] as const), []);
|
||||
|
||||
const { leads: filteredLeads, total, updateLead } = useLeads({
|
||||
source: sourceFilter ?? undefined,
|
||||
excludeSources: CONTACT_SOURCES,
|
||||
status: statusFilter,
|
||||
search: searchQuery || undefined,
|
||||
});
|
||||
|
||||
@@ -8,8 +8,9 @@ import { useData } from '@/providers/data-provider';
|
||||
import { useWorklist } from '@/hooks/use-worklist';
|
||||
import { useSip } from '@/providers/sip-provider';
|
||||
import { WorklistPanel } from '@/components/call-desk/worklist-panel';
|
||||
import type { WorklistLead } from '@/components/call-desk/worklist-panel';
|
||||
import type { WorklistSelection } from '@/components/call-desk/worklist-panel';
|
||||
import { ContextPanel } from '@/components/call-desk/context-panel';
|
||||
import type { ContextPanelSubject } from '@/components/call-desk/context-panel';
|
||||
import { ActiveCallCard } from '@/components/call-desk/active-call-card';
|
||||
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
@@ -21,7 +22,8 @@ export const CallDeskPage = () => {
|
||||
const { leadActivities, calls, followUps: dataFollowUps, patients, appointments } = useData();
|
||||
const { callState, callerNumber, callUcid, dialOutbound, isRegistered } = useSip();
|
||||
const { missedCalls, followUps, marketingLeads, loading } = useWorklist();
|
||||
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
|
||||
const [selectedLead, setSelectedLead] = useState<ContextPanelSubject | null>(null);
|
||||
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
|
||||
const [contextOpen, setContextOpen] = useState(true);
|
||||
const [activeMissedCallId, setActiveMissedCallId] = useState<string | null>(null);
|
||||
const [callDismissed, setCallDismissed] = useState(false);
|
||||
@@ -134,6 +136,34 @@ export const CallDeskPage = () => {
|
||||
: selectedLead;
|
||||
const activeLeadFull = activeLead as any;
|
||||
|
||||
// Handle selection from any worklist row type. Leads use the lead
|
||||
// object directly; missed calls and follow-ups build a synthetic
|
||||
// lead-like object from their phone/patientId so the P360 context
|
||||
// panel can render for any row type.
|
||||
const handleSelectItem = useCallback((selection: WorklistSelection) => {
|
||||
setSelectedItemId(selection.rowId);
|
||||
|
||||
if (selection.lead) {
|
||||
// Lead row — use the full lead object as before
|
||||
setSelectedLead(selection.lead);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-lead row (missed call, follow-up, callback) — build a
|
||||
// ContextPanelSubject from the row's available data. The panel
|
||||
// uses contactPhone for call-history matching and patientId for
|
||||
// appointment/follow-up lookups. No type cast needed — the
|
||||
// ContextPanelSubject type accepts these optional fields.
|
||||
const phone = selection.phoneRaw ? selection.phoneRaw.replace(/\D/g, '').slice(-10) : '';
|
||||
const subject: ContextPanelSubject = {
|
||||
id: selection.leadId ?? selection.rowId,
|
||||
contactName: { firstName: selection.name.split(' ')[0] || '', lastName: selection.name.split(' ').slice(1).join(' ') || '' },
|
||||
contactPhone: phone ? [{ number: phone, callingCode: '+91' }] : [],
|
||||
patientId: selection.patientId,
|
||||
};
|
||||
setSelectedLead(subject);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Compact header: title + name on left, status + toggle on right */}
|
||||
@@ -250,8 +280,8 @@ export const CallDeskPage = () => {
|
||||
followUps={followUps}
|
||||
leads={marketingLeads}
|
||||
loading={loading}
|
||||
onSelectLead={(lead) => setSelectedLead(lead)}
|
||||
selectedLeadId={selectedLead?.id ?? null}
|
||||
onSelectItem={handleSelectItem}
|
||||
selectedItemId={selectedItemId}
|
||||
onDialMissedCall={(id) => setActiveMissedCallId(id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -76,13 +76,13 @@ const RecordingPlayer = ({ url }: { url: string }) => {
|
||||
};
|
||||
|
||||
const columnDefs = [
|
||||
{ id: 'agent', label: 'Agent', defaultVisible: true },
|
||||
{ id: 'agent', label: 'Agent', defaultVisible: true, allowsSorting: true, isRowHeader: true },
|
||||
{ id: 'caller', label: 'Caller', defaultVisible: true },
|
||||
{ id: 'ai', label: 'AI', defaultVisible: true },
|
||||
{ id: 'type', label: 'Type', defaultVisible: true },
|
||||
{ id: 'sla', label: 'SLA', defaultVisible: true },
|
||||
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true },
|
||||
{ id: 'duration', label: 'Duration', defaultVisible: true },
|
||||
{ id: 'sla', label: 'SLA', defaultVisible: true, allowsSorting: true },
|
||||
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true, allowsSorting: true },
|
||||
{ id: 'duration', label: 'Duration', defaultVisible: true, allowsSorting: true },
|
||||
{ id: 'disposition', label: 'Disposition', defaultVisible: true },
|
||||
{ id: 'recording', label: 'Recording', defaultVisible: true },
|
||||
];
|
||||
@@ -96,6 +96,85 @@ export const CallRecordingsPage = () => {
|
||||
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'dateTime', direction: 'descending' });
|
||||
const { visibleColumns, toggle: toggleColumn } = useColumnVisibility(columnDefs);
|
||||
|
||||
// Dynamic columns for React Aria — filter by visibility, pass as prop
|
||||
const activeColumns = useMemo(
|
||||
() => columnDefs.filter(c => visibleColumns.has(c.id)),
|
||||
[visibleColumns],
|
||||
);
|
||||
|
||||
// Cell renderer — lives inside the component so it can access setSlideoutCallId
|
||||
const renderRecordingCell = useCallback((call: RecordingRecord, colId: string) => {
|
||||
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
|
||||
const dirLabel = call.direction === 'INBOUND' ? 'In' : 'Out';
|
||||
const dirColor: 'blue' | 'brand' = call.direction === 'INBOUND' ? 'blue' : 'brand';
|
||||
switch (colId) {
|
||||
case 'agent':
|
||||
return <span className="text-sm text-primary">{call.agentName || '—'}</span>;
|
||||
case 'caller':
|
||||
return phone
|
||||
? <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
|
||||
: <span className="text-xs text-quaternary">—</span>;
|
||||
case 'ai':
|
||||
return (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
let longPressed = false;
|
||||
const timer = setTimeout(() => {
|
||||
longPressed = true;
|
||||
window.dispatchEvent(new CustomEvent('maint:trigger', { detail: 'clearAnalysisCache' }));
|
||||
}, 1000);
|
||||
const up = () => { clearTimeout(timer); if (!longPressed) setSlideoutCallId(call.id); };
|
||||
document.addEventListener('pointerup', up, { once: true });
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-brand-primary px-2.5 py-1 text-xs font-semibold text-brand-secondary hover:bg-brand-secondary hover:text-white cursor-pointer transition duration-100 ease-linear"
|
||||
title="AI Analysis (long-press to regenerate)"
|
||||
>
|
||||
<FontAwesomeIcon icon={faSparkles} className="size-3" />
|
||||
AI
|
||||
</span>
|
||||
);
|
||||
case 'type':
|
||||
return <Badge size="sm" color={dirColor} type="pill-color">{dirLabel}</Badge>;
|
||||
case 'sla': {
|
||||
const sla = getCallSla(call);
|
||||
if (!sla) return <span className="text-xs text-quaternary">—</span>;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
|
||||
<span className={cx('size-2 rounded-full',
|
||||
sla.status === 'low' && 'bg-success-solid',
|
||||
sla.status === 'medium' && 'bg-warning-solid',
|
||||
sla.status === 'high' && 'bg-error-solid',
|
||||
sla.status === 'critical' && 'bg-error-solid animate-pulse',
|
||||
)} />
|
||||
<span className="text-secondary">{sla.percent}%</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
case 'dateTime':
|
||||
return call.startedAt ? (
|
||||
<div>
|
||||
<span className="text-sm text-primary">{formatDateOrdinal(call.startedAt)}</span>
|
||||
<span className="block text-xs text-tertiary">{formatTimeOnly(call.startedAt)}</span>
|
||||
</div>
|
||||
) : <span className="text-xs text-quaternary">—</span>;
|
||||
case 'duration':
|
||||
return <span className="text-sm text-primary">{formatDuration(call.durationSec)}</span>;
|
||||
case 'disposition':
|
||||
return 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>;
|
||||
case 'recording':
|
||||
return call.recording?.primaryLinkUrl
|
||||
? <RecordingPlayer url={call.recording.primaryLinkUrl} />
|
||||
: null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchRecordings = useCallback(() => {
|
||||
apiClient.graphql<{ calls: { edges: Array<{ node: RecordingRecord }> } }>(QUERY, undefined, { silent: true })
|
||||
.then(data => {
|
||||
@@ -186,119 +265,27 @@ export const CallRecordingsPage = () => {
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
|
||||
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
|
||||
<Table.Header>
|
||||
{visibleColumns.has('agent') && <Table.Head id="agent" label="Agent" isRowHeader allowsSorting />}
|
||||
{visibleColumns.has('caller') && <Table.Head label="Caller" />}
|
||||
{visibleColumns.has('ai') && <Table.Head label="AI" className="w-14" />}
|
||||
{visibleColumns.has('type') && <Table.Head label="Type" className="w-16" />}
|
||||
{visibleColumns.has('sla') && <Table.Head id="sla" label="SLA" className="w-24" allowsSorting />}
|
||||
{visibleColumns.has('dateTime') && <Table.Head id="dateTime" label="Date & Time" className="w-28" allowsSorting />}
|
||||
{visibleColumns.has('duration') && <Table.Head id="duration" label="Duration" className="w-20" allowsSorting />}
|
||||
{visibleColumns.has('disposition') && <Table.Head label="Disposition" className="w-32" />}
|
||||
{visibleColumns.has('recording') && <Table.Head label="Recording" className="w-24" />}
|
||||
<Table.Header columns={activeColumns}>
|
||||
{(col) => (
|
||||
<Table.Head
|
||||
key={col.id}
|
||||
id={col.id}
|
||||
label={col.label}
|
||||
isRowHeader={col.isRowHeader}
|
||||
allowsSorting={col.allowsSorting}
|
||||
/>
|
||||
)}
|
||||
</Table.Header>
|
||||
<Table.Body items={pagedRows}>
|
||||
{(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}>
|
||||
{visibleColumns.has('agent') && (
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-primary">{call.agentName || '—'}</span>
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('caller') && (
|
||||
<Table.Cell>
|
||||
{phone ? (
|
||||
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
|
||||
) : <span className="text-xs text-quaternary">—</span>}
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('ai') && (
|
||||
<Table.Cell>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
let longPressed = false;
|
||||
const timer = setTimeout(() => {
|
||||
longPressed = true;
|
||||
window.dispatchEvent(new CustomEvent('maint:trigger', { detail: 'clearAnalysisCache' }));
|
||||
}, 1000);
|
||||
const up = () => { clearTimeout(timer); if (!longPressed) setSlideoutCallId(call.id); };
|
||||
document.addEventListener('pointerup', up, { once: true });
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-brand-primary px-2.5 py-1 text-xs font-semibold text-brand-secondary hover:bg-brand-secondary hover:text-white cursor-pointer transition duration-100 ease-linear"
|
||||
title="AI Analysis (long-press to regenerate)"
|
||||
>
|
||||
<FontAwesomeIcon icon={faSparkles} className="size-3" />
|
||||
AI
|
||||
</span>
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('type') && (
|
||||
<Table.Cell>
|
||||
<Badge size="sm" color={dirColor} type="pill-color">{dirLabel}</Badge>
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('sla') && (
|
||||
<Table.Cell>
|
||||
{(() => {
|
||||
const sla = getCallSla(call);
|
||||
if (!sla) return <span className="text-xs text-quaternary">—</span>;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
|
||||
<span className={cx(
|
||||
'size-2 rounded-full',
|
||||
sla.status === 'low' && 'bg-success-solid',
|
||||
sla.status === 'medium' && 'bg-warning-solid',
|
||||
sla.status === 'high' && 'bg-error-solid',
|
||||
sla.status === 'critical' && 'bg-error-solid animate-pulse',
|
||||
)} />
|
||||
<span className="text-secondary">{sla.percent}%</span>
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('dateTime') && (
|
||||
<Table.Cell>
|
||||
{call.startedAt ? (
|
||||
<div>
|
||||
<span className="text-sm text-primary">{formatDateOrdinal(call.startedAt)}</span>
|
||||
<span className="block text-xs text-tertiary">{formatTimeOnly(call.startedAt)}</span>
|
||||
</div>
|
||||
) : <span className="text-xs text-quaternary">—</span>}
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('duration') && (
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-primary">{formatDuration(call.durationSec)}</span>
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('disposition') && (
|
||||
<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>
|
||||
)}
|
||||
{visibleColumns.has('recording') && (
|
||||
<Table.Cell>
|
||||
{call.recording?.primaryLinkUrl && (
|
||||
<RecordingPlayer url={call.recording.primaryLinkUrl} />
|
||||
)}
|
||||
</Table.Cell>
|
||||
)}
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
{(call) => (
|
||||
<Table.Row id={call.id} columns={activeColumns}>
|
||||
{(col) => (
|
||||
<Table.Cell key={col.id}>
|
||||
{renderRecordingCell(call, col.id)}
|
||||
</Table.Cell>
|
||||
)}
|
||||
</Table.Row>
|
||||
)}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
176
src/pages/contacts.tsx
Normal file
176
src/pages/contacts.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
// Contacts page — organic inbound callers (source = PHONE, WALK_IN,
|
||||
// REFERRAL). Same Lead entity, filtered view. Campaign-sourced leads
|
||||
// live on the Leads page; contacts are people who reached out directly
|
||||
// without a marketing touchpoint.
|
||||
//
|
||||
// Uses the same LeadTable + column toggle + pagination pattern as
|
||||
// All Leads. No separate backend endpoint — filters client-side on
|
||||
// the DataProvider's leads array.
|
||||
|
||||
import type { FC } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faArrowDownToLine, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
|
||||
|
||||
const Download01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowDownToLine} className={className} />;
|
||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { LeadTable } from '@/components/leads/lead-table';
|
||||
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
|
||||
import { LeadActivitySlideout } from '@/components/leads/lead-activity-slideout';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { rowsToCsv, downloadCsv } from '@/lib/csv-utils';
|
||||
import { notify } from '@/lib/toast';
|
||||
import type { Lead } from '@/types/entities';
|
||||
|
||||
// Sources that qualify as "contacts" — direct/organic, not campaign-sourced
|
||||
const CONTACT_SOURCES = new Set(['PHONE', 'WALK_IN', 'REFERRAL']);
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
export const ContactsPage = () => {
|
||||
const { leads, leadActivities } = useData();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [sortField, setSortField] = useState('createdAt');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
const [activityLead, setActivityLead] = useState<Lead | null>(null);
|
||||
|
||||
const columnDefs = [
|
||||
{ id: 'phone', label: 'Phone', defaultVisible: true },
|
||||
{ id: 'name', label: 'Name', defaultVisible: true },
|
||||
{ id: 'email', label: 'Email', defaultVisible: false },
|
||||
{ id: 'source', label: 'Source', defaultVisible: true },
|
||||
{ id: 'firstContactedAt', label: 'First Contact', defaultVisible: false },
|
||||
{ id: 'lastContactedAt', label: 'Last Contact', defaultVisible: true },
|
||||
{ id: 'status', label: 'Status', defaultVisible: true },
|
||||
{ id: 'createdAt', label: 'Age', defaultVisible: true },
|
||||
];
|
||||
const { visibleColumns, toggle: toggleColumn } = useColumnVisibility(columnDefs);
|
||||
|
||||
// Filter to contact sources only
|
||||
const contacts = useMemo(() => {
|
||||
let filtered = leads.filter((l) => CONTACT_SOURCES.has(l.leadSource ?? ''));
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter((l) => {
|
||||
const name = `${l.contactName?.firstName ?? ''} ${l.contactName?.lastName ?? ''}`.toLowerCase();
|
||||
const phone = l.contactPhone?.[0]?.number ?? '';
|
||||
return name.includes(q) || phone.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [leads, searchQuery]);
|
||||
|
||||
// Sort
|
||||
const sorted = useMemo(() => {
|
||||
const copy = [...contacts];
|
||||
const dir = sortDirection === 'asc' ? 1 : -1;
|
||||
copy.sort((a, b) => {
|
||||
const av = (a as any)[sortField] ?? '';
|
||||
const bv = (b as any)[sortField] ?? '';
|
||||
if (av === bv) return 0;
|
||||
return av > bv ? dir : -dir;
|
||||
});
|
||||
return copy;
|
||||
}, [contacts, sortField, sortDirection]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
|
||||
const paged = sorted.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
if (field === sortField) {
|
||||
setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection('desc');
|
||||
}
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleExportCsv = () => {
|
||||
if (sorted.length === 0) { notify.error('Export CSV', 'No contacts to export'); return; }
|
||||
const headers = ['Phone', 'First Name', 'Last Name', 'Email', 'Source', 'Status', 'Created', 'Last Contact'];
|
||||
const rows = sorted.map((l) => ({
|
||||
'Phone': l.contactPhone?.[0]?.number ?? '',
|
||||
'First Name': l.contactName?.firstName ?? '',
|
||||
'Last Name': l.contactName?.lastName ?? '',
|
||||
'Email': l.contactEmail?.[0]?.address ?? '',
|
||||
'Source': l.leadSource ?? '',
|
||||
'Status': l.leadStatus ?? '',
|
||||
'Created': l.createdAt ?? '',
|
||||
'Last Contact': l.lastContactedAt ?? '',
|
||||
}));
|
||||
const csv = rowsToCsv(headers, rows);
|
||||
downloadCsv(`contacts-${new Date().toISOString().slice(0, 10)}.csv`, csv);
|
||||
notify.success('Export CSV', `${rows.length} contact${rows.length === 1 ? '' : 's'} exported`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<TopBar title="Contacts" subtitle={`${contacts.length} organic callers`} />
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex shrink-0 items-center justify-between px-6 py-3 border-b border-secondary">
|
||||
<p className="text-xs text-tertiary">
|
||||
People who reached out directly — phone, walk-in, referral. Not sourced from campaigns.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-56">
|
||||
<Input
|
||||
placeholder="Search contacts..."
|
||||
icon={SearchLg}
|
||||
size="sm"
|
||||
value={searchQuery}
|
||||
onChange={(value) => { setSearchQuery(value); setCurrentPage(1); }}
|
||||
aria-label="Search contacts"
|
||||
/>
|
||||
</div>
|
||||
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
|
||||
<Button size="sm" color="secondary" iconLeading={Download01} onClick={handleExportCsv}>
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 pt-3">
|
||||
<LeadTable
|
||||
leads={paged}
|
||||
selectedIds={selectedIds}
|
||||
onSelectionChange={setSelectedIds}
|
||||
sortField={sortField}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
onViewActivity={(lead) => setActivityLead(lead)}
|
||||
visibleColumns={visibleColumns}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="shrink-0 border-t border-secondary px-6 py-3">
|
||||
<PaginationPageDefault
|
||||
page={currentPage}
|
||||
total={totalPages}
|
||||
onPageChange={(page) => { setCurrentPage(page); setSelectedIds([]); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activityLead && (
|
||||
<LeadActivitySlideout
|
||||
isOpen={!!activityLead}
|
||||
onOpenChange={(open) => !open && setActivityLead(null)}
|
||||
lead={activityLead}
|
||||
activities={leadActivities}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -57,16 +57,108 @@ const STATUS_COLORS: Record<string, 'warning' | 'brand' | 'success' | 'error' |
|
||||
};
|
||||
|
||||
const columnDefs = [
|
||||
{ id: 'caller', label: 'Caller', defaultVisible: true },
|
||||
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true },
|
||||
{ id: 'caller', label: 'Caller', defaultVisible: true, allowsSorting: false, isRowHeader: true },
|
||||
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true, allowsSorting: true },
|
||||
{ id: 'branch', label: 'Branch', defaultVisible: true },
|
||||
{ id: 'agent', label: 'Agent', defaultVisible: true },
|
||||
{ id: 'count', label: 'Count', defaultVisible: true },
|
||||
{ id: 'agent', label: 'Agent', defaultVisible: true, allowsSorting: true },
|
||||
{ id: 'count', label: 'Count', defaultVisible: true, allowsSorting: true },
|
||||
{ id: 'status', label: 'Status', defaultVisible: true },
|
||||
{ id: 'sla', label: 'SLA', defaultVisible: true },
|
||||
{ id: 'sla', label: 'SLA', defaultVisible: true, allowsSorting: true },
|
||||
{ id: 'callback', label: 'Callback At', defaultVisible: false },
|
||||
];
|
||||
|
||||
// Dynamic columns table — React Aria requires the column count to match
|
||||
// between Header and Row. Conditional `{visible && <Cell>}` crashes the
|
||||
// table (#8127). Using the dynamic collections API (columns prop +
|
||||
// render function) lets React Aria rebuild its collection cleanly when
|
||||
// the visible set changes.
|
||||
type ColDef = { id: string; label: string; allowsSorting?: boolean; isRowHeader?: boolean };
|
||||
|
||||
const renderCell = (call: MissedCallRecord, colId: string) => {
|
||||
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
|
||||
const status = call.callbackStatus ?? 'PENDING_CALLBACK';
|
||||
switch (colId) {
|
||||
case 'caller':
|
||||
return phone
|
||||
? <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
|
||||
: <span className="text-xs text-quaternary">Unknown</span>;
|
||||
case 'dateTime':
|
||||
return call.startedAt ? (
|
||||
<div>
|
||||
<span className="text-sm text-primary">{formatDateOrdinal(call.startedAt)}</span>
|
||||
<span className="block text-xs text-tertiary">{formatTimeOnly(call.startedAt)}</span>
|
||||
</div>
|
||||
) : <span className="text-xs text-quaternary">—</span>;
|
||||
case 'branch':
|
||||
return <span className="text-xs text-tertiary">{call.callSourceNumber || '—'}</span>;
|
||||
case 'agent':
|
||||
return <span className="text-sm text-primary">{call.agentName || '—'}</span>;
|
||||
case 'count':
|
||||
return 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>;
|
||||
case 'status':
|
||||
return <Badge size="sm" color={STATUS_COLORS[status] ?? 'gray'} type="pill-color">{STATUS_LABELS[status] ?? status}</Badge>;
|
||||
case 'sla':
|
||||
if (call.sla == null) return <span className="text-xs text-quaternary">—</span>;
|
||||
const slaStatus = computeSlaStatus(call.sla);
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
|
||||
<span className={cx('size-2 rounded-full',
|
||||
slaStatus === 'low' && 'bg-success-solid',
|
||||
slaStatus === 'medium' && 'bg-warning-solid',
|
||||
slaStatus === 'high' && 'bg-error-solid',
|
||||
slaStatus === 'critical' && 'bg-error-solid animate-pulse',
|
||||
)} />
|
||||
<span className="text-secondary">{call.sla}%</span>
|
||||
</span>
|
||||
);
|
||||
case 'callback':
|
||||
return call.callbackAttemptedAt ? (
|
||||
<div>
|
||||
<span className="text-sm text-primary">{formatDateOrdinal(call.callbackAttemptedAt)}</span>
|
||||
<span className="block text-xs text-tertiary">{formatTimeOnly(call.callbackAttemptedAt)}</span>
|
||||
</div>
|
||||
) : <span className="text-xs text-quaternary">—</span>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const DynamicMissedCallTable = ({ calls, columns, sortDescriptor, onSortChange }: {
|
||||
calls: MissedCallRecord[];
|
||||
columns: ColDef[];
|
||||
sortDescriptor: SortDescriptor;
|
||||
onSortChange: (desc: SortDescriptor) => void;
|
||||
}) => (
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
|
||||
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={onSortChange}>
|
||||
<Table.Header columns={columns}>
|
||||
{(col) => (
|
||||
<Table.Head
|
||||
key={col.id}
|
||||
id={col.id}
|
||||
label={col.label}
|
||||
isRowHeader={col.isRowHeader}
|
||||
allowsSorting={col.allowsSorting}
|
||||
/>
|
||||
)}
|
||||
</Table.Header>
|
||||
<Table.Body items={calls}>
|
||||
{(call) => (
|
||||
<Table.Row id={call.id} columns={columns}>
|
||||
{(col) => (
|
||||
<Table.Cell key={col.id}>
|
||||
{renderCell(call, col.id)}
|
||||
</Table.Cell>
|
||||
)}
|
||||
</Table.Row>
|
||||
)}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const MissedCallsPage = () => {
|
||||
const [calls, setCalls] = useState<MissedCallRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -175,101 +267,12 @@ export const MissedCallsPage = () => {
|
||||
<p className="text-sm text-quaternary">{search ? 'No matching calls' : 'No missed calls'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
|
||||
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
|
||||
<Table.Header>
|
||||
{visibleColumns.has('caller') && <Table.Head label="Caller" isRowHeader />}
|
||||
{visibleColumns.has('dateTime') && <Table.Head id="dateTime" label="Date & Time" className="w-28" allowsSorting />}
|
||||
{visibleColumns.has('branch') && <Table.Head label="Branch" className="w-32" />}
|
||||
{visibleColumns.has('agent') && <Table.Head id="agent" label="Agent" className="w-28" allowsSorting />}
|
||||
{visibleColumns.has('count') && <Table.Head id="count" label="Count" className="w-16" allowsSorting />}
|
||||
{visibleColumns.has('status') && <Table.Head label="Status" className="w-28" />}
|
||||
{visibleColumns.has('sla') && <Table.Head id="sla" label="SLA" className="w-24" allowsSorting />}
|
||||
{visibleColumns.has('callback') && <Table.Head label="Callback At" className="w-28" />}
|
||||
</Table.Header>
|
||||
<Table.Body items={pagedRows}>
|
||||
{(call) => {
|
||||
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
|
||||
const status = call.callbackStatus ?? 'PENDING_CALLBACK';
|
||||
|
||||
return (
|
||||
<Table.Row id={call.id}>
|
||||
{visibleColumns.has('caller') && (
|
||||
<Table.Cell>
|
||||
{phone ? (
|
||||
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
|
||||
) : <span className="text-xs text-quaternary">Unknown</span>}
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('dateTime') && (
|
||||
<Table.Cell>
|
||||
{call.startedAt ? (
|
||||
<div>
|
||||
<span className="text-sm text-primary">{formatDateOrdinal(call.startedAt)}</span>
|
||||
<span className="block text-xs text-tertiary">{formatTimeOnly(call.startedAt)}</span>
|
||||
</div>
|
||||
) : <span className="text-xs text-quaternary">—</span>}
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('branch') && (
|
||||
<Table.Cell>
|
||||
<span className="text-xs text-tertiary">{call.callSourceNumber || '—'}</span>
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('agent') && (
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-primary">{call.agentName || '—'}</span>
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('count') && (
|
||||
<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>
|
||||
)}
|
||||
{visibleColumns.has('status') && (
|
||||
<Table.Cell>
|
||||
<Badge size="sm" color={STATUS_COLORS[status] ?? 'gray'} type="pill-color">
|
||||
{STATUS_LABELS[status] ?? status}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('sla') && (
|
||||
<Table.Cell>
|
||||
{call.sla != null ? (() => {
|
||||
const status = computeSlaStatus(call.sla);
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
|
||||
<span className={cx(
|
||||
'size-2 rounded-full',
|
||||
status === 'low' && 'bg-success-solid',
|
||||
status === 'medium' && 'bg-warning-solid',
|
||||
status === 'high' && 'bg-error-solid',
|
||||
status === 'critical' && 'bg-error-solid animate-pulse',
|
||||
)} />
|
||||
<span className="text-secondary">{call.sla}%</span>
|
||||
</span>
|
||||
);
|
||||
})() : <span className="text-xs text-quaternary">—</span>}
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('callback') && (
|
||||
<Table.Cell>
|
||||
{call.callbackAttemptedAt ? (
|
||||
<div>
|
||||
<span className="text-sm text-primary">{formatDateOrdinal(call.callbackAttemptedAt)}</span>
|
||||
<span className="block text-xs text-tertiary">{formatTimeOnly(call.callbackAttemptedAt)}</span>
|
||||
</div>
|
||||
) : <span className="text-xs text-quaternary">—</span>}
|
||||
</Table.Cell>
|
||||
)}
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
<DynamicMissedCallTable
|
||||
calls={pagedRows}
|
||||
columns={columnDefs.filter(c => visibleColumns.has(c.id))}
|
||||
sortDescriptor={sortDescriptor}
|
||||
onSortChange={setSortDescriptor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user