mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
177 lines
7.8 KiB
TypeScript
177 lines
7.8 KiB
TypeScript
// 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 { PageHeader } from '@/components/layout/page-header';
|
|
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 [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">
|
|
<PageHeader
|
|
title="Contacts"
|
|
badge={contacts.length}
|
|
infoText="People who reached out directly — phone, walk-in, referral. Not sourced from campaigns."
|
|
controls={
|
|
<>
|
|
<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 className="flex flex-1 flex-col overflow-hidden">
|
|
<div className="flex-1 overflow-y-auto px-4 pt-3">
|
|
<LeadTable
|
|
leads={paged}
|
|
selectedIds={[]}
|
|
onSelectionChange={() => {}}
|
|
selectionMode="none"
|
|
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); }}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{activityLead && (
|
|
<LeadActivitySlideout
|
|
isOpen={!!activityLead}
|
|
onOpenChange={(open) => !open && setActivityLead(null)}
|
|
lead={activityLead}
|
|
activities={leadActivities}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|