unified-column-popover.tsx53.2 KBView on GitHub /**
* Unified Column Popover Component
*
* Combines filtering and sorting functionality for CRM table column headers.
* Each option can be:
* - Clicked to select/deselect (for filtering)
* - Clicked on the exclude button to exclude
* - Dragged to reorder (for sorting)
*
* NOTE: For the 'type' field, selection changes selectedAopId (single source of truth)
* instead of using column filters. This syncs schema and display filtering.
*/
import {
closestCenter,
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from '@dnd-kit/core';
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { ArrowUp, Ban, Calendar as CalendarIcon, Check, ChevronDown, GripVertical, X } from 'lucide-react';
import { getEnumColor, sortEnumOptions, type EnumFieldKey } from '@/modules/crm/field-enums';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import type { CanvasFilterSortConfiguration } from '@/modules/cedar-os/src/store/messages/MessageTypes';
import type { ConversationViewConfig } from '@/modules/canvas/types/canvas-types';
import type { ColumnPopoverMode } from './column-popover-presentation';
import { useColumnFilterOverride } from './column-filter-override';
import { useActivateColumnSortAtTop } from '../hooks/use-activate-column-sort';
import type { ColumnFilter } from '@/modules/crm/store/crmSlice';
import { computeDaysOffset, resolveOffsetToDate, formatDaysOffset } from '@/modules/crm/utils/relative-dates';
import { DatePickerWithNaturalInput } from '@/components/ui/date-picker-with-natural-input';
import { Checkbox } from '@/components/ui/checkbox';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { CrmFieldEnumOption } from '@/modules/crm/types';
import { getEnumDisplayText, getAopColorStyles, getTypeColor } from '@/modules/crm/utils';
import { useAOPs } from '@/modules/aop/hooks/use-aops';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { useCedarStore } from '@/modules/store';
import { Label } from '@/components/ui/label';
import { CSS } from '@dnd-kit/utilities';
import { createPortal } from 'react-dom';
import { motion } from 'motion/react';
import { format } from 'date-fns';
import { cn } from '@/lib/utils';
type SortableEnumField = EnumFieldKey;
interface OptionValue {
value: string | null;
label: string;
}
interface SortableFilterItemProps {
id: string; // Already converted: null becomes '__null__'
option: OptionValue;
field: SortableEnumField;
isSelected: boolean;
isExcluded: boolean;
onToggleSelect: () => void;
onToggleExclude: () => void;
enumOptions?: CrmFieldEnumOption[];
aopColor?: string | null; // AOP color for 'type' field
/** Select/exclude affordances — off when the popover is scoped to sorting. */
showFilterControls?: boolean;
/** Drag handle — position in this list is the custom sort order, so it is sort-only. */
showDragHandle?: boolean;
}
function SortableFilterItem({
id,
option,
field,
isSelected,
isExcluded,
onToggleSelect,
onToggleExclude,
enumOptions,
aopColor,
showFilterControls = true,
showDragHandle = true,
}: SortableFilterItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
animateLayoutChanges: () => true,
});
const style = {
transform: CSS.Transform.toString(transform),
transition: transition || 'transform 200ms ease',
};
// For 'type' field, use AOP color if available
const colorStyles = field === 'type' ? getAopColorStyles(aopColor) : null;
const colorClass = colorStyles
? ''
: field === 'type'
? getTypeColor(option.value)
: getEnumColor(option.value, field, enumOptions);
return (
<div
ref={setNodeRef}
style={style}
className={cn('flex items-center gap-2 transition-opacity', isDragging && 'opacity-30')}
>
{/* Select/Deselect badge - clickable, with drag handle inside */}
<button
onClick={showFilterControls ? onToggleSelect : undefined}
style={colorStyles || undefined}
className={cn(
'flex-1 flex items-center gap-1 rounded-sm px-1.5 py-1.5 text-xs font-semibold transition-all',
colorClass,
showFilterControls ? 'cursor-pointer hover:scale-[1.01]' : 'cursor-default',
)}
>
{/* Drag handle inside badge — position is the sort order, so it is sort-only */}
{showDragHandle && (
<div
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing shrink-0 opacity-40 hover:opacity-70"
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="h-3.5 w-3.5" />
</div>
)}
<span className="flex-1 text-left">{option.label}</span>
{showFilterControls && isSelected && <Check className="h-3 w-3 shrink-0" />}
</button>
{/* Exclude button - hidden for type and crmSynced (single-select only) */}
{showFilterControls && field !== 'type' && field !== 'crmSynced' && (
<button
onClick={onToggleExclude}
className={cn(
'shrink-0 rounded-sm border px-2 py-1.5 transition-all hover:scale-105',
isExcluded
? 'border-red-300 bg-red-100 text-red-700 hover:bg-red-200 dark:border-red-800 dark:bg-red-950 dark:text-red-400 dark:hover:bg-red-900'
: 'border-transparent hover:border-red-200 hover:bg-red-50 hover:text-red-600 dark:hover:border-red-800 dark:hover:bg-red-950/50 dark:hover:text-red-400',
)}
>
<Ban className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}
// ============================================================
// InlineDateSection — reusable date filter UI for history popover
// Matches the DateColumnPopover style: operator select, DatePickerWithNaturalInput, relative checkbox
// ============================================================
type HistoryDateOp = 'before' | 'after' | 'on' | 'range';
const HISTORY_OPERATOR_LABELS: Record<HistoryDateOp, string> = {
before: 'Before',
after: 'After',
on: 'On',
range: 'Range',
};
interface InlineDateSectionState {
operator?: HistoryDateOp;
date?: Date | null;
dateTo?: Date | null;
relative?: boolean;
daysOffset?: number;
daysOffsetTo?: number;
}
function InlineDateSection({
label,
state,
onApply,
onClear,
}: {
label: string;
state: InlineDateSectionState;
onApply: (next: InlineDateSectionState) => void;
onClear: () => void;
}) {
const [operatorOpen, setOperatorOpen] = useState(false);
const [localOperator, setLocalOperator] = useState<HistoryDateOp>(state.operator ?? 'after');
useEffect(() => {
if (state.operator) setLocalOperator(state.operator);
}, [state.operator]);
const currentOperator = state.operator ?? localOperator;
const hasActiveFilter = !!state.date;
const handleOperatorChange = (op: HistoryDateOp) => {
setLocalOperator(op);
if (state.date) {
onApply({ ...state, operator: op, dateTo: op === 'range' ? state.dateTo : undefined, daysOffsetTo: op === 'range' ? state.daysOffsetTo : undefined });
}
setOperatorOpen(false);
};
const handleDateChange = (date: Date | null) => {
if (date) {
const op = currentOperator;
const offset = computeDaysOffset(date);
onApply({
...state,
operator: op,
date,
daysOffset: state.relative ? offset : undefined,
});
} else {
onClear();
}
};
const handleDateToChange = (date: Date | null) => {
onApply({
...state,
dateTo: date ?? undefined,
daysOffsetTo: state.relative && date ? computeDaysOffset(date) : undefined,
});
};
const handleToggleRelative = (checked: boolean) => {
if (!state.date) return;
onApply({
...state,
relative: checked,
daysOffset: checked ? computeDaysOffset(state.date) : undefined,
daysOffsetTo: checked && state.dateTo ? computeDaysOffset(state.dateTo) : undefined,
});
};
return (
<div className="space-y-2 pt-2">
{label && (
<div className="flex items-center justify-between">
<span className="text-xs font-medium">{label}</span>
{hasActiveFilter && (
<button
type="button"
onClick={onClear}
className="flex h-5 w-5 items-center justify-center rounded-sm text-red-500 transition-colors hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-950/50"
>
<X className="h-3 w-3" />
</button>
)}
</div>
)}
<div className="flex items-center gap-2">
{/* Operator select */}
<Popover open={operatorOpen} onOpenChange={setOperatorOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8 min-w-[80px] justify-between text-xs">
{HISTORY_OPERATOR_LABELS[currentOperator]}
<ChevronDown className="ml-1 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[100px] p-1" align="start">
{(Object.keys(HISTORY_OPERATOR_LABELS) as HistoryDateOp[]).map((op) => (
<button
key=[redacted]
type="button"
onClick={() => handleOperatorChange(op)}
className={cn(
'w-full rounded-sm px-2 py-1.5 text-left text-xs transition-colors',
currentOperator === op ? 'bg-primary/10 text-primary font-medium' : 'hover:bg-muted',
)}
>
{HISTORY_OPERATOR_LABELS[op]}
</button>
))}
</PopoverContent>
</Popover>
{/* Primary date picker */}
<DatePickerWithNaturalInput
value={state.date ?? null}
onChange={handleDateChange}
showTimeInput={false}
showClearButton={false}
trigger={
<Button
variant="outline"
size="sm"
className={cn('h-8 flex-1 justify-start text-xs', !state.date && 'text-muted-foreground')}
>
<CalendarIcon className="mr-2 h-3.5 w-3.5" />
{state.date
? state.relative && state.daysOffset !== undefined
? `${formatDaysOffset(state.daysOffset)} (${format(state.date, 'MMM d')})`
: format(state.date, 'MMM d, yyyy')
: currentOperator === 'range'
? 'From...'
: 'Select date...'}
</Button>
}
/>
</div>
{/* Range: second date picker */}
{currentOperator === 'range' && (
<div className="flex items-center gap-2">
<span className="w-[80px] shrink-0 pl-1 text-xs text-muted-foreground">to</span>
<DatePickerWithNaturalInput
value={state.dateTo ?? null}
onChange={handleDateToChange}
showTimeInput={false}
showClearButton={false}
trigger={
<Button
variant="outline"
size="sm"
className={cn('h-8 flex-1 justify-start text-xs', !state.dateTo && 'text-muted-foreground')}
>
<CalendarIcon className="mr-2 h-3.5 w-3.5" />
{state.dateTo
? state.relative && state.daysOffsetTo !== undefined
? `${formatDaysOffset(state.daysOffsetTo)} (${format(state.dateTo, 'MMM d')})`
: format(state.dateTo, 'MMM d, yyyy')
: 'To...'}
</Button>
}
/>
</div>
)}
{/* Relative checkbox */}
<div className="flex items-center gap-2">
<Checkbox
id={`relative-${label}`}
checked={state.relative ?? false}
disabled={!state.date}
onCheckedChange={(checked) => handleToggleRelative(checked === true)}
/>
<Label
htmlFor={`relative-${label}`}
className={cn(
'cursor-pointer select-none text-xs',
!state.date && 'cursor-not-allowed opacity-50',
)}
>
Relative
</Label>
{state.relative && (
<span className="text-[10px] text-muted-foreground">Updates daily</span>
)}
</div>
</div>
);
}
interface UnifiedColumnPopoverContentProps {
field: SortableEnumField;
enumOptions?: CrmFieldEnumOption[];
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** When provided, reads/writes sort+filter state from canvas viewConfig instead of global CRMSlice */
canvasId?: string;
/**
* Canvas-scoped AOP selection. When provided (canvas mode + type field),
* overrides crmSlice.selectedAopIds so the type popover controls the canvas's
* own selectedAopIds rather than the global CRM page state.
*/
canvasSelectedAopIds?: (string | null)[] | null;
/** Canvas-scoped setter for selectedAopIds. Called when user toggles AOPs in the type popover. */
canvasSetSelectedAopIds?: (aopIds: (string | null)[] | null) => void;
/** Which half of the popover to show. Defaults to 'full' (column header). */
mode?: ColumnPopoverMode;
/** Hand-off button ("Set filter") rendered below the controls. */
crossLink?: React.ReactNode;
}
export function UnifiedColumnPopoverContent({
field,
enumOptions,
canvasId,
canvasSelectedAopIds,
canvasSetSelectedAopIds,
mode = 'full',
crossLink,
}: UnifiedColumnPopoverContentProps) {
const columns = useCedarStore((state) => state.columns);
const setColumnFilter = useCedarStore((state) => state.setColumnFilter);
const setColumnSortCustomOrder = useCedarStore((state) => state.setColumnSortCustomOrder);
// For type field: use selectedAopIds as single source of truth (multi-select)
// In canvas mode, use the canvas-scoped values; otherwise use global crmSlice.
const globalSelectedAopIds = useCedarStore((state) => state.selectedAopIds);
const globalToggleSelectedAopId = useCedarStore((state) => state.toggleSelectedAopId);
const { data: aopsData } = useAOPs();
// Resolve which AOP state to use: canvas-scoped or global
const isCanvasTypeMode = field === 'type' && canvasId && canvasSetSelectedAopIds;
const selectedAopIds = isCanvasTypeMode ? (canvasSelectedAopIds ?? null) : globalSelectedAopIds;
// Canvas-scoped toggle: add/remove a single AOP ID from the selection
const toggleSelectedAopId = useCallback(
(aopId: string | null) => {
if (isCanvasTypeMode && canvasSetSelectedAopIds) {
const current = canvasSelectedAopIds ?? [];
const isCurrentlySelected = current.includes(aopId);
if (isCurrentlySelected) {
const next = current.filter((id) => id !== aopId);
canvasSetSelectedAopIds(next.length > 0 ? next : []);
} else {
canvasSetSelectedAopIds([...current, aopId]);
}
} else {
globalToggleSelectedAopId(aopId);
}
},
[isCanvasTypeMode, canvasSelectedAopIds, canvasSetSelectedAopIds, globalToggleSelectedAopId],
);
// Canvas mode: subscribe to viewConfig for sort/filter state and provide patch helper
const updateCanvasViewConfig = useCedarStore((state) => state.updateCanvasViewConfig);
const saveCanvasViewConfig = useCedarStore((state) => state.saveCanvasViewConfig);
const canvasViewConfig = useCedarStore((state) => {
if (!canvasId) return null;
const c = state.canvasesById[canvasId];
return c?.viewConfig as ConversationViewConfig | null | undefined;
});
const canvasFilterSortConfig = canvasId ? (canvasViewConfig?.filterSortConfiguration ?? {}) : null;
const patchFilterSort = useCallback(
(patcher: (config: CanvasFilterSortConfiguration) => CanvasFilterSortConfiguration) => {
if (!canvasId) return;
const state = useCedarStore.getState();
const canvas = state.canvasesById[canvasId];
if (!canvas) return;
const currentViewConfig = (canvas.viewConfig ?? {}) as ConversationViewConfig;
const newFilterSort = patcher(currentViewConfig.filterSortConfiguration ?? {});
updateCanvasViewConfig(canvasId, { ...currentViewConfig, filterSortConfiguration: newFilterSort });
void saveCanvasViewConfig(canvasId);
},
[canvasId, updateCanvasViewConfig, saveCanvasViewConfig],
);
// Derive selectedAopNames from selectedAopIds + AOPs data (for display only)
// Includes null if selectedAopIds contains null (for "no AOP" filter)
const selectedAopNames = useMemo((): (string | null)[] => {
if (!selectedAopIds || selectedAopIds.length === 0) return [];
return selectedAopIds
.map((id) => {
if (id === null) return null; // Keep null as-is
return aopsData?.aops?.find((a) => a.id === id)?.name ?? null;
})
.filter((name): name is string | null => name !== undefined);
}, [selectedAopIds, aopsData?.aops]);
const [activeId, setActiveId] = useState<string | null>(null);
const [dragOverlayContainer, setDragOverlayContainer] = useState<HTMLElement | null>(null);
const [localOrdering, setLocalOrdering] = useState<OptionValue[]>([]);
// Draft-local override (inbox editor) wins over both store-backed targets.
const override = useColumnFilterOverride(field);
/** Single write path for this column's filter, whichever target owns it. */
const applyColumnFilter = useCallback(
(filter: ColumnFilter | undefined) => {
if (override) {
override.applyFilter(filter);
} else if (canvasId) {
patchFilterSort((config) => ({ ...config, [field]: { ...config[field], filter } }));
} else {
setColumnFilter(field, filter);
}
},
[override, canvasId, field, patchFilterSort, setColumnFilter],
);
// Get column state — from the override, canvas viewConfig, or the CRMSlice
const column = override
? override.entry
: canvasFilterSortConfig !== null
? canvasFilterSortConfig[field]
: columns[field];
const columnSort = column?.sort;
const columnFilter = column?.filter;
const isActiveSortField = columnSort?.active ?? false;
const sortDirection = columnSort?.direction ?? 'asc';
const setColumnSort = useCedarStore((state) => state.setColumnSort);
// Turning sorting on here makes this column the #1 sort (see useActivateColumnSortAtTop).
const activateSortAtTop = useActivateColumnSortAtTop(field, canvasId);
// Get filter values:
// - For 'type' field: derive from selectedAopIds via selectedAopNames (multi-select)
// - For other fields: use column filter state
const selectedValues = useMemo(() => {
if (field === 'type') {
return selectedAopNames;
}
return columnFilter?.selected || [];
}, [field, selectedAopNames, columnFilter?.selected]);
// Excluded values (not used for type since it uses selectedAopIds directly)
const excludedValues = useMemo(() => {
if (field === 'type') return [];
return columnFilter?.excluded || [];
}, [field, columnFilter?.excluded]);
// Initialize local ordering from store or default to available values
useEffect(() => {
if (!enumOptions || enumOptions.length === 0) {
setLocalOrdering([]);
return;
}
// Create a map of enum options for quick lookup
const enumOptionsMap = new Map<string | null, CrmFieldEnumOption>();
enumOptions.forEach((opt) => {
enumOptionsMap.set(opt.value, opt);
});
// Get valid values from current AOP (this is the source of truth)
const validValues = new Set(enumOptions.map((opt) => opt.value));
// Check if we have a custom order saved (from user dragging to reorder)
const customOrder = columnSort?.order;
if (customOrder && customOrder.length > 0) {
// Filter custom order to only include values that exist in current AOP
const validCustomOrder = customOrder.filter((value) => validValues.has(value));
// Find any new values in enumOptions that aren't in the custom order
const newValues = enumOptions
.filter((opt) => !validCustomOrder.includes(opt.value))
.sort((a, b) => a.enumOrder - b.enumOrder)
.map((opt) => opt.value);
// Combine: existing custom order + new values appended at the end
const combinedOrder = [...validCustomOrder, ...newValues];
setLocalOrdering(
combinedOrder.map((value) => ({
value,
label: getEnumDisplayText(value, enumOptions),
})),
);
} else {
// No custom order - use enumOptions sorted by enumOrder (default order from AOP)
const sorted = sortEnumOptions(enumOptions);
setLocalOrdering(
sorted.map((opt) => ({
value: opt.value,
label: getEnumDisplayText(opt.value, enumOptions),
})),
);
}
}, [field, enumOptions, columnSort?.order]);
// Create a container for the drag overlay
useEffect(() => {
const container = document.createElement('div');
container.style.position = 'fixed';
container.style.top = '0';
container.style.left = '0';
container.style.zIndex = '10001';
container.style.pointerEvents = 'none';
document.body.appendChild(container);
setDragOverlayContainer(container);
return () => {
document.body.removeChild(container);
};
}, []);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (!over || active.id === over.id) return;
setLocalOrdering((items) => {
// Convert null values to '__null__' for comparison with active.id/over.id
const oldIndex = items.findIndex((item) => (item.value ?? '__null__') === active.id);
const newIndex = items.findIndex((item) => (item.value ?? '__null__') === over.id);
if (oldIndex === -1 || newIndex === -1) return items;
const newItems = [...items];
const [movedItem] = newItems.splice(oldIndex, 1);
newItems.splice(newIndex, 0, movedItem);
// Update the store with the new ordering
const newOrder = newItems.map((item) => item.value);
if (canvasId) {
patchFilterSort((config) => ({
...config,
[field]: { ...config[field], sort: { ...(config[field]?.sort ?? { active: false, direction: 'asc', priority: 0 }), order: newOrder } },
}));
} else {
setColumnSortCustomOrder(field, newOrder);
}
return newItems;
});
};
const handleToggleSortingActive = (enabled: boolean) => {
if (enabled) {
// If no custom order exists, initialize with default enum order from AOP
let order = columnSort?.order;
if (!order && enumOptions && enumOptions.length > 0) {
const sorted = sortEnumOptions(enumOptions);
order = sorted.map((opt) => opt.value);
}
// Enable sorting as the #1 sort, keeping existing direction or defaulting to 'asc'
activateSortAtTop({ active: true, direction: sortDirection, order });
} else {
// Disable sorting but preserve custom order for next time
const savedOrder = columnSort?.order;
const disabledSort = savedOrder
? { active: false, direction: 'asc' as const, priority: 0, order: savedOrder }
: undefined;
if (canvasId) {
patchFilterSort((config) => ({ ...config, [field]: { ...config[field], sort: disabledSort } }));
} else {
setColumnSort(field, disabledSort);
}
}
};
const handleToggleDirection = () => {
if (!columnSort) return;
const newDirection = sortDirection === 'asc' ? ('desc' as const) : ('asc' as const);
const updatedSort = { ...columnSort, direction: newDirection };
if (canvasId) {
patchFilterSort((config) => ({ ...config, [field]: { ...config[field], sort: updatedSort } }));
} else {
setColumnSort(field, updatedSort);
}
};
const handleToggleSelect = useCallback(
(value: string | null) => {
const isSelected = selectedValues.includes(value);
const isExcluded = excludedValues.includes(value);
// Type field: uses selectedAopIds as single source of truth (multi-select)
// Toggle this AOP in/out of the selection
if (field === 'type') {
if (value === null) {
// Toggle null (conversations with no AOP) in/out of selection
toggleSelectedAopId(null);
} else {
// Find the AOP ID by name and toggle it
const aop = aopsData?.aops?.find((a) => a.name === value);
if (aop) {
toggleSelectedAopId(aop.id);
}
}
return;
}
// Single-select binary fields: selecting the same value again clears the filter
if (field === 'crmSynced' || field === 'hasFutureCalendar') {
applyColumnFilter(isSelected ? undefined : { selected: [value] });
return;
}
// Multi-select behavior for status/priority/responsiveness
let newFilter: ColumnFilter | undefined;
if (isSelected) {
const newSelected = selectedValues.filter((v) => v !== value);
newFilter = newSelected.length === 0 && excludedValues.length === 0
? undefined
: { selected: newSelected.length > 0 ? newSelected : undefined, excluded: excludedValues.length > 0 ? excludedValues : undefined };
} else {
const newSelected = [...selectedValues, value];
const newExcluded = isExcluded ? excludedValues.filter((v) => v !== value) : excludedValues;
newFilter = { selected: newSelected, excluded: newExcluded.length > 0 ? newExcluded : undefined };
}
applyColumnFilter(newFilter);
},
[field, selectedValues, excludedValues, applyColumnFilter, aopsData?.aops, toggleSelectedAopId],
);
const handleToggleExclude = useCallback(
(value: string | null) => {
const isExcluded = excludedValues.includes(value);
const isSelected = selectedValues.includes(value);
let newFilter: ColumnFilter | undefined;
if (isExcluded) {
const newExcluded = excludedValues.filter((v) => v !== value);
newFilter = newExcluded.length === 0 && selectedValues.length === 0
? undefined
: { selected: selectedValues.length > 0 ? selectedValues : undefined, excluded: newExcluded.length > 0 ? newExcluded : undefined };
} else {
const newExcluded = [...excludedValues, value];
const newSelected = isSelected ? selectedValues.filter((v) => v !== value) : selectedValues;
newFilter = { selected: newSelected.length > 0 ? newSelected : undefined, excluded: newExcluded };
}
applyColumnFilter(newFilter);
},
[selectedValues, excludedValues, applyColumnFilter],
);
const handleClearAll = () => {
// Type field cannot be cleared (always need an AOP selected)
if (field === 'type') return;
// Other fields (CRM Synced, history) can be cleared (shows all conversations)
applyColumnFilter(undefined);
};
const activeValue = useMemo(() => {
if (!activeId) return null;
// Handle null values that are represented as '__null__' in drag IDs
return localOrdering.find((item) => (item.value ?? '__null__') === activeId);
}, [activeId, localOrdering]);
// Filter count - for type field, don't count as "filters" since it's required AOP selection
// For crmSynced, count it as a filter (unlike type which is required)
const filterCount = field === 'type' ? 0 : selectedValues.length + excludedValues.length;
// History date filter helpers — shared apply for both event date & latest event date
const applyHistoryFilter = applyColumnFilter;
// Derive InlineDateSection state from columnFilter (event date fields)
const eventDateState: InlineDateSectionState = {
operator: columnFilter?.dateOperator as HistoryDateOp | undefined,
date: columnFilter?.dateRelative && columnFilter?.dateFromDaysOffset !== undefined
? resolveOffsetToDate(columnFilter.dateFromDaysOffset)
: columnFilter?.dateFrom ? new Date(columnFilter.dateFrom) : undefined,
dateTo: columnFilter?.dateOperator === 'range'
? (columnFilter?.dateRelative && columnFilter?.dateToDaysOffset !== undefined
? resolveOffsetToDate(columnFilter.dateToDaysOffset)
: columnFilter?.dateTo ? new Date(columnFilter.dateTo) : undefined)
: undefined,
relative: columnFilter?.dateRelative ?? false,
daysOffset: columnFilter?.dateFromDaysOffset,
daysOffsetTo: columnFilter?.dateToDaysOffset,
};
// Derive InlineDateSection state from columnFilter (latest event date fields)
const latestEventState: InlineDateSectionState = {
operator: columnFilter?.latestEventOperator as HistoryDateOp | undefined,
date: columnFilter?.latestEventRelative && columnFilter?.latestEventFromDaysOffset !== undefined
? resolveOffsetToDate(columnFilter.latestEventFromDaysOffset)
: columnFilter?.latestEventFrom ? new Date(columnFilter.latestEventFrom) : undefined,
dateTo: columnFilter?.latestEventOperator === 'range'
? (columnFilter?.latestEventRelative && columnFilter?.latestEventToDaysOffset !== undefined
? resolveOffsetToDate(columnFilter.latestEventToDaysOffset)
: columnFilter?.latestEventTo ? new Date(columnFilter.latestEventTo) : undefined)
: undefined,
relative: columnFilter?.latestEventRelative ?? false,
daysOffset: columnFilter?.latestEventFromDaysOffset,
daysOffsetTo: columnFilter?.latestEventToDaysOffset,
};
const handleApplyEventDate = useCallback(
(next: InlineDateSectionState) => {
applyHistoryFilter({
...columnFilter,
dateOperator: next.operator,
dateFrom: !next.relative && next.date ? next.date.toISOString() : undefined,
dateTo: next.operator === 'range' && !next.relative && next.dateTo ? next.dateTo.toISOString() : undefined,
dateRelative: next.relative || undefined,
dateFromDaysOffset: next.relative ? next.daysOffset : undefined,
dateToDaysOffset: next.operator === 'range' && next.relative ? next.daysOffsetTo : undefined,
});
},
[columnFilter, applyHistoryFilter],
);
const handleClearEventDate = useCallback(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { dateFrom, dateTo, dateOperator, dateRelative, dateFromDaysOffset, dateToDaysOffset, ...rest } = columnFilter || {};
applyHistoryFilter(Object.keys(rest).length === 0 ? undefined : rest as ColumnFilter);
}, [columnFilter, applyHistoryFilter]);
const handleApplyLatestEvent = useCallback(
(next: InlineDateSectionState) => {
applyHistoryFilter({
...columnFilter,
latestEventOperator: next.operator,
latestEventFrom: !next.relative && next.date ? next.date.toISOString() : undefined,
latestEventTo: next.operator === 'range' && !next.relative && next.dateTo ? next.dateTo.toISOString() : undefined,
latestEventRelative: next.relative || undefined,
latestEventFromDaysOffset: next.relative ? next.daysOffset : undefined,
latestEventToDaysOffset: next.operator === 'range' && next.relative ? next.daysOffsetTo : undefined,
});
},
[columnFilter, applyHistoryFilter],
);
const handleToggleEventOccurrenceType = useCallback(
(eventType: string) => {
const current = columnFilter?.eventOccurrenceType ?? [];
const isSelected = current.includes(eventType);
const next = isSelected ? current.filter((t) => t !== eventType) : [...current, eventType];
applyHistoryFilter({ ...columnFilter, eventOccurrenceType: next.length > 0 ? next : undefined });
},
[columnFilter, applyHistoryFilter],
);
const handleClearEventOccurrenceType = useCallback(() => {
applyHistoryFilter({ ...columnFilter, eventOccurrenceType: undefined });
}, [columnFilter, applyHistoryFilter]);
// Clear only latest event date fields (type stays)
const handleClearLatestEventDate = useCallback(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { latestEventFrom, latestEventTo, latestEventOperator, latestEventRelative, latestEventFromDaysOffset, latestEventToDaysOffset, ...rest } = columnFilter || {};
applyHistoryFilter(Object.keys(rest).length === 0 ? undefined : rest as ColumnFilter);
}, [columnFilter, applyHistoryFilter]);
// Clear all latest event filters (type + date)
const handleClearLatestEvent = useCallback(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { latestEventFrom, latestEventTo, latestEventOperator, latestEventRelative, latestEventFromDaysOffset, latestEventToDaysOffset, latestEventType, ...rest } = columnFilter || {};
applyHistoryFilter(Object.keys(rest).length === 0 ? undefined : rest as ColumnFilter);
}, [columnFilter, applyHistoryFilter]);
const handleToggleLatestEventType = useCallback(
(eventType: string) => {
const current = columnFilter?.latestEventType ?? [];
const isSelected = current.includes(eventType);
const next = isSelected ? current.filter((t) => t !== eventType) : [...current, eventType];
applyHistoryFilter({ ...columnFilter, latestEventType: next.length > 0 ? next : undefined });
},
[columnFilter, applyHistoryFilter],
);
const hasLatestEventFilter =
!!(columnFilter?.latestEventType?.length) ||
!!(columnFilter?.latestEventFrom || columnFilter?.latestEventFromDaysOffset !== undefined);
// "Last [event type]" — MAX(occurred_at WHERE type IN [...]) semantics
const lastEventByTypeState: InlineDateSectionState = {
operator: columnFilter?.lastEventByTypeDateOperator as HistoryDateOp | undefined,
date: columnFilter?.lastEventByTypeRelative && columnFilter?.lastEventByTypeDaysOffset !== undefined
? resolveOffsetToDate(columnFilter.lastEventByTypeDaysOffset)
: columnFilter?.lastEventByTypeFrom ? new Date(columnFilter.lastEventByTypeFrom) : undefined,
dateTo: columnFilter?.lastEventByTypeDateOperator === 'range'
? (columnFilter?.lastEventByTypeRelative && columnFilter?.lastEventByTypeDaysOffsetTo !== undefined
? resolveOffsetToDate(columnFilter.lastEventByTypeDaysOffsetTo)
: columnFilter?.lastEventByTypeTo ? new Date(columnFilter.lastEventByTypeTo) : undefined)
: undefined,
relative: columnFilter?.lastEventByTypeRelative ?? false,
daysOffset: columnFilter?.lastEventByTypeDaysOffset,
daysOffsetTo: columnFilter?.lastEventByTypeDaysOffsetTo,
};
const handleApplyLastEventByType = useCallback(
(next: InlineDateSectionState) => {
applyHistoryFilter({
...columnFilter,
lastEventByTypeDateOperator: next.operator,
lastEventByTypeFrom: !next.relative && next.date ? next.date.toISOString() : undefined,
lastEventByTypeTo: next.operator === 'range' && !next.relative && next.dateTo ? next.dateTo.toISOString() : undefined,
lastEventByTypeRelative: next.relative || undefined,
lastEventByTypeDaysOffset: next.relative ? next.daysOffset : undefined,
lastEventByTypeDaysOffsetTo: next.operator === 'range' && next.relative ? next.daysOffsetTo : undefined,
});
},
[columnFilter, applyHistoryFilter],
);
const handleClearLastEventByTypeDate = useCallback(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { lastEventByTypeFrom, lastEventByTypeTo, lastEventByTypeDateOperator, lastEventByTypeRelative, lastEventByTypeDaysOffset, lastEventByTypeDaysOffsetTo, ...rest } = columnFilter || {};
applyHistoryFilter(Object.keys(rest).length === 0 ? undefined : rest as ColumnFilter);
}, [columnFilter, applyHistoryFilter]);
const handleClearLastEventByType = useCallback(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { lastEventByTypeFrom, lastEventByTypeTo, lastEventByTypeDateOperator, lastEventByTypeRelative, lastEventByTypeDaysOffset, lastEventByTypeDaysOffsetTo, lastEventByTypeTypes, ...rest } = columnFilter || {};
applyHistoryFilter(Object.keys(rest).length === 0 ? undefined : rest as ColumnFilter);
}, [columnFilter, applyHistoryFilter]);
const handleToggleLastEventByTypeType = useCallback(
(eventType: string) => {
const current = columnFilter?.lastEventByTypeTypes ?? [];
const isSelected = current.includes(eventType);
const next = isSelected ? current.filter((t) => t !== eventType) : [...current, eventType];
applyHistoryFilter({ ...columnFilter, lastEventByTypeTypes: next.length > 0 ? next : undefined });
},
[columnFilter, applyHistoryFilter],
);
const hasLastEventByTypeFilter = !!(
columnFilter?.lastEventByTypeDateOperator ||
columnFilter?.lastEventByTypeTypes?.length ||
columnFilter?.lastEventByTypeFrom
);
return (
<div className="space-y-2">
{/* Top row: Sort enabled toggle, asc/desc, and Clear filters */}
<div className="flex items-center justify-between gap-2">
{/* Left: Sort enabled toggle and asc/desc — hidden for history (sort by event type is removed) */}
{field !== 'history' && mode !== 'filter' && (
<div className="flex items-center gap-1.5">
{mode !== 'sort' && (
<>
<Label htmlFor={`sort-toggle-${field}`} className="text-sm font-medium">
Sort enabled
</Label>
<Switch
id={`sort-toggle-${field}`}
checked={isActiveSortField}
onCheckedChange={handleToggleSortingActive}
/>
</>
)}
{/* Asc/Desc toggle button */}
<button
onClick={handleToggleDirection}
disabled={!isActiveSortField}
className={cn(
'flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium transition-all',
isActiveSortField
? 'bg-muted hover:bg-muted/80 text-foreground'
: 'bg-muted/50 text-muted-foreground cursor-not-allowed opacity-50',
)}
>
<motion.div
animate={{ rotate: sortDirection === 'asc' ? 0 : 180 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
<ArrowUp className="h-3 w-3" />
</motion.div>
<span>{sortDirection === 'asc' ? 'Asc' : 'Desc'}</span>
</button>
</div>
)}
{/* Right: Clear filters button */}
{mode !== 'sort' && filterCount > 0 && field !== 'type' && (
<Button
variant="outline"
size="sm"
onClick={handleClearAll}
className="h-6 shrink-0 gap-1 rounded-md border-red-200 bg-red-50 px-2 text-xs text-red-700 hover:bg-red-100 hover:text-red-800 dark:border-red-800 dark:bg-red-950/50 dark:text-red-400 dark:hover:bg-red-900"
>
<X className="h-3 w-3" />
Clear Filters
</Button>
)}
</div>
{/* Sortable list - no border, no background (hidden for history field) */}
{field !== 'history' && <DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext
items={localOrdering.map((v) => v.value ?? '__null__')}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1.5 overflow-show">
{localOrdering.map((option) => {
// For 'type' field, get AOP color
const matchedAop = field === 'type'
? aopsData?.aops?.find((a) => a.name === option.value)
: undefined;
const aopColor = matchedAop?.color;
return (
<SortableFilterItem
key=[redacted] ?? '__null__'}
id={option.value ?? '__null__'}
option={option}
field={field}
isSelected={selectedValues.includes(option.value)}
isExcluded={excludedValues.includes(option.value)}
onToggleSelect={() => handleToggleSelect(option.value)}
onToggleExclude={() => handleToggleExclude(option.value)}
enumOptions={enumOptions}
aopColor={aopColor}
showFilterControls={mode !== 'sort'}
showDragHandle={mode !== 'filter'}
/>
);
})}
</div>
</SortableContext>
{dragOverlayContainer &&
createPortal(
<DragOverlay
dropAnimation={{
duration: 200,
easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)',
}}
>
{activeValue && (() => {
// For 'type' field, use AOP color if available
const activeMatchedAop = field === 'type'
? aopsData?.aops?.find((a) => a.name === activeValue.value)
: undefined;
const activeAopColor = activeMatchedAop?.color;
const overlayColorStyles = field === 'type' ? getAopColorStyles(activeAopColor) : null;
const overlayColorClass = overlayColorStyles
? ''
: field === 'type'
? getTypeColor(activeValue.value)
: getEnumColor(activeValue.value, field, enumOptions);
return (
<div
className={cn(
'ring-primary/20 flex cursor-grabbing items-center gap-2 rounded-sm px-3 py-1.5 text-xs font-semibold shadow-lg ring-2',
overlayColorClass,
)}
style={{ cursor: 'grabbing', ...overlayColorStyles }}
>
<GripVertical className="h-3.5 w-3.5 shrink-0 opacity-40" />
<span>{getEnumDisplayText(activeValue.value, enumOptions)}</span>
</div>
);
})()}
</DragOverlay>,
dragOverlayContainer,
)}
</DndContext>}
{/* Date Filter Sections - only for history field */}
{field === 'history' && (
<div className="mt-1 border-t border-border/50">
{/* Last Meeting sort control */}
{canvasId && mode !== 'filter' && (
<div className="pt-2 pb-1 flex items-center gap-1.5">
<Label htmlFor="sort-toggle-lastMeetingTime" className="text-sm font-medium">
Sort by Last Meeting
</Label>
<Switch
id="sort-toggle-lastMeetingTime"
checked={!!(canvasFilterSortConfig?.['lastMeetingTime']?.sort?.active)}
onCheckedChange={(checked) => {
patchFilterSort((config) => ({
...config,
lastMeetingTime: {
...config['lastMeetingTime'],
sort: {
active: checked,
direction: config['lastMeetingTime']?.sort?.direction ?? 'desc',
priority: checked
? Math.max(0, ...Object.values(config).map((c) => c.sort?.priority ?? 0)) + 1
: (config['lastMeetingTime']?.sort?.priority ?? 0),
},
},
}));
}}
/>
<button
onClick={() => {
const current = canvasFilterSortConfig?.['lastMeetingTime']?.sort?.direction ?? 'desc';
patchFilterSort((config) => ({
...config,
lastMeetingTime: {
...config['lastMeetingTime'],
sort: {
...config['lastMeetingTime']?.sort,
active: config['lastMeetingTime']?.sort?.active ?? false,
direction: current === 'asc' ? 'desc' : 'asc',
priority: config['lastMeetingTime']?.sort?.priority ?? 0,
},
},
}));
}}
disabled={!(canvasFilterSortConfig?.['lastMeetingTime']?.sort?.active)}
className={cn(
'flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium transition-all',
canvasFilterSortConfig?.['lastMeetingTime']?.sort?.active
? 'bg-muted hover:bg-muted/80 text-foreground'
: 'bg-muted/50 text-muted-foreground cursor-not-allowed opacity-50',
)}
>
<motion.div
animate={{
rotate: (canvasFilterSortConfig?.['lastMeetingTime']?.sort?.direction ?? 'desc') === 'asc' ? 0 : 180,
}}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
<ArrowUp className="h-3 w-3" />
</motion.div>
<span>
{(canvasFilterSortConfig?.['lastMeetingTime']?.sort?.direction ?? 'desc') === 'asc'
? 'Oldest first'
: 'Newest first'}
</span>
</button>
</div>
)}
{/* Event occurrence type badges — combine with event date */}
{mode !== 'sort' && enumOptions && enumOptions.length > 0 && (
<div className="pt-2 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs font-medium">Event Occurrence</span>
{columnFilter?.eventOccurrenceType?.length ? (
<button
type="button"
onClick={handleClearEventOccurrenceType}
className="flex h-5 w-5 items-center justify-center rounded-sm text-red-500 transition-colors hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-950/50"
>
<X className="h-3 w-3" />
</button>
) : null}
</div>
<div className="flex flex-wrap gap-1.5">
{enumOptions.map((opt) => {
const isSelected = (columnFilter?.eventOccurrenceType ?? []).includes(opt.value ?? '');
return (
<button
key=[redacted] ?? 'null'}
type="button"
onClick={() => handleToggleEventOccurrenceType(opt.value ?? '')}
className={cn(
'rounded-sm px-2 py-1 text-xs font-semibold transition-all hover:scale-105',
isSelected
? getEnumColor(opt.value, 'history', enumOptions)
: 'bg-muted text-muted-foreground hover:bg-muted/80',
)}
>
{opt.label}
</button>
);
})}
</div>
</div>
)}
<InlineDateSection
label="Event Date"
state={eventDateState}
onApply={handleApplyEventDate}
onClear={handleClearEventDate}
/>
{/* Latest Event — type picker + date filter */}
<div className="mt-1 border-t border-border/30 pt-2 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium">Latest Event</span>
{hasLatestEventFilter && (
<button
type="button"
onClick={handleClearLatestEvent}
className="flex h-5 w-5 items-center justify-center rounded-sm text-red-500 transition-colors hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-950/50"
>
<X className="h-3 w-3" />
</button>
)}
</div>
{/* Event type badges */}
{enumOptions && enumOptions.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{enumOptions.map((opt) => {
const isSelected = (columnFilter?.latestEventType ?? []).includes(opt.value ?? '');
return (
<button
key=[redacted] ?? 'null'}
type="button"
onClick={() => handleToggleLatestEventType(opt.value ?? '')}
className={cn(
'rounded-sm px-2 py-1 text-xs font-semibold transition-all hover:scale-105',
isSelected
? getEnumColor(opt.value, 'history', enumOptions)
: 'bg-muted text-muted-foreground hover:bg-muted/80',
)}
>
{opt.label}
</button>
);
})}
</div>
)}
{/* Date filter — no header (parent already has "Latest Event" label) */}
<InlineDateSection
label=""
state={latestEventState}
onApply={handleApplyLatestEvent}
onClear={handleClearLatestEventDate}
/>
</div>
{/* Last [event type] — MAX(occurred_at WHERE type IN [...]) semantics */}
<div className="mt-1 border-t border-border/30 pt-2 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium">Last [event type]</span>
{hasLastEventByTypeFilter && (
<button
type="button"
onClick={handleClearLastEventByType}
className="flex h-5 w-5 items-center justify-center rounded-sm text-red-500 transition-colors hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-950/50"
>
<X className="h-3 w-3" />
</button>
)}
</div>
<p className="text-[10px] text-muted-foreground leading-tight">
When was the last [type] — ignores later events of other types
</p>
{/* Event type badges — same options as Latest Event */}
{enumOptions && enumOptions.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{enumOptions.map((opt) => {
const isSelected = (columnFilter?.lastEventByTypeTypes ?? []).includes(opt.value ?? '');
return (
<button
key=[redacted] ?? 'null'}
type="button"
onClick={() => handleToggleLastEventByTypeType(opt.value ?? '')}
className={cn(
'rounded-sm px-2 py-1 text-xs font-semibold transition-all hover:scale-105',
isSelected
? getEnumColor(opt.value, 'history', enumOptions)
: 'bg-muted text-muted-foreground hover:bg-muted/80',
)}
>
{opt.label}
</button>
);
})}
</div>
)}
<InlineDateSection
label=""
state={lastEventByTypeState}
onApply={handleApplyLastEventByType}
onClear={handleClearLastEventByTypeDate}
/>
</div>
</div>
)}
{/* Instructions at the bottom */}
{field !== 'history' && (
<p className="text-muted-foreground pt-1 text-xs leading-relaxed">
{mode === 'sort' ? (
'Drag to reorder'
) : field === 'type' ? (
'Click to select'
) : field === 'crmSynced' ? (
'Click to filter'
) : (
<>
Click to filter • <Ban className="inline h-2.5 w-2.5 align-text-bottom" /> exclude
</>
)}
{mode === 'full' && ' • Drag to reorder'}
</p>
)}
{crossLink}
</div>
);
}
interface UnifiedColumnPopoverProps {
field: SortableEnumField;
enumOptions?: CrmFieldEnumOption[];
children: React.ReactElement;
open?: boolean;
onOpenChange?: (open: boolean) => void;
canvasId?: string;
canvasSelectedAopIds?: (string | null)[] | null;
canvasSetSelectedAopIds?: (aopIds: (string | null)[] | null) => void;
}
export function UnifiedColumnPopover({
field,
enumOptions,
children,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
canvasId,
canvasSelectedAopIds,
canvasSetSelectedAopIds,
}: UnifiedColumnPopoverProps) {
const [internalOpen, setInternalOpen] = useState(false);
const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen;
const setIsOpen = controlledOnOpenChange || setInternalOpen;
const availableValues = enumOptions?.map((opt) => opt.value) || [];
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent className="w-96 p-3" align="start">
{availableValues.length === 0 ? (
<div className="text-center py-3 text-muted-foreground text-xs">
No options available for this column
</div>
) : (
<UnifiedColumnPopoverContent
field={field}
enumOptions={enumOptions}
canvasId={canvasId}
canvasSelectedAopIds={canvasSelectedAopIds}
canvasSetSelectedAopIds={canvasSetSelectedAopIds}
/>
)}
</PopoverContent>
</Popover>
);
}