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:
2026-03-16 14:59:56 +05:30
parent 1bed4b7d08
commit 7970a34434
4 changed files with 659 additions and 4 deletions

View 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>
);
};

View 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>
);
};

View 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>
);
};