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:
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user