mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
376 lines
16 KiB
TypeScript
376 lines
16 KiB
TypeScript
import type { FC } from 'react';
|
|
import { useMemo, useState } from 'react';
|
|
import { useSearchParams } from 'react-router';
|
|
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';
|
|
// Tabs removed — campaign pills handle all filtering now
|
|
// import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
|
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';
|
|
// Bulk actions removed — checkboxes hidden
|
|
// import { BulkActionBar } from '@/components/leads/bulk-action-bar';
|
|
import { FilterPills } from '@/components/leads/filter-pills';
|
|
// Bulk action modals removed — checkboxes hidden
|
|
// import { AssignModal } from '@/components/modals/assign-modal';
|
|
// import { WhatsAppSendModal } from '@/components/modals/whatsapp-send-modal';
|
|
// import { MarkSpamModal } from '@/components/modals/mark-spam-modal';
|
|
import { LeadActivitySlideout } from '@/components/leads/lead-activity-slideout';
|
|
import { useLeads } from '@/hooks/use-leads';
|
|
import { useAuth } from '@/providers/auth-provider';
|
|
import { useData } from '@/providers/data-provider';
|
|
import { cx } from '@/utils/cx';
|
|
import { rowsToCsv, downloadCsv } from '@/lib/csv-utils';
|
|
import { notify } from '@/lib/toast';
|
|
import type { Lead, LeadSource, LeadStatus } from '@/types/entities';
|
|
|
|
type TabKey = 'new' | 'my-leads' | 'all';
|
|
|
|
type ActiveFilter = {
|
|
key: string;
|
|
label: string;
|
|
value: string;
|
|
};
|
|
|
|
const PAGE_SIZE = 15;
|
|
|
|
export const AllLeadsPage = () => {
|
|
const { user } = useAuth();
|
|
const [searchParams] = useSearchParams();
|
|
const initialSource = searchParams.get('source') as LeadSource | null;
|
|
const tab: TabKey = 'all'; // Tabs removed — show all campaign-sourced leads
|
|
const [sortField, setSortField] = useState('createdAt');
|
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
|
const [sourceFilter, setSourceFilter] = useState<LeadSource | null>(initialSource);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
|
|
const statusFilter: LeadStatus | undefined = undefined;
|
|
const myLeadsOnly = false;
|
|
|
|
// 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 } = useLeads({
|
|
source: sourceFilter ?? undefined,
|
|
excludeSources: CONTACT_SOURCES,
|
|
status: statusFilter,
|
|
search: searchQuery || undefined,
|
|
});
|
|
|
|
const { leadActivities, campaigns } = useData();
|
|
const [campaignFilter, setCampaignFilter] = useState<string | null>(null);
|
|
|
|
const columnDefs = [
|
|
{ id: 'phone', label: 'Phone', defaultVisible: true },
|
|
{ id: 'name', label: 'Name', defaultVisible: true },
|
|
{ id: 'email', label: 'Email', defaultVisible: true },
|
|
{ id: 'campaign', label: 'Campaign', defaultVisible: false },
|
|
{ id: 'ad', label: 'Ad', 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 },
|
|
{ id: 'spamScore', label: 'Spam', defaultVisible: false },
|
|
{ id: 'dups', label: 'Dups', defaultVisible: false },
|
|
];
|
|
const { visibleColumns, toggle: toggleColumn } = useColumnVisibility(columnDefs);
|
|
|
|
// Client-side sorting
|
|
const sortedLeads = useMemo(() => {
|
|
const sorted = [...filteredLeads];
|
|
sorted.sort((a, b) => {
|
|
let aVal: string | number | null = null;
|
|
let bVal: string | number | null = null;
|
|
|
|
switch (sortField) {
|
|
case 'phone':
|
|
aVal = a.contactPhone?.[0]?.number ?? '';
|
|
bVal = b.contactPhone?.[0]?.number ?? '';
|
|
break;
|
|
case 'name':
|
|
aVal = `${a.contactName?.firstName ?? ''} ${a.contactName?.lastName ?? ''}`.trim();
|
|
bVal = `${b.contactName?.firstName ?? ''} ${b.contactName?.lastName ?? ''}`.trim();
|
|
break;
|
|
case 'source':
|
|
aVal = a.leadSource ?? '';
|
|
bVal = b.leadSource ?? '';
|
|
break;
|
|
case 'firstContactedAt':
|
|
aVal = a.firstContactedAt ?? '';
|
|
bVal = b.firstContactedAt ?? '';
|
|
break;
|
|
case 'lastContactedAt':
|
|
aVal = a.lastContactedAt ?? '';
|
|
bVal = b.lastContactedAt ?? '';
|
|
break;
|
|
case 'status':
|
|
aVal = a.leadStatus ?? '';
|
|
bVal = b.leadStatus ?? '';
|
|
break;
|
|
case 'createdAt':
|
|
aVal = a.createdAt ?? '';
|
|
bVal = b.createdAt ?? '';
|
|
break;
|
|
case 'spamScore':
|
|
aVal = a.spamScore ?? 0;
|
|
bVal = b.spamScore ?? 0;
|
|
break;
|
|
default:
|
|
return 0;
|
|
}
|
|
|
|
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
|
return sortDirection === 'asc' ? aVal - bVal : bVal - aVal;
|
|
}
|
|
|
|
const aStr = String(aVal);
|
|
const bStr = String(bVal);
|
|
return sortDirection === 'asc' ? aStr.localeCompare(bStr) : bStr.localeCompare(aStr);
|
|
});
|
|
return sorted;
|
|
}, [filteredLeads, sortField, sortDirection]);
|
|
|
|
// Apply "My Leads" + campaign filter
|
|
const displayLeads = useMemo(() => {
|
|
let result = sortedLeads;
|
|
if (myLeadsOnly) {
|
|
result = result.filter((l) => l.assignedAgent === user.name);
|
|
}
|
|
if (campaignFilter) {
|
|
result = campaignFilter === '__none__'
|
|
? result.filter((l) => !l.campaignId)
|
|
: result.filter((l) => l.campaignId === campaignFilter);
|
|
}
|
|
return result;
|
|
}, [sortedLeads, myLeadsOnly, user.name, campaignFilter]);
|
|
|
|
// Client-side pagination
|
|
const totalPages = Math.max(1, Math.ceil(displayLeads.length / PAGE_SIZE));
|
|
const pagedLeads = displayLeads.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
|
|
|
|
const handleSort = (field: string) => {
|
|
if (field === sortField) {
|
|
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
|
} else {
|
|
setSortField(field);
|
|
setSortDirection('asc');
|
|
}
|
|
setCurrentPage(1);
|
|
};
|
|
|
|
|
|
const handleExportCsv = () => {
|
|
// Export exactly what the user currently sees — same filters, same
|
|
// sort, same tab/campaign scope. Ignores pagination so the file
|
|
// contains every matching row, not just the current page.
|
|
if (displayLeads.length === 0) {
|
|
notify.error('Export CSV', 'No leads to export');
|
|
return;
|
|
}
|
|
const headers = [
|
|
'Phone', 'First Name', 'Last Name', 'Email',
|
|
'Source', 'Status', 'Campaign', 'Assigned Agent',
|
|
'First Contact', 'Last Contact', 'Created', 'Age (days)',
|
|
];
|
|
const campaignNameById = new Map(campaigns.map((c) => [c.id, c.campaignName]));
|
|
const now = Date.now();
|
|
const rows = displayLeads.map((l) => {
|
|
const createdMs = l.createdAt ? new Date(l.createdAt).getTime() : null;
|
|
return {
|
|
'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 ?? '',
|
|
'Campaign': l.campaignId ? (campaignNameById.get(l.campaignId) ?? '') : '',
|
|
'Assigned Agent': l.assignedAgent ?? '',
|
|
'First Contact': l.firstContactedAt ?? '',
|
|
'Last Contact': l.lastContactedAt ?? '',
|
|
'Created': l.createdAt ?? '',
|
|
'Age (days)': createdMs ? String(Math.floor((now - createdMs) / 86400000)) : '',
|
|
};
|
|
});
|
|
const csv = rowsToCsv(headers, rows);
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
downloadCsv(`leads-${tab}-${today}.csv`, csv);
|
|
notify.success('Export CSV', `${rows.length} lead${rows.length === 1 ? '' : 's'} exported`);
|
|
};
|
|
|
|
const handlePageChange = (page: number) => {
|
|
setCurrentPage(page);
|
|
};
|
|
|
|
// Build active filters for pills display
|
|
const activeFilters: ActiveFilter[] = [];
|
|
if (sourceFilter) {
|
|
activeFilters.push({ key: 'source', label: 'Source', value: sourceFilter });
|
|
}
|
|
if (searchQuery) {
|
|
activeFilters.push({ key: 'search', label: 'Search', value: searchQuery });
|
|
}
|
|
|
|
const handleRemoveFilter = (key: string) => {
|
|
if (key === 'source') setSourceFilter(null);
|
|
if (key === 'search') setSearchQuery('');
|
|
setCurrentPage(1);
|
|
};
|
|
|
|
const handleClearAllFilters = () => {
|
|
setSourceFilter(null);
|
|
setSearchQuery('');
|
|
setCurrentPage(1);
|
|
};
|
|
|
|
|
|
// Activity slideout state
|
|
const [activityLead, setActivityLead] = useState<Lead | null>(null);
|
|
const [isActivityOpen, setIsActivityOpen] = useState(false);
|
|
|
|
const handleViewActivity = (lead: Lead) => {
|
|
setActivityLead(lead);
|
|
setIsActivityOpen(true);
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-1 flex-col overflow-hidden">
|
|
<PageHeader
|
|
title="All Leads"
|
|
subtitle={`${total} total`}
|
|
infoText="Campaign-sourced marketing leads. Organic callers are on the Contacts page."
|
|
controls={
|
|
<>
|
|
<div className="w-56">
|
|
<Input
|
|
placeholder="Search leads..."
|
|
icon={SearchLg}
|
|
size="sm"
|
|
value={searchQuery}
|
|
onChange={(value) => {
|
|
setSearchQuery(value);
|
|
setCurrentPage(1);
|
|
}}
|
|
aria-label="Search leads"
|
|
/>
|
|
</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">
|
|
|
|
{/* Active filters */}
|
|
{activeFilters.length > 0 && (
|
|
<div className="shrink-0 px-6 pt-2">
|
|
<FilterPills
|
|
filters={activeFilters}
|
|
onRemove={handleRemoveFilter}
|
|
onClearAll={handleClearAllFilters}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Campaign filter pills */}
|
|
{campaigns.length > 0 && (
|
|
<div className="flex shrink-0 items-center gap-1.5 px-6 py-2 border-b border-secondary overflow-x-auto">
|
|
<button
|
|
onClick={() => { setCampaignFilter(null); setCurrentPage(1); }}
|
|
className={cx(
|
|
'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
|
|
!campaignFilter
|
|
? 'bg-brand-solid text-white'
|
|
: 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
|
|
)}
|
|
>
|
|
All
|
|
</button>
|
|
{campaigns.map(c => {
|
|
const isActive = campaignFilter === c.id;
|
|
const count = filteredLeads.filter(l => l.campaignId === c.id).length;
|
|
return (
|
|
<button
|
|
key={c.id}
|
|
onClick={() => { setCampaignFilter(isActive ? null : c.id); setCurrentPage(1); }}
|
|
className={cx(
|
|
'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
|
|
isActive
|
|
? 'bg-brand-solid text-white'
|
|
: 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
|
|
)}
|
|
>
|
|
{c.campaignName ?? 'Untitled'} ({count})
|
|
</button>
|
|
);
|
|
})}
|
|
<button
|
|
onClick={() => { setCampaignFilter(campaignFilter === '__none__' ? null : '__none__'); setCurrentPage(1); }}
|
|
className={cx(
|
|
'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
|
|
campaignFilter === '__none__'
|
|
? 'bg-brand-solid text-white'
|
|
: 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
|
|
)}
|
|
>
|
|
No Campaign ({filteredLeads.filter(l => !l.campaignId).length})
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Table — fills remaining space, scrolls internally */}
|
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-2">
|
|
<LeadTable
|
|
leads={pagedLeads}
|
|
onSelectionChange={() => {}}
|
|
selectedIds={[]}
|
|
selectionMode="none"
|
|
sortField={sortField}
|
|
sortDirection={sortDirection}
|
|
onSort={handleSort}
|
|
onViewActivity={handleViewActivity}
|
|
visibleColumns={visibleColumns}
|
|
/>
|
|
</div>
|
|
|
|
{/* Pagination — pinned at bottom */}
|
|
{totalPages > 1 && (
|
|
<div className="shrink-0 border-t border-secondary px-6 py-3">
|
|
<PaginationPageDefault
|
|
page={currentPage}
|
|
total={totalPages}
|
|
onPageChange={handlePageChange}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Activity slideout */}
|
|
{activityLead && (
|
|
<LeadActivitySlideout
|
|
isOpen={isActivityOpen}
|
|
onOpenChange={setIsActivityOpen}
|
|
lead={activityLead}
|
|
activities={leadActivities}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|