feat: Lead Master — column show/hide toggle, resizable table, remove dead filter/sort buttons

- ColumnToggle component with checkbox dropdown for column visibility
- useColumnVisibility hook for state management
- Campaign/Ad/FirstContact/Spam/Dups hidden by default (mostly empty)
- ResizableTableContainer wrapping Table for column resize support
- Column defaultWidth/minWidth props
- Removed non-functional Filter and Sort buttons

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 13:31:12 +05:30
parent c36802864c
commit 1e64760fd1
4 changed files with 175 additions and 60 deletions

View File

@@ -0,0 +1,94 @@
import { useState, useRef, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faColumns3 } from '@fortawesome/pro-duotone-svg-icons';
import { Button } from '@/components/base/buttons/button';
import { Checkbox } from '@/components/base/checkbox/checkbox';
import type { FC } from 'react';
const ColumnsIcon: FC<{ className?: string }> = ({ className }) => (
<FontAwesomeIcon icon={faColumns3} className={className} />
);
export type ColumnDef = {
id: string;
label: string;
defaultVisible?: boolean;
};
interface ColumnToggleProps {
columns: ColumnDef[];
visibleColumns: Set<string>;
onToggle: (columnId: string) => void;
}
export const ColumnToggle = ({ columns, visibleColumns, onToggle }: ColumnToggleProps) => {
const [open, setOpen] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
return (
<div className="relative" ref={panelRef}>
<Button
size="sm"
color="secondary"
iconLeading={ColumnsIcon}
onClick={() => setOpen(!open)}
>
Columns
</Button>
{open && (
<div className="absolute top-full right-0 mt-2 w-56 rounded-xl bg-primary shadow-xl ring-1 ring-secondary z-50 overflow-hidden">
<div className="px-3 py-2 border-b border-secondary">
<span className="text-xs font-semibold text-tertiary">Show/Hide Columns</span>
</div>
<div className="max-h-64 overflow-y-auto py-1">
{columns.map(col => (
<label
key={col.id}
className="flex items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-primary_hover cursor-pointer"
>
<Checkbox
size="sm"
isSelected={visibleColumns.has(col.id)}
onChange={() => onToggle(col.id)}
/>
{col.label}
</label>
))}
</div>
</div>
)}
</div>
);
};
export const useColumnVisibility = (columns: ColumnDef[]) => {
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(() => {
return new Set(columns.filter(c => c.defaultVisible !== false).map(c => c.id));
});
const toggle = (columnId: string) => {
setVisibleColumns(prev => {
const next = new Set(prev);
if (next.has(columnId)) {
next.delete(columnId);
} else {
next.add(columnId);
}
return next;
});
};
return { visibleColumns, toggle };
};

View File

@@ -17,7 +17,9 @@ import {
Cell as AriaCell, Cell as AriaCell,
Collection as AriaCollection, Collection as AriaCollection,
Column as AriaColumn, Column as AriaColumn,
ColumnResizer as AriaColumnResizer,
Group as AriaGroup, Group as AriaGroup,
ResizableTableContainer as AriaResizableTableContainer,
Row as AriaRow, Row as AriaRow,
Table as AriaTable, Table as AriaTable,
TableBody as AriaTableBody, TableBody as AriaTableBody,
@@ -115,9 +117,9 @@ const TableRoot = ({ className, size = "md", ...props }: TableRootProps) => {
return ( return (
<TableContext.Provider value={{ size: context?.size ?? size }}> <TableContext.Provider value={{ size: context?.size ?? size }}>
<div className="flex-1 overflow-auto min-h-0"> <AriaResizableTableContainer className="flex-1 overflow-auto min-h-0">
<AriaTable className={(state) => cx("w-full", typeof className === "function" ? className(state) : className)} {...props} /> <AriaTable className={(state) => cx("w-full", typeof className === "function" ? className(state) : className)} {...props} />
</div> </AriaResizableTableContainer>
</TableContext.Provider> </TableContext.Provider>
); );
}; };
@@ -168,9 +170,10 @@ TableHeader.displayName = "TableHeader";
interface TableHeadProps extends AriaColumnProps, Omit<ThHTMLAttributes<HTMLTableCellElement>, "children" | "className" | "style" | "id"> { interface TableHeadProps extends AriaColumnProps, Omit<ThHTMLAttributes<HTMLTableCellElement>, "children" | "className" | "style" | "id"> {
label?: string; label?: string;
tooltip?: string; tooltip?: string;
allowsResizing?: boolean;
} }
const TableHead = ({ className, tooltip, label, children, ...props }: TableHeadProps) => { const TableHead = ({ className, tooltip, label, children, allowsResizing, ...props }: TableHeadProps) => {
const { selectionBehavior } = useTableOptions(); const { selectionBehavior } = useTableOptions();
return ( return (
@@ -186,8 +189,8 @@ const TableHead = ({ className, tooltip, label, children, ...props }: TableHeadP
} }
> >
{(state) => ( {(state) => (
<AriaGroup className="flex items-center gap-1"> <AriaGroup className="flex items-center gap-1" role="presentation">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1 flex-1 min-w-0">
{label && <span className="text-xs font-semibold whitespace-nowrap text-quaternary">{label}</span>} {label && <span className="text-xs font-semibold whitespace-nowrap text-quaternary">{label}</span>}
{typeof children === "function" ? children(state) : children} {typeof children === "function" ? children(state) : children}
</div> </div>
@@ -206,6 +209,10 @@ const TableHead = ({ className, tooltip, label, children, ...props }: TableHeadP
) : ( ) : (
<FontAwesomeIcon icon={faSort} className="text-fg-quaternary" /> <FontAwesomeIcon icon={faSort} className="text-fg-quaternary" />
))} ))}
{allowsResizing && (
<AriaColumnResizer className="absolute top-0 right-0 bottom-0 w-1 cursor-col-resize bg-transparent hover:bg-brand data-[resizing]:bg-brand" />
)}
</AriaGroup> </AriaGroup>
)} )}
</AriaColumn> </AriaColumn>

View File

@@ -24,6 +24,7 @@ type LeadTableProps = {
sortDirection: 'asc' | 'desc'; sortDirection: 'asc' | 'desc';
onSort: (field: string) => void; onSort: (field: string) => void;
onViewActivity?: (lead: Lead) => void; onViewActivity?: (lead: Lead) => void;
visibleColumns?: Set<string>;
}; };
type TableRow = { type TableRow = {
@@ -53,6 +54,7 @@ export const LeadTable = ({
sortDirection, sortDirection,
onSort, onSort,
onViewActivity, onViewActivity,
visibleColumns,
}: LeadTableProps) => { }: LeadTableProps) => {
const [expandedDupId, setExpandedDupId] = useState<string | null>(null); const [expandedDupId, setExpandedDupId] = useState<string | null>(null);
@@ -92,22 +94,26 @@ export const LeadTable = ({
return rows; return rows;
}, [leads, expandedDupId]); }, [leads, expandedDupId]);
const columns = [ const allColumns = [
{ id: 'phone', label: 'Phone', allowsSorting: true }, { id: 'phone', label: 'Phone', allowsSorting: true, defaultWidth: 150 },
{ id: 'name', label: 'Name', allowsSorting: true }, { id: 'name', label: 'Name', allowsSorting: true, defaultWidth: 160 },
{ id: 'email', label: 'Email', allowsSorting: false }, { id: 'email', label: 'Email', allowsSorting: false, defaultWidth: 180 },
{ id: 'campaign', label: 'Campaign', allowsSorting: false }, { id: 'campaign', label: 'Campaign', allowsSorting: false, defaultWidth: 140 },
{ id: 'ad', label: 'Ad', allowsSorting: false }, { id: 'ad', label: 'Ad', allowsSorting: false, defaultWidth: 80 },
{ id: 'source', label: 'Source', allowsSorting: true }, { id: 'source', label: 'Source', allowsSorting: true, defaultWidth: 100 },
{ id: 'firstContactedAt', label: 'First Contact', allowsSorting: true }, { id: 'firstContactedAt', label: 'First Contact', allowsSorting: true, defaultWidth: 130 },
{ id: 'lastContactedAt', label: 'Last Contact', allowsSorting: true }, { id: 'lastContactedAt', label: 'Last Contact', allowsSorting: true, defaultWidth: 130 },
{ id: 'status', label: 'Status', allowsSorting: true }, { id: 'status', label: 'Status', allowsSorting: true, defaultWidth: 100 },
{ id: 'createdAt', label: 'Age', allowsSorting: true }, { id: 'createdAt', label: 'Age', allowsSorting: true, defaultWidth: 80 },
{ id: 'spamScore', label: 'Spam', allowsSorting: true }, { id: 'spamScore', label: 'Spam', allowsSorting: true, defaultWidth: 70 },
{ id: 'dups', label: 'Dups', allowsSorting: false }, { id: 'dups', label: 'Dups', allowsSorting: false, defaultWidth: 60 },
{ id: 'actions', label: '', allowsSorting: false }, { id: 'actions', label: '', allowsSorting: false, defaultWidth: 50 },
]; ];
const columns = visibleColumns
? allColumns.filter(c => visibleColumns.has(c.id) || c.id === 'actions')
: allColumns;
return ( return (
<div className="overflow-hidden rounded-xl ring-1 ring-secondary"> <div className="overflow-hidden rounded-xl ring-1 ring-secondary">
<Table <Table
@@ -127,6 +133,9 @@ export const LeadTable = ({
id={column.id} id={column.id}
label={column.label} label={column.label}
allowsSorting={column.allowsSorting} allowsSorting={column.allowsSorting}
allowsResizing={column.id !== 'actions'}
defaultWidth={column.defaultWidth}
minWidth={50}
/> />
)} )}
</Table.Header> </Table.Header>
@@ -204,6 +213,8 @@ export const LeadTable = ({
const isDup = lead.isDuplicate === true; const isDup = lead.isDuplicate === true;
const isExpanded = expandedDupId === lead.id; const isExpanded = expandedDupId === lead.id;
const isCol = (id: string) => !visibleColumns || visibleColumns.has(id);
return ( return (
<Table.Row <Table.Row
key={row.id} key={row.id}
@@ -213,16 +224,16 @@ export const LeadTable = ({
isSelected && 'bg-brand-primary', isSelected && 'bg-brand-primary',
)} )}
> >
<Table.Cell> {isCol('phone') && <Table.Cell>
<span className="font-semibold text-primary">{phone}</span> <span className="font-semibold text-primary">{phone}</span>
</Table.Cell> </Table.Cell>}
<Table.Cell> {isCol('name') && <Table.Cell>
<span className="text-secondary">{name}</span> <span className="text-secondary">{name}</span>
</Table.Cell> </Table.Cell>}
<Table.Cell> {isCol('email') && <Table.Cell>
<span className="text-tertiary">{email}</span> <span className="text-tertiary">{email}</span>
</Table.Cell> </Table.Cell>}
<Table.Cell> {isCol('campaign') && <Table.Cell>
{lead.utmCampaign ? ( {lead.utmCampaign ? (
<Badge size="sm" type="pill-color" color="purple"> <Badge size="sm" type="pill-color" color="purple">
{lead.utmCampaign} {lead.utmCampaign}
@@ -230,8 +241,8 @@ export const LeadTable = ({
) : ( ) : (
<span className="text-tertiary">{'\u2014'}</span> <span className="text-tertiary">{'\u2014'}</span>
)} )}
</Table.Cell> </Table.Cell>}
<Table.Cell> {isCol('ad') && <Table.Cell>
{lead.adId ? ( {lead.adId ? (
<Badge size="sm" type="pill-color" color="success"> <Badge size="sm" type="pill-color" color="success">
Ad Ad
@@ -239,50 +250,50 @@ export const LeadTable = ({
) : ( ) : (
<span className="text-tertiary">{'\u2014'}</span> <span className="text-tertiary">{'\u2014'}</span>
)} )}
</Table.Cell> </Table.Cell>}
<Table.Cell> {isCol('source') && <Table.Cell>
{lead.leadSource ? ( {lead.leadSource ? (
<SourceTag source={lead.leadSource} /> <SourceTag source={lead.leadSource} />
) : ( ) : (
<span className="text-tertiary">{'\u2014'}</span> <span className="text-tertiary">{'\u2014'}</span>
)} )}
</Table.Cell> </Table.Cell>}
<Table.Cell> {isCol('firstContactedAt') && <Table.Cell>
<span className="text-tertiary"> <span className="text-tertiary">
{lead.firstContactedAt {lead.firstContactedAt
? formatShortDate(lead.firstContactedAt) ? formatShortDate(lead.firstContactedAt)
: '\u2014'} : '\u2014'}
</span> </span>
</Table.Cell> </Table.Cell>}
<Table.Cell> {isCol('lastContactedAt') && <Table.Cell>
<span className="text-tertiary"> <span className="text-tertiary">
{lead.lastContactedAt {lead.lastContactedAt
? formatShortDate(lead.lastContactedAt) ? formatShortDate(lead.lastContactedAt)
: '\u2014'} : '\u2014'}
</span> </span>
</Table.Cell> </Table.Cell>}
<Table.Cell> {isCol('status') && <Table.Cell>
{lead.leadStatus ? ( {lead.leadStatus ? (
<LeadStatusBadge status={lead.leadStatus} /> <LeadStatusBadge status={lead.leadStatus} />
) : ( ) : (
<span className="text-tertiary">{'\u2014'}</span> <span className="text-tertiary">{'\u2014'}</span>
)} )}
</Table.Cell> </Table.Cell>}
<Table.Cell> {isCol('createdAt') && <Table.Cell>
{lead.createdAt ? ( {lead.createdAt ? (
<AgeIndicator dateStr={lead.createdAt} /> <AgeIndicator dateStr={lead.createdAt} />
) : ( ) : (
<span className="text-tertiary">{'\u2014'}</span> <span className="text-tertiary">{'\u2014'}</span>
)} )}
</Table.Cell> </Table.Cell>}
<Table.Cell> {isCol('spamScore') && <Table.Cell>
{lead.spamScore != null ? ( {lead.spamScore != null ? (
<SpamDisplay score={lead.spamScore} /> <SpamDisplay score={lead.spamScore} />
) : ( ) : (
<span className="text-tertiary">0%</span> <span className="text-tertiary">0%</span>
)} )}
</Table.Cell> </Table.Cell>}
<Table.Cell> {isCol('dups') && <Table.Cell>
{isDup ? ( {isDup ? (
<button <button
type="button" type="button"
@@ -297,7 +308,7 @@ export const LeadTable = ({
) : ( ) : (
<span className="text-tertiary">0</span> <span className="text-tertiary">0</span>
)} )}
</Table.Cell> </Table.Cell>}
<Table.Cell> <Table.Cell>
<Button <Button
size="sm" size="sm"

View File

@@ -2,19 +2,18 @@ import type { FC } from 'react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowLeft, faArrowDownToLine, faFilterList, faMagnifyingGlass, faArrowUpArrowDown } from '@fortawesome/pro-duotone-svg-icons'; import { faArrowLeft, faArrowDownToLine, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
const ArrowLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowLeft} className={className} />; const ArrowLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowLeft} className={className} />;
const Download01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowDownToLine} className={className} />; const Download01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowDownToLine} className={className} />;
const FilterLines: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faFilterList} className={className} />;
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />; const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
const SwitchVertical01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowUpArrowDown} className={className} />;
import { Button } from '@/components/base/buttons/button'; import { Button } from '@/components/base/buttons/button';
import { Input } from '@/components/base/input/input'; import { Input } from '@/components/base/input/input';
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs'; import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
import { PaginationPageDefault } from '@/components/application/pagination/pagination'; import { PaginationPageDefault } from '@/components/application/pagination/pagination';
import { TopBar } from '@/components/layout/top-bar'; import { TopBar } from '@/components/layout/top-bar';
import { LeadTable } from '@/components/leads/lead-table'; import { LeadTable } from '@/components/leads/lead-table';
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
import { BulkActionBar } from '@/components/leads/bulk-action-bar'; import { BulkActionBar } from '@/components/leads/bulk-action-bar';
import { FilterPills } from '@/components/leads/filter-pills'; import { FilterPills } from '@/components/leads/filter-pills';
import { AssignModal } from '@/components/modals/assign-modal'; import { AssignModal } from '@/components/modals/assign-modal';
@@ -61,6 +60,22 @@ export const AllLeadsPage = () => {
const { agents, templates, leadActivities, campaigns } = useData(); const { agents, templates, leadActivities, campaigns } = useData();
const [campaignFilter, setCampaignFilter] = useState<string | null>(null); 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 // Client-side sorting
const sortedLeads = useMemo(() => { const sortedLeads = useMemo(() => {
const sorted = [...filteredLeads]; const sorted = [...filteredLeads];
@@ -233,20 +248,6 @@ export const AllLeadsPage = () => {
</div> </div>
<div className="flex items-center gap-2"> <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"> <div className="w-56">
<Input <Input
placeholder="Search leads..." placeholder="Search leads..."
@@ -260,6 +261,7 @@ export const AllLeadsPage = () => {
aria-label="Search leads" aria-label="Search leads"
/> />
</div> </div>
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
<Button <Button
size="sm" size="sm"
color="secondary" color="secondary"
@@ -350,6 +352,7 @@ export const AllLeadsPage = () => {
sortDirection={sortDirection} sortDirection={sortDirection}
onSort={handleSort} onSort={handleSort}
onViewActivity={handleViewActivity} onViewActivity={handleViewActivity}
visibleColumns={visibleColumns}
/> />
</div> </div>