mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 10:23:27 +00:00
- 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>
329 lines
16 KiB
TypeScript
329 lines
16 KiB
TypeScript
import type { FC } from 'react';
|
|
import { useMemo, useState } from 'react';
|
|
import { TableBody as AriaTableBody } from 'react-aria-components';
|
|
import type { SortDescriptor, Selection } from 'react-aria-components';
|
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
import { faEllipsisVertical } from '@fortawesome/pro-duotone-svg-icons';
|
|
|
|
const DotsVertical: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faEllipsisVertical} className={className} />;
|
|
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;
|
|
onViewActivity?: (lead: Lead) => void;
|
|
visibleColumns?: Set<string>;
|
|
};
|
|
|
|
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,
|
|
onViewActivity,
|
|
visibleColumns,
|
|
}: 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 allColumns = [
|
|
{ id: 'phone', label: 'Phone', allowsSorting: true, defaultWidth: 150 },
|
|
{ id: 'name', label: 'Name', allowsSorting: true, defaultWidth: 160 },
|
|
{ id: 'email', label: 'Email', allowsSorting: false, defaultWidth: 180 },
|
|
{ id: 'campaign', label: 'Campaign', allowsSorting: false, defaultWidth: 140 },
|
|
{ id: 'ad', label: 'Ad', allowsSorting: false, defaultWidth: 80 },
|
|
{ id: 'source', label: 'Source', allowsSorting: true, defaultWidth: 100 },
|
|
{ id: 'firstContactedAt', label: 'First Contact', allowsSorting: true, defaultWidth: 130 },
|
|
{ id: 'lastContactedAt', label: 'Last Contact', allowsSorting: true, defaultWidth: 130 },
|
|
{ id: 'status', label: 'Status', allowsSorting: true, defaultWidth: 100 },
|
|
{ id: 'createdAt', label: 'Age', allowsSorting: true, defaultWidth: 80 },
|
|
{ id: 'spamScore', label: 'Spam', allowsSorting: true, defaultWidth: 70 },
|
|
{ id: 'dups', label: 'Dups', allowsSorting: false, defaultWidth: 60 },
|
|
{ id: 'actions', label: '', allowsSorting: false, defaultWidth: 50 },
|
|
];
|
|
|
|
const columns = visibleColumns
|
|
? allColumns.filter(c => visibleColumns.has(c.id) || c.id === 'actions')
|
|
: allColumns;
|
|
|
|
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}
|
|
allowsResizing={column.id !== 'actions'}
|
|
defaultWidth={column.defaultWidth}
|
|
minWidth={50}
|
|
/>
|
|
)}
|
|
</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;
|
|
|
|
const isCol = (id: string) => !visibleColumns || visibleColumns.has(id);
|
|
|
|
return (
|
|
<Table.Row
|
|
key={row.id}
|
|
id={row.id}
|
|
className={cx(
|
|
isSpamRow && !isSelected && 'bg-warning-primary',
|
|
isSelected && 'bg-brand-primary',
|
|
)}
|
|
>
|
|
{isCol('phone') && <Table.Cell>
|
|
<span className="font-semibold text-primary">{phone}</span>
|
|
</Table.Cell>}
|
|
{isCol('name') && <Table.Cell>
|
|
<span className="text-secondary">{name}</span>
|
|
</Table.Cell>}
|
|
{isCol('email') && <Table.Cell>
|
|
<span className="text-tertiary">{email}</span>
|
|
</Table.Cell>}
|
|
{isCol('campaign') && <Table.Cell>
|
|
{lead.utmCampaign ? (
|
|
<Badge size="sm" type="pill-color" color="purple">
|
|
{lead.utmCampaign}
|
|
</Badge>
|
|
) : (
|
|
<span className="text-tertiary">{'\u2014'}</span>
|
|
)}
|
|
</Table.Cell>}
|
|
{isCol('ad') && <Table.Cell>
|
|
{lead.adId ? (
|
|
<Badge size="sm" type="pill-color" color="success">
|
|
Ad
|
|
</Badge>
|
|
) : (
|
|
<span className="text-tertiary">{'\u2014'}</span>
|
|
)}
|
|
</Table.Cell>}
|
|
{isCol('source') && <Table.Cell>
|
|
{lead.leadSource ? (
|
|
<SourceTag source={lead.leadSource} />
|
|
) : (
|
|
<span className="text-tertiary">{'\u2014'}</span>
|
|
)}
|
|
</Table.Cell>}
|
|
{isCol('firstContactedAt') && <Table.Cell>
|
|
<span className="text-tertiary">
|
|
{lead.firstContactedAt
|
|
? formatShortDate(lead.firstContactedAt)
|
|
: '\u2014'}
|
|
</span>
|
|
</Table.Cell>}
|
|
{isCol('lastContactedAt') && <Table.Cell>
|
|
<span className="text-tertiary">
|
|
{lead.lastContactedAt
|
|
? formatShortDate(lead.lastContactedAt)
|
|
: '\u2014'}
|
|
</span>
|
|
</Table.Cell>}
|
|
{isCol('status') && <Table.Cell>
|
|
{lead.leadStatus ? (
|
|
<LeadStatusBadge status={lead.leadStatus} />
|
|
) : (
|
|
<span className="text-tertiary">{'\u2014'}</span>
|
|
)}
|
|
</Table.Cell>}
|
|
{isCol('createdAt') && <Table.Cell>
|
|
{lead.createdAt ? (
|
|
<AgeIndicator dateStr={lead.createdAt} />
|
|
) : (
|
|
<span className="text-tertiary">{'\u2014'}</span>
|
|
)}
|
|
</Table.Cell>}
|
|
{isCol('spamScore') && <Table.Cell>
|
|
{lead.spamScore != null ? (
|
|
<SpamDisplay score={lead.spamScore} />
|
|
) : (
|
|
<span className="text-tertiary">0%</span>
|
|
)}
|
|
</Table.Cell>}
|
|
{isCol('dups') && <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"
|
|
onClick={() => onViewActivity?.(lead)}
|
|
/>
|
|
</Table.Cell>
|
|
</Table.Row>
|
|
);
|
|
}}
|
|
</AriaTableBody>
|
|
</Table>
|
|
</div>
|
|
);
|
|
};
|