number-column-popover.tsx15.0 KBView on GitHub /**
* Number Column Popover Component
*
* Provides sorting and number filtering functionality for number columns in the CRM table.
* Layout: [sort toggle] <-> [asc/desc button]
* [operator select] [number input]
*
* Operators:
* - gt: Show values greater than the input
* - lt: Show values less than the input
* - eq: Show values equal to the input
* - empty: Show items with no value set
*/
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import type { ColumnPopoverMode, ColumnPopoverPresentation } from './column-popover-presentation';
import type { ColumnFilter, ColumnSort, NumberFilterOperator } from '@/modules/crm/store/crmSlice';
import type { ConversationViewConfig } from '@/modules/canvas/types/canvas-types';
import { useColumnFilterOverride } from './column-filter-override';
import { useActivateColumnSortAtTop } from '../hooks/use-activate-column-sort';
import { ArrowUp, ChevronDown, DollarSign, X } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { useCedarStore } from '@/modules/store';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { motion } from 'motion/react';
import { cn } from '@/lib/utils';
type NumberColumnField = 'dealValue';
interface NumberColumnPopoverContentProps {
mode?: ColumnPopoverMode;
crossLink?: React.ReactNode;
field: NumberColumnField;
title: string;
prefix?: string; // e.g., "$" for currency
canvasId?: string;
headerAction?: React.ReactNode;
}
const OPERATOR_LABELS: Record<NumberFilterOperator, string> = {
gt: 'Greater than',
lt: 'Less than',
eq: 'Equal to',
empty: 'No value set',
};
const OPERATOR_SYMBOLS: Record<NumberFilterOperator, string> = {
gt: '>',
lt: '<',
eq: '=',
empty: '∅',
};
function NumberColumnPopoverContent({
field,
title,
prefix,
canvasId,
headerAction,
mode = 'full',
crossLink,
}: NumberColumnPopoverContentProps) {
const globalColumns = useCedarStore((state) => state.columns);
const setColumnFilter = useCedarStore((state) => state.setColumnFilter);
const setColumnSort = useCedarStore((state) => state.setColumnSort);
const updateCanvasViewConfig = useCedarStore((state) => state.updateCanvasViewConfig);
const saveCanvasViewConfig = useCedarStore((state) => state.saveCanvasViewConfig);
const [operatorOpen, setOperatorOpen] = useState(false);
// Canvas mode: subscribe to canvas viewConfig for sort/filter state
const canvasColumnConfig = useCedarStore((state) => {
if (!canvasId) return null;
const c = state.canvasesById[canvasId];
const viewConfig = c?.viewConfig as ConversationViewConfig | null | undefined;
return viewConfig?.filterSortConfiguration?.[field] ?? null;
});
// Draft-local override (inbox editor) wins over both store-backed targets.
const override = useColumnFilterOverride(field);
// Get column state — from the override, canvas, or global store
const columnSort: ColumnSort | undefined = override
? override.entry?.sort
: canvasId
? canvasColumnConfig?.sort
: globalColumns[field]?.sort;
const columnFilter: ColumnFilter | undefined = override
? override.entry?.filter
: canvasId
? (canvasColumnConfig?.filter as ColumnFilter | undefined)
: globalColumns[field]?.filter;
const isActiveSortField = columnSort?.active ?? false;
const sortDirection = columnSort?.direction ?? 'asc';
// Get filter values
const storedOperator = columnFilter?.numberOperator;
const storedValue = columnFilter?.numberValue ?? '';
const [localOperator, setLocalOperator] = useState<NumberFilterOperator>(storedOperator ?? 'gt');
const [localValue, setLocalValue] = useState<string>(storedValue);
useEffect(() => {
if (storedOperator) {
setLocalOperator(storedOperator);
}
setLocalValue(storedValue);
}, [storedOperator, storedValue]);
const currentOperator = storedOperator ?? localOperator;
const currentValue = storedValue || localValue;
// Canvas-aware filter write
const applyFilter = useCallback(
(filter: ColumnFilter | undefined) => {
if (override) {
override.applyFilter(filter);
} else if (canvasId) {
const state = useCedarStore.getState();
const canvas = state.canvasesById[canvasId];
if (!canvas) return;
const currentViewConfig = (canvas.viewConfig ?? {}) as ConversationViewConfig;
const currentFilterSort = currentViewConfig.filterSortConfiguration ?? {};
updateCanvasViewConfig(canvasId, {
...currentViewConfig,
filterSortConfiguration: {
...currentFilterSort,
[field]: { ...currentFilterSort[field], filter },
},
});
void saveCanvasViewConfig(canvasId);
} else {
setColumnFilter(field, filter);
}
},
[override, canvasId, field, setColumnFilter, updateCanvasViewConfig, saveCanvasViewConfig],
);
// Canvas-aware sort write
const applySort = useCallback(
(sort: ColumnSort | undefined) => {
if (canvasId) {
const state = useCedarStore.getState();
const canvas = state.canvasesById[canvasId];
if (!canvas) return;
const currentViewConfig = (canvas.viewConfig ?? {}) as ConversationViewConfig;
const currentFilterSort = currentViewConfig.filterSortConfiguration ?? {};
updateCanvasViewConfig(canvasId, {
...currentViewConfig,
filterSortConfiguration: {
...currentFilterSort,
[field]: { ...currentFilterSort[field], sort },
},
});
void saveCanvasViewConfig(canvasId);
} else {
setColumnSort(field, sort);
}
},
[canvasId, field, setColumnSort, updateCanvasViewConfig, saveCanvasViewConfig],
);
// Turning sorting on here makes this column the #1 sort (see useActivateColumnSortAtTop).
const activateSortAtTop = useActivateColumnSortAtTop(field, canvasId);
const handleToggleSortingActive = useCallback(
(enabled: boolean) => {
if (enabled) {
activateSortAtTop({ active: true, direction: sortDirection });
} else {
applySort(undefined);
}
},
[activateSortAtTop, sortDirection, applySort],
);
const handleToggleDirection = useCallback(() => {
if (!columnSort) return;
applySort({ ...columnSort, direction: sortDirection === 'asc' ? 'desc' : 'asc' });
}, [columnSort, sortDirection, applySort]);
const handleOperatorChange = useCallback(
(operator: NumberFilterOperator) => {
setLocalOperator(operator);
if (operator === 'empty') {
applyFilter({ numberOperator: 'empty' });
} else if (currentValue && currentValue.trim() !== '') {
applyFilter({
numberValue: currentValue,
numberOperator: operator,
});
}
setOperatorOpen(false);
},
[currentValue, applyFilter],
);
const handleValueChange = useCallback(
(value: string) => {
const sanitized = value.replace(/[^0-9.]/g, '');
setLocalValue(sanitized);
if (currentOperator === 'empty') {
setLocalOperator('gt');
}
},
[currentOperator],
);
const handleValueBlur = useCallback(() => {
if (localValue && localValue.trim() !== '') {
const operator = currentOperator === 'empty' ? 'gt' : currentOperator;
setLocalOperator(operator);
applyFilter({
numberValue: localValue,
numberOperator: operator,
});
} else {
applyFilter(undefined);
}
}, [localValue, currentOperator, applyFilter]);
const handleClearFilter = useCallback(() => {
applyFilter(undefined);
setLocalValue('');
setLocalOperator('gt');
}, [applyFilter]);
const hasActiveFilter = currentOperator === 'empty' || (!!currentValue && currentValue.trim() !== '');
const formatDisplayValue = (val: string) => {
if (!val) return '';
const num = parseFloat(val);
if (isNaN(num)) return val;
return num.toLocaleString();
};
return (
<div className="space-y-3">
{/* Header */}
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold">{title}</h4>
<div className="flex items-center gap-1">
{mode !== 'sort' && hasActiveFilter && (
<Button
variant="ghost"
size="sm"
onClick={handleClearFilter}
className="h-6 rounded-sm border border-transparent px-2 text-xs hover:border-red-200 hover:bg-red-50 hover:text-red-600 dark:hover:border-red-800 dark:hover:bg-red-950"
>
<X className="mr-1 h-3 w-3" />
Clear
</Button>
)}
{headerAction}
</div>
</div>
{mode !== 'filter' && (
<>
{/* Sorting controls */}
<div className="flex items-center justify-between gap-3">
{mode !== 'sort' && (
<div className="flex items-center gap-2">
<Switch
id={`sort-toggle-${field}`}
checked={isActiveSortField}
onCheckedChange={handleToggleSortingActive}
/>
<Label htmlFor={`sort-toggle-${field}`} className="text-sm">
Sort
</Label>
</div>
)}
{/* Asc/Desc toggle button */}
<button
onClick={handleToggleDirection}
disabled={!isActiveSortField}
className={cn(
'flex items-center gap-1.5 rounded-md px-2.5 py-1.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',
)}
>
<span>{sortDirection === 'asc' ? 'Smallest first' : 'Largest first'}</span>
<motion.div
animate={{ rotate: sortDirection === 'asc' ? 0 : 180 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
<ArrowUp className="h-3.5 w-3.5" />
</motion.div>
</button>
</div>
</>
)}
{mode !== 'sort' && (
<>
{/* Number Filter */}
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">Filter by value</Label>
<div className="flex items-center gap-2">
{/* Operator selector */}
<Popover open={operatorOpen} onOpenChange={setOperatorOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 min-w-[120px] justify-between text-xs"
>
{OPERATOR_LABELS[currentOperator]}
<ChevronDown className="h-3 w-3 ml-1 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[140px] p-1" align="start">
{(Object.keys(OPERATOR_LABELS) as NumberFilterOperator[]).map((op) => (
<button
key=[redacted]
onClick={() => handleOperatorChange(op)}
className={cn(
'w-full rounded-sm px-2 py-1.5 text-left text-xs transition-colors flex items-center gap-2',
currentOperator === op
? 'bg-primary/10 text-primary font-medium'
: 'hover:bg-muted',
)}
>
<span className="w-4 text-center font-mono">{OPERATOR_SYMBOLS[op]}</span>
<span>{OPERATOR_LABELS[op]}</span>
</button>
))}
</PopoverContent>
</Popover>
{/* Number input - disabled when "No value set" is selected */}
<div className="relative flex-1">
{prefix && (
<div className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground">
<DollarSign className="h-3.5 w-3.5" />
</div>
)}
<Input
type="text"
inputMode="decimal"
value={localValue}
onChange={(e) => handleValueChange(e.target.value)}
onBlur={handleValueBlur}
onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur(); }}
disabled={currentOperator === 'empty'}
placeholder="Enter value..."
className={cn(
'h-8 text-xs',
prefix && 'pl-7',
currentOperator === 'empty' && 'opacity-50 cursor-not-allowed',
)}
/>
</div>
</div>
{/* Active filter summary */}
{hasActiveFilter && (
<div className="rounded-md bg-primary/5 px-3 py-2 text-xs text-primary">
{currentOperator === 'empty' ? (
'Showing items with no value set'
) : currentValue ? (
<>
Showing items{' '}
{currentOperator === 'gt'
? 'greater than'
: currentOperator === 'lt'
? 'less than'
: 'equal to'}{' '}
{prefix}
{formatDisplayValue(currentValue)}
</>
) : (
'Enter a value to apply filter'
)}
</div>
)}
</div>
</>
)}
{crossLink}
</div>
);
}
interface NumberColumnPopoverProps extends ColumnPopoverPresentation {
field: NumberColumnField;
title: string;
prefix?: string;
children: React.ReactElement;
canvasId?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
headerAction?: React.ReactNode;
}
export function NumberColumnPopover({
field,
title,
prefix,
children,
canvasId,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
headerAction,
side,
align = 'start',
mode,
crossLink,
}: NumberColumnPopoverProps) {
const [internalOpen, setInternalOpen] = useState(false);
const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen;
const setIsOpen = controlledOnOpenChange || setInternalOpen;
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent className="w-80 p-3" side={side} align={align}>
<NumberColumnPopoverContent
field={field}
title={title}
prefix={prefix}
canvasId={canvasId}
headerAction={headerAction}
mode={mode}
crossLink={crossLink}
/>
</PopoverContent>
</Popover>
);
}
// Export the field type for use in other components
export type { NumberColumnField };