Files
helix-engage/src/pages/all-leads.tsx
saridsa2 dfcaa175ab feat: PageHeader component + refactor all 6 list pages
New reusable PageHeader component (src/components/layout/page-header.tsx)
with consistent layout: title + badge + subtitle on left, controls on
right, optional tabs below with no extra borders.

Refactored pages:
 - All Leads: inline header → PageHeader
 - Contacts: inline header → PageHeader
 - Appointments v2: inline header → PageHeader with tabs
 - Call History: removed p-7 wrapper + TableCard.Root → flat table
 - Patients: removed p-7 wrapper + TableCard.Root → flat table
 - Missed Calls: removed TopBar → PageHeader with tabs

All pages now share identical header spacing, font sizing, and
control alignment. No more double borders from tab + container.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:31:30 +05:30

375 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`}
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>
);
};