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:
50
src/components/leads/bulk-action-bar.tsx
Normal file
50
src/components/leads/bulk-action-bar.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
interface BulkActionBarProps {
|
||||||
|
selectedCount: number;
|
||||||
|
onAssign: () => void;
|
||||||
|
onWhatsApp: () => void;
|
||||||
|
onMarkSpam: () => void;
|
||||||
|
onDeselect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BulkActionBar = ({ selectedCount, onAssign, onWhatsApp, onMarkSpam, onDeselect }: BulkActionBarProps) => {
|
||||||
|
if (selectedCount === 0) return null;
|
||||||
|
|
||||||
|
const buttonBase = 'rounded-lg px-3 py-1.5 text-xs font-semibold border-none cursor-pointer transition duration-100 ease-linear';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 rounded-xl bg-brand-section p-3 text-white">
|
||||||
|
<span className="text-sm font-semibold">{selectedCount} leads selected</span>
|
||||||
|
|
||||||
|
<div className="ml-auto flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAssign}
|
||||||
|
className={`${buttonBase} bg-white/20 text-white hover:bg-white/30`}
|
||||||
|
>
|
||||||
|
Assign to Call Center
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onWhatsApp}
|
||||||
|
className={`${buttonBase} bg-success-solid text-white hover:bg-success-solid/90`}
|
||||||
|
>
|
||||||
|
Send WhatsApp
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onMarkSpam}
|
||||||
|
className={`${buttonBase} bg-white/15 text-error-primary hover:bg-white/25`}
|
||||||
|
>
|
||||||
|
Mark Spam
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDeselect}
|
||||||
|
className={`${buttonBase} bg-white/10 text-white/70 hover:bg-white/20 hover:text-white`}
|
||||||
|
>
|
||||||
|
Deselect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
48
src/components/leads/filter-pills.tsx
Normal file
48
src/components/leads/filter-pills.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { X } from '@untitledui/icons';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
|
type FilterPill = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FilterPillsProps {
|
||||||
|
filters: FilterPill[];
|
||||||
|
onRemove: (key: string) => void;
|
||||||
|
onClearAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FilterPills = ({ filters, onRemove, onClearAll }: FilterPillsProps) => {
|
||||||
|
if (filters.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{filters.map((filter) => (
|
||||||
|
<span
|
||||||
|
key={filter.key}
|
||||||
|
className={cx(
|
||||||
|
'flex items-center gap-1 rounded-full border border-brand bg-brand-primary px-3 py-1 text-xs font-medium text-brand-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{filter.label}: {filter.value}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(filter.key)}
|
||||||
|
className="ml-0.5 cursor-pointer rounded-full p-0.5 transition duration-100 ease-linear hover:bg-brand-secondary hover:text-white"
|
||||||
|
aria-label={`Remove ${filter.label} filter`}
|
||||||
|
>
|
||||||
|
<X className="size-3 stroke-[3px]" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClearAll}
|
||||||
|
className="ml-2 cursor-pointer border-none bg-transparent text-xs text-quaternary transition duration-100 ease-linear hover:text-error-primary"
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
310
src/components/leads/lead-table.tsx
Normal file
310
src/components/leads/lead-table.tsx
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { TableBody as AriaTableBody } from 'react-aria-components';
|
||||||
|
import type { SortDescriptor, Selection } from 'react-aria-components';
|
||||||
|
import { DotsVertical } from '@untitledui/icons';
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { Table } from '@/components/application/table/table';
|
||||||
|
import { LeadStatusBadge } from '@/components/shared/status-badge';
|
||||||
|
import { SourceTag } from '@/components/shared/source-tag';
|
||||||
|
import { AgeIndicator } from '@/components/shared/age-indicator';
|
||||||
|
import { formatPhone, formatShortDate } from '@/lib/format';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
import type { Lead } from '@/types/entities';
|
||||||
|
|
||||||
|
type LeadTableProps = {
|
||||||
|
leads: Lead[];
|
||||||
|
onSelectionChange: (selectedIds: string[]) => void;
|
||||||
|
selectedIds: string[];
|
||||||
|
sortField: string;
|
||||||
|
sortDirection: 'asc' | 'desc';
|
||||||
|
onSort: (field: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TableRow = {
|
||||||
|
id: string;
|
||||||
|
type: 'lead' | 'dup-sub';
|
||||||
|
lead: Lead;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SpamDisplay = ({ score }: { score: number }) => {
|
||||||
|
const colorClass =
|
||||||
|
score < 30
|
||||||
|
? 'text-success-primary'
|
||||||
|
: score < 60
|
||||||
|
? 'text-warning-primary'
|
||||||
|
: 'text-error-primary';
|
||||||
|
|
||||||
|
const bgClass = score >= 60 ? 'rounded px-1.5 py-0.5 bg-error-primary' : '';
|
||||||
|
|
||||||
|
return <span className={cx('text-xs font-semibold', colorClass, bgClass)}>{score}%</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LeadTable = ({
|
||||||
|
leads,
|
||||||
|
onSelectionChange,
|
||||||
|
selectedIds,
|
||||||
|
sortField,
|
||||||
|
sortDirection,
|
||||||
|
onSort,
|
||||||
|
}: LeadTableProps) => {
|
||||||
|
const [expandedDupId, setExpandedDupId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const selectedKeys: Selection = new Set(selectedIds);
|
||||||
|
|
||||||
|
const handleSelectionChange = (keys: Selection) => {
|
||||||
|
if (keys === 'all') {
|
||||||
|
// Only select actual lead rows, not dup sub-rows
|
||||||
|
onSelectionChange(leads.map((l) => l.id));
|
||||||
|
} else {
|
||||||
|
// Filter out dup sub-row IDs
|
||||||
|
const leadOnlyIds = [...keys].filter((k) => !String(k).endsWith('-dup')) as string[];
|
||||||
|
onSelectionChange(leadOnlyIds);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortDescriptor: SortDescriptor = {
|
||||||
|
column: sortField,
|
||||||
|
direction: sortDirection === 'asc' ? 'ascending' : 'descending',
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSortChange = (descriptor: SortDescriptor) => {
|
||||||
|
if (descriptor.column) {
|
||||||
|
onSort(String(descriptor.column));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Flatten leads + expanded dup sub-rows into a single list
|
||||||
|
const tableRows = useMemo<TableRow[]>(() => {
|
||||||
|
const rows: TableRow[] = [];
|
||||||
|
for (const lead of leads) {
|
||||||
|
rows.push({ id: lead.id, type: 'lead', lead });
|
||||||
|
if (lead.isDuplicate === true && expandedDupId === lead.id) {
|
||||||
|
rows.push({ id: `${lead.id}-dup`, type: 'dup-sub', lead });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}, [leads, expandedDupId]);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ id: 'phone', label: 'Phone', allowsSorting: true },
|
||||||
|
{ id: 'name', label: 'Name', allowsSorting: true },
|
||||||
|
{ id: 'email', label: 'Email', allowsSorting: false },
|
||||||
|
{ id: 'campaign', label: 'Campaign', allowsSorting: false },
|
||||||
|
{ id: 'ad', label: 'Ad', allowsSorting: false },
|
||||||
|
{ id: 'source', label: 'Source', allowsSorting: true },
|
||||||
|
{ id: 'firstContactedAt', label: 'First Contact', allowsSorting: true },
|
||||||
|
{ id: 'lastContactedAt', label: 'Last Contact', allowsSorting: true },
|
||||||
|
{ id: 'status', label: 'Status', allowsSorting: true },
|
||||||
|
{ id: 'createdAt', label: 'Age', allowsSorting: true },
|
||||||
|
{ id: 'spamScore', label: 'Spam', allowsSorting: true },
|
||||||
|
{ id: 'dups', label: 'Dups', allowsSorting: false },
|
||||||
|
{ id: 'actions', label: '', allowsSorting: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-xl ring-1 ring-secondary">
|
||||||
|
<Table
|
||||||
|
aria-label="Leads table"
|
||||||
|
selectionMode="multiple"
|
||||||
|
selectionBehavior="toggle"
|
||||||
|
selectedKeys={selectedKeys}
|
||||||
|
onSelectionChange={handleSelectionChange}
|
||||||
|
sortDescriptor={sortDescriptor}
|
||||||
|
onSortChange={handleSortChange}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Table.Header columns={columns}>
|
||||||
|
{(column) => (
|
||||||
|
<Table.Head
|
||||||
|
key={column.id}
|
||||||
|
id={column.id}
|
||||||
|
label={column.label}
|
||||||
|
allowsSorting={column.allowsSorting}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Table.Header>
|
||||||
|
|
||||||
|
<AriaTableBody items={tableRows}>
|
||||||
|
{(row) => {
|
||||||
|
const { lead } = row;
|
||||||
|
const firstName = lead.contactName?.firstName ?? '';
|
||||||
|
const lastName = lead.contactName?.lastName ?? '';
|
||||||
|
const name = `${firstName} ${lastName}`.trim() || '\u2014';
|
||||||
|
const phone = lead.contactPhone?.[0]
|
||||||
|
? formatPhone(lead.contactPhone[0])
|
||||||
|
: '\u2014';
|
||||||
|
const email = lead.contactEmail?.[0]?.address ?? '\u2014';
|
||||||
|
|
||||||
|
// Render duplicate sub-row
|
||||||
|
if (row.type === 'dup-sub') {
|
||||||
|
return (
|
||||||
|
<Table.Row
|
||||||
|
key={row.id}
|
||||||
|
id={row.id}
|
||||||
|
className="bg-warning-primary"
|
||||||
|
>
|
||||||
|
<Table.Cell className="pl-10">
|
||||||
|
<span className="text-xs text-tertiary">{phone}</span>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<span className="text-xs text-tertiary">{name}</span>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<span className="text-xs text-tertiary">{email}</span>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
{lead.leadSource ? (
|
||||||
|
<SourceTag source={lead.leadSource} size="sm" />
|
||||||
|
) : (
|
||||||
|
<span className="text-tertiary">{'\u2014'}</span>
|
||||||
|
)}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Badge size="sm" type="pill-color" color="warning">
|
||||||
|
Same phone number
|
||||||
|
</Badge>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<span className="text-xs text-tertiary">
|
||||||
|
{lead.createdAt
|
||||||
|
? formatShortDate(lead.createdAt)
|
||||||
|
: '\u2014'}
|
||||||
|
</span>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell />
|
||||||
|
<Table.Cell />
|
||||||
|
<Table.Cell />
|
||||||
|
<Table.Cell />
|
||||||
|
<Table.Cell />
|
||||||
|
<Table.Cell />
|
||||||
|
<Table.Cell>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" color="primary">
|
||||||
|
Merge
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" color="secondary">
|
||||||
|
Keep Separate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render normal lead row
|
||||||
|
const isSpamRow = (lead.spamScore ?? 0) >= 60;
|
||||||
|
const isSelected = selectedIds.includes(lead.id);
|
||||||
|
const isDup = lead.isDuplicate === true;
|
||||||
|
const isExpanded = expandedDupId === lead.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.Row
|
||||||
|
key={row.id}
|
||||||
|
id={row.id}
|
||||||
|
className={cx(
|
||||||
|
isSpamRow && !isSelected && 'bg-warning-primary',
|
||||||
|
isSelected && 'bg-brand-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Table.Cell>
|
||||||
|
<span className="font-semibold text-primary">{phone}</span>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<span className="text-secondary">{name}</span>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<span className="text-tertiary">{email}</span>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
{lead.utmCampaign ? (
|
||||||
|
<Badge size="sm" type="pill-color" color="purple">
|
||||||
|
{lead.utmCampaign}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-tertiary">{'\u2014'}</span>
|
||||||
|
)}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
{lead.adId ? (
|
||||||
|
<Badge size="sm" type="pill-color" color="success">
|
||||||
|
Ad
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-tertiary">{'\u2014'}</span>
|
||||||
|
)}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
{lead.leadSource ? (
|
||||||
|
<SourceTag source={lead.leadSource} />
|
||||||
|
) : (
|
||||||
|
<span className="text-tertiary">{'\u2014'}</span>
|
||||||
|
)}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<span className="text-tertiary">
|
||||||
|
{lead.firstContactedAt
|
||||||
|
? formatShortDate(lead.firstContactedAt)
|
||||||
|
: '\u2014'}
|
||||||
|
</span>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<span className="text-tertiary">
|
||||||
|
{lead.lastContactedAt
|
||||||
|
? formatShortDate(lead.lastContactedAt)
|
||||||
|
: '\u2014'}
|
||||||
|
</span>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
{lead.leadStatus ? (
|
||||||
|
<LeadStatusBadge status={lead.leadStatus} />
|
||||||
|
) : (
|
||||||
|
<span className="text-tertiary">{'\u2014'}</span>
|
||||||
|
)}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
{lead.createdAt ? (
|
||||||
|
<AgeIndicator dateStr={lead.createdAt} />
|
||||||
|
) : (
|
||||||
|
<span className="text-tertiary">{'\u2014'}</span>
|
||||||
|
)}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
{lead.spamScore != null ? (
|
||||||
|
<SpamDisplay score={lead.spamScore} />
|
||||||
|
) : (
|
||||||
|
<span className="text-tertiary">0%</span>
|
||||||
|
)}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
{isDup ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setExpandedDupId(isExpanded ? null : lead.id);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer border-none bg-transparent text-xs font-semibold text-warning-primary hover:text-warning-primary"
|
||||||
|
>
|
||||||
|
1 {isExpanded ? '\u25B4' : '\u25BE'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-tertiary">0</span>
|
||||||
|
)}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="tertiary"
|
||||||
|
iconLeading={DotsVertical}
|
||||||
|
aria-label="Row actions"
|
||||||
|
/>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</AriaTableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 = () => {
|
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 (
|
return (
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<TopBar title="All Leads" />
|
<TopBar title="All Leads" subtitle={`${total} total`} />
|
||||||
<div className="flex flex-1 items-center justify-center p-8">
|
|
||||||
<p className="text-tertiary">All Leads — coming soon</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user