sortable-column-header.tsx11.1 KBView on GitHub /**
* Sortable Column Header Component
* Renders column name with sorting functionality - entire header is clickable
* For enum fields, shows a popover with unified filter/sort controls
* For date fields, shows a popover with date filtering controls
* For binary fields, shows a popover with binary choice filtering
*/
import { BinaryColumnPopover, type BinaryColumnField } from './binary-column-popover';
import { NumberColumnPopover, type NumberColumnField } from './number-column-popover';
import { DateColumnPopover, type DateColumnField } from './date-column-popover';
import { UnifiedColumnPopover } from './unified-column-popover';
import { ArrowUp, ArrowDown, Filter } from 'lucide-react';
import { getColumnIcon } from './editable-column-header';
import type { CRMColumn } from '../store/crmSlice';
import { useCedarStore } from '@/modules/store';
interface SortableColumnHeaderProps {
colConfig: CRMColumn;
onUpdate?: (updatedColumn: CRMColumn) => void;
onDelete?: (columnId: string) => void;
}
const SORTABLE_FIELDS = new Set<string>([
'type',
'status',
'priority',
'lastContactedAt',
'nextSteps',
'currentTasks',
'history',
'dealValue',
'name',
'crmSynced',
'hasFutureCalendar',
]);
const ENUM_FIELDS = new Set<string>([
'type',
'status',
'priority',
'crmSynced',
'history',
'hasFutureCalendar',
]);
const DATE_FIELDS = new Set<string>(['lastContactedAt', 'nextSteps']);
const NUMBER_FIELDS = new Set<string>(['dealValue']);
const BINARY_FIELDS = new Set<string>(['currentTasks']);
export function SortableColumnHeader({ colConfig }: SortableColumnHeaderProps) {
const enumOptions = colConfig.enumOptions;
const toggleColumnSort = useCedarStore((state) => state.toggleColumnSort);
const isSortable = SORTABLE_FIELDS.has(colConfig.id);
const isEnumField = ENUM_FIELDS.has(colConfig.id);
const isDateField = DATE_FIELDS.has(colConfig.id);
const isNumberField = NUMBER_FIELDS.has(colConfig.id);
const isBinaryField = BINARY_FIELDS.has(colConfig.id);
// Get sort state from column config
const columnSort = colConfig.sort;
const isActiveSortField = columnSort?.active ?? false;
const sortPriority = isActiveSortField && columnSort ? columnSort.priority + 1 : null; // 1-indexed for display
const sortDirection = columnSort?.direction ?? 'asc';
// Check if this column has active filters
const columnFilter = colConfig.filter;
const hasActiveFilters =
(columnFilter?.selected?.length || 0) + (columnFilter?.excluded?.length || 0) > 0;
const hasActiveDateFilter = !!columnFilter?.dateOperator; // Show if any operator is set (including 'empty')
const hasActiveNumberFilter = !!columnFilter?.numberOperator; // Show if any operator is set (including 'empty')
const hasActiveBinaryFilter = !!columnFilter?.binaryChoice;
const handleHeaderClick = () => {
if (!isSortable) return;
// For non-enum, non-date, non-binary fields, toggle sort (asc -> desc -> off)
toggleColumnSort(colConfig.id);
};
// Binary field with binary choice popover (e.g., tasks)
if (isBinaryField) {
const binaryOptions: [
{ value: 'true'; label: string; color: string },
{ value: 'false'; label: string; color: string },
] =
colConfig.id === 'currentTasks'
? [
{
value: 'true',
label: 'Has Todo Tasks',
color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
},
{
value: 'false',
label: 'No Todo Tasks',
color: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200',
},
]
: [
{ value: 'true', label: 'Yes', color: 'bg-green-100 text-green-800' },
{ value: 'false', label: 'No', color: 'bg-gray-100 text-gray-800' },
];
return (
<BinaryColumnPopover
field={colConfig.id as BinaryColumnField}
options={binaryOptions}
title={colConfig.name}
>
<button
type="button"
className="flex min-w-0 cursor-pointer items-center gap-2 text-left hover:opacity-70"
>
<div className="text-muted-foreground shrink-0">{getColumnIcon(colConfig.type)}</div>
<span className="truncate font-medium">{colConfig.name}</span>
<div className="ml-auto flex items-center gap-1">
{hasActiveBinaryFilter && (
<div className="text-primary flex h-5 w-5 shrink-0 items-center justify-center">
<Filter className="h-3 w-3" />
</div>
)}
{isActiveSortField && sortPriority && sortPriority > 1 && (
<div className="bg-primary/10 text-primary rounded-full px-1.5 py-0.5 text-[10px] font-bold">
{sortPriority}
</div>
)}
{isActiveSortField && (
<div className="text-primary flex h-5 w-5 shrink-0 items-center justify-center">
{sortDirection === 'asc' ? (
<ArrowUp className="h-3.5 w-3.5" />
) : (
<ArrowDown className="h-3.5 w-3.5" />
)}
</div>
)}
</div>
</button>
</BinaryColumnPopover>
);
}
// Date field with date filter popover
if (isDateField) {
return (
<DateColumnPopover field={colConfig.id as DateColumnField}>
<button
type="button"
className="flex min-w-0 cursor-pointer items-center gap-2 text-left hover:opacity-70"
>
<div className="text-muted-foreground shrink-0">{getColumnIcon(colConfig.type)}</div>
<span className="truncate font-medium">{colConfig.name}</span>
<div className="ml-auto flex items-center gap-1">
{hasActiveDateFilter && (
<div className="text-primary flex h-5 w-5 shrink-0 items-center justify-center">
<Filter className="h-3 w-3" />
</div>
)}
{isActiveSortField && sortPriority && sortPriority > 1 && (
<div className="bg-primary/10 text-primary rounded-full px-1.5 py-0.5 text-[10px] font-bold">
{sortPriority}
</div>
)}
{isActiveSortField && (
<div className="text-primary flex h-5 w-5 shrink-0 items-center justify-center">
{sortDirection === 'asc' ? (
<ArrowUp className="h-3.5 w-3.5" />
) : (
<ArrowDown className="h-3.5 w-3.5" />
)}
</div>
)}
</div>
</button>
</DateColumnPopover>
);
}
// Number field with number filter popover
if (isNumberField) {
return (
<NumberColumnPopover
field={colConfig.id as NumberColumnField}
title={colConfig.name}
prefix={colConfig.id === 'dealValue' ? '$' : undefined}
>
<button
type="button"
className="flex min-w-0 cursor-pointer items-center gap-2 text-left hover:opacity-70"
>
<div className="text-muted-foreground shrink-0">{getColumnIcon(colConfig.type)}</div>
<span className="truncate font-medium">{colConfig.name}</span>
<div className="ml-auto flex items-center gap-1">
{hasActiveNumberFilter && (
<div className="text-primary flex h-5 w-5 shrink-0 items-center justify-center">
<Filter className="h-3 w-3" />
</div>
)}
{isActiveSortField && sortPriority && sortPriority > 1 && (
<div className="bg-primary/10 text-primary rounded-full px-1.5 py-0.5 text-[10px] font-bold">
{sortPriority}
</div>
)}
{isActiveSortField && (
<div className="text-primary flex h-5 w-5 shrink-0 items-center justify-center">
{sortDirection === 'asc' ? (
<ArrowUp className="h-3.5 w-3.5" />
) : (
<ArrowDown className="h-3.5 w-3.5" />
)}
</div>
)}
</div>
</button>
</NumberColumnPopover>
);
}
// Non-enum, non-date, non-number field - simple sortable header
if (isSortable && !isEnumField && !isNumberField) {
return (
<button
onClick={handleHeaderClick}
className="flex min-w-0 cursor-pointer items-center gap-2 text-left hover:opacity-70"
>
<div className="text-muted-foreground shrink-0">{getColumnIcon(colConfig.type)}</div>
<span className="truncate font-medium">{colConfig.name}</span>
<div className="ml-auto flex items-center gap-1">
{isActiveSortField && sortPriority && sortPriority > 1 && (
<div className="bg-primary/10 text-primary rounded-full px-1.5 py-0.5 text-[10px] font-bold">
{sortPriority}
</div>
)}
{isActiveSortField && (
<div className="text-primary flex h-5 w-5 shrink-0 items-center justify-center">
{sortDirection === 'asc' ? (
<ArrowUp className="h-3.5 w-3.5" />
) : (
<ArrowDown className="h-3.5 w-3.5" />
)}
</div>
)}
</div>
</button>
);
}
// Enum field with unified filter/sort popover
// Let Radix handle open state - don't use controlled mode
if (isEnumField) {
return (
<UnifiedColumnPopover
field={
colConfig.id as
| 'status'
| 'priority'
| 'type'
| 'crmSynced'
| 'history'
| 'hasFutureCalendar'
}
enumOptions={enumOptions}
>
<button
type="button"
className="flex min-w-0 cursor-pointer items-center gap-2 text-left hover:opacity-70"
>
<div className="text-muted-foreground shrink-0">{getColumnIcon(colConfig.type)}</div>
<span className="truncate font-medium">{colConfig.name}</span>
<div className="ml-auto flex items-center gap-1">
{hasActiveFilters && (
<div className="bg-primary/10 text-primary rounded-full px-1.5 py-0.5 text-[10px] font-bold">
{(columnFilter?.selected?.length || 0) + (columnFilter?.excluded?.length || 0)}
</div>
)}
{isActiveSortField && sortPriority && sortPriority > 1 && (
<div className="bg-primary/10 text-primary rounded-full px-1.5 py-0.5 text-[10px] font-bold">
{sortPriority}
</div>
)}
{isActiveSortField && (
<div className="text-primary flex h-5 w-5 shrink-0 items-center justify-center">
{sortDirection === 'asc' ? (
<ArrowUp className="h-3.5 w-3.5" />
) : (
<ArrowDown className="h-3.5 w-3.5" />
)}
</div>
)}
</div>
</button>
</UnifiedColumnPopover>
);
}
// Non-sortable field
return (
<div className="flex min-w-0 items-center gap-2">
<div className="text-muted-foreground shrink-0">{getColumnIcon(colConfig.type)}</div>
<span className="truncate font-medium">{colConfig.name}</span>
</div>
);
}