mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
feat: build All Leads table page with bulk actions, filters, and pagination
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,258 @@
|
||||
import { TopBar } from "@/components/layout/top-bar";
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ArrowLeft, Download01, FilterLines, SearchLg, SwitchVertical01 } from '@untitledui/icons';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
||||
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { LeadTable } from '@/components/leads/lead-table';
|
||||
import { BulkActionBar } from '@/components/leads/bulk-action-bar';
|
||||
import { FilterPills } from '@/components/leads/filter-pills';
|
||||
import { useLeads } from '@/hooks/use-leads';
|
||||
import type { LeadSource, LeadStatus } from '@/types/entities';
|
||||
|
||||
type TabKey = 'new' | 'all';
|
||||
|
||||
type ActiveFilter = {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export const AllLeadsPage = () => {
|
||||
const [tab, setTab] = useState<TabKey>('new');
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [sortField, setSortField] = useState('createdAt');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
const [sourceFilter, setSourceFilter] = useState<LeadSource | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const statusFilter: LeadStatus | undefined = tab === 'new' ? 'NEW' : undefined;
|
||||
|
||||
const { leads: filteredLeads, total } = useLeads({
|
||||
source: sourceFilter ?? undefined,
|
||||
status: statusFilter,
|
||||
search: searchQuery || undefined,
|
||||
});
|
||||
|
||||
// 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]);
|
||||
|
||||
// Client-side pagination
|
||||
const totalPages = Math.max(1, Math.ceil(sortedLeads.length / PAGE_SIZE));
|
||||
const pagedLeads = sortedLeads.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 handleTabChange = (key: string | number) => {
|
||||
setTab(key as TabKey);
|
||||
setCurrentPage(1);
|
||||
setSelectedIds([]);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
setSelectedIds([]);
|
||||
};
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
const tabItems = [
|
||||
{ id: 'new', label: 'New', badge: tab === 'new' ? total : undefined },
|
||||
{ id: 'all', label: 'All Leads', badge: tab === 'all' ? total : undefined },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<TopBar title="All Leads" />
|
||||
<div className="flex flex-1 items-center justify-center p-8">
|
||||
<p className="text-tertiary">All Leads — coming soon</p>
|
||||
<TopBar title="All Leads" subtitle={`${total} total`} />
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-y-auto p-7">
|
||||
{/* Tabs + Controls row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
href="/"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
iconLeading={ArrowLeft}
|
||||
aria-label="Back to workspace"
|
||||
/>
|
||||
|
||||
<Tabs selectedKey={tab} onSelectionChange={handleTabChange}>
|
||||
<TabList items={tabItems} type="button-gray" size="sm">
|
||||
{(item) => (
|
||||
<Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />
|
||||
)}
|
||||
</TabList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
iconLeading={FilterLines}
|
||||
>
|
||||
Filter
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
iconLeading={SwitchVertical01}
|
||||
>
|
||||
Sort
|
||||
</Button>
|
||||
<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>
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
iconLeading={Download01}
|
||||
>
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active filters */}
|
||||
{activeFilters.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<FilterPills
|
||||
filters={activeFilters}
|
||||
onRemove={handleRemoveFilter}
|
||||
onClearAll={handleClearAllFilters}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk action bar */}
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<BulkActionBar
|
||||
selectedCount={selectedIds.length}
|
||||
onAssign={() => {}}
|
||||
onWhatsApp={() => {}}
|
||||
onMarkSpam={() => {}}
|
||||
onDeselect={() => setSelectedIds([])}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="mt-3">
|
||||
<LeadTable
|
||||
leads={pagedLeads}
|
||||
onSelectionChange={setSelectedIds}
|
||||
selectedIds={selectedIds}
|
||||
sortField={sortField}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-3">
|
||||
<PaginationPageDefault
|
||||
page={currentPage}
|
||||
total={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user