date-column-popover.tsx21.2 KBView on GitHub /**
* Date Column Popover Component
*
* Provides sorting and date filtering functionality for date columns in the CRM table.
* Layout: [sort toggle] <-> [asc/desc button]
* [operator select] [date picker trigger]
*
* Operators:
* - before: Show dates before the selected date
* - after: Show dates after the selected date
* - on: Show dates on the exact selected date
*/
import { DatePickerWithNaturalInput } from '@/components/ui/date-picker-with-natural-input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import type { ColumnPopoverMode, ColumnPopoverPresentation } from './column-popover-presentation';
import { useColumnFilterOverride } from './column-filter-override';
import type { ColumnFilter, ColumnSort, DateFilterOperator } from '@/modules/crm/store/crmSlice';
import type { ConversationViewConfig } from '@/modules/canvas/types/canvas-types';
import { useActivateColumnSortAtTop } from '../hooks/use-activate-column-sort';
import { ArrowUp, Calendar, ChevronDown, X } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { computeDaysOffset, resolveOffsetToDate, formatDaysOffset } from '../utils/relative-dates';
import { Checkbox } from '@/components/ui/checkbox';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { useCedarStore } from '@/modules/store';
import { Label } from '@/components/ui/label';
import { motion } from 'motion/react';
import { format } from 'date-fns';
import { cn } from '@/lib/utils';
type DateColumnField = 'lastContactedAt' | 'nextStepDate' | 'lastMeetingTime' | 'lastOutboundEmailTime';
interface DateColumnPopoverContentProps {
field: DateColumnField;
canvasId?: string;
headerAction?: React.ReactNode;
mode?: ColumnPopoverMode;
crossLink?: React.ReactNode;
}
const OPERATOR_LABELS: Record<DateFilterOperator, string> = {
before: 'Before',
after: 'After',
on: 'On',
range: 'Range',
empty: 'No date set',
};
function DateColumnPopoverContent({
field,
canvasId,
headerAction,
mode = 'full',
crossLink,
}: DateColumnPopoverContentProps) {
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?.dateOperator;
const isRelative = columnFilter?.dateRelative ?? false;
// Resolve dates — when relative, compute from offset; otherwise parse stored ISO string
const currentDate = isRelative
? (columnFilter?.dateFromDaysOffset !== undefined ? resolveOffsetToDate(columnFilter.dateFromDaysOffset) : null)
: (columnFilter?.dateFrom ? new Date(columnFilter.dateFrom) : null);
const currentDateTo = isRelative
? (columnFilter?.dateToDaysOffset !== undefined ? resolveOffsetToDate(columnFilter.dateToDaysOffset) : null)
: (columnFilter?.dateTo ? new Date(columnFilter.dateTo) : null);
// Local state for operator when no date is selected yet
const [localOperator, setLocalOperator] = useState<DateFilterOperator>(
storedOperator ?? 'before',
);
// Sync local state when stored operator changes (e.g., filter cleared externally)
useEffect(() => {
if (storedOperator) {
setLocalOperator(storedOperator);
}
}, [storedOperator]);
const currentOperator = storedOperator ?? localOperator;
// 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: DateFilterOperator) => {
setLocalOperator(operator);
if (operator === 'empty') {
applyFilter({ dateOperator: 'empty' });
} else if (isRelative) {
// Preserve relative offsets when changing operator
applyFilter({
dateRelative: true,
dateFromDaysOffset: columnFilter?.dateFromDaysOffset,
dateToDaysOffset: operator === 'range' ? columnFilter?.dateToDaysOffset : undefined,
dateOperator: operator,
});
} else if (operator !== 'range') {
applyFilter({
dateFrom: currentDate?.toISOString(),
dateTo: undefined,
dateOperator: operator,
});
} else {
applyFilter({
dateFrom: currentDate?.toISOString(),
dateTo: currentDateTo?.toISOString(),
dateOperator: operator,
});
}
setOperatorOpen(false);
},
[currentDate, currentDateTo, isRelative, columnFilter?.dateFromDaysOffset, columnFilter?.dateToDaysOffset, applyFilter],
);
const handleDateChange = useCallback(
(date: Date | null) => {
if (date) {
const operator = currentOperator === 'empty' ? 'before' : currentOperator;
setLocalOperator(operator);
if (isRelative) {
applyFilter({
dateRelative: true,
dateFromDaysOffset: computeDaysOffset(date),
dateToDaysOffset: currentOperator === 'range' ? columnFilter?.dateToDaysOffset : undefined,
dateOperator: operator,
});
} else {
applyFilter({
dateFrom: date.toISOString(),
dateTo: currentOperator === 'range' ? columnFilter?.dateTo : undefined,
dateOperator: operator,
});
}
} else {
applyFilter(undefined);
}
},
[currentOperator, isRelative, columnFilter?.dateTo, columnFilter?.dateToDaysOffset, applyFilter],
);
const handleDateToChange = useCallback(
(date: Date | null) => {
if (isRelative) {
applyFilter({
dateRelative: true,
dateFromDaysOffset: columnFilter?.dateFromDaysOffset,
dateToDaysOffset: date ? computeDaysOffset(date) : undefined,
dateOperator: 'range',
});
} else {
applyFilter({
dateFrom: columnFilter?.dateFrom,
dateTo: date ? date.toISOString() : undefined,
dateOperator: 'range',
});
}
},
[isRelative, columnFilter?.dateFrom, columnFilter?.dateFromDaysOffset, applyFilter],
);
const handleToggleRelative = useCallback(
(checked: boolean) => {
if (!currentDate && !columnFilter?.dateFromDaysOffset) return;
const operator = currentOperator === 'empty' ? 'before' : currentOperator;
if (checked) {
// Convert absolute → relative: compute offsets from resolved dates
const fromOffset = currentDate ? computeDaysOffset(currentDate) : undefined;
const toOffset = currentDateTo ? computeDaysOffset(currentDateTo) : undefined;
applyFilter({
dateRelative: true,
dateFromDaysOffset: fromOffset,
dateToDaysOffset: currentOperator === 'range' ? toOffset : undefined,
dateOperator: operator,
});
} else {
// Convert relative → absolute: resolve offsets to ISO dates
applyFilter({
dateFrom: currentDate?.toISOString(),
dateTo: currentOperator === 'range' ? currentDateTo?.toISOString() : undefined,
dateOperator: operator,
});
}
},
[currentDate, currentDateTo, currentOperator, columnFilter?.dateFromDaysOffset, applyFilter],
);
const handleClearFilter = useCallback(() => {
applyFilter(undefined);
}, [applyFilter]);
const hasActiveFilter = currentOperator === 'empty' || !!currentDate || (currentOperator === 'range' && !!currentDateTo);
return (
<div className="space-y-3">
{/* Header */}
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold">
{field === 'lastContactedAt'
? 'Last Contact'
: field === 'lastMeetingTime'
? 'Last Meeting'
: field === 'lastOutboundEmailTime'
? 'Last Email Sent'
: 'Next Step Date'}
</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' ? 'Oldest first' : 'Newest 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' && (
<>
{/* Date Filter */}
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">Filter by date</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-[90px] justify-between text-xs"
>
{OPERATOR_LABELS[currentOperator]}
<ChevronDown className="h-3 w-3 ml-1 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[120px] p-1" align="start">
{(Object.keys(OPERATOR_LABELS) as DateFilterOperator[]).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',
currentOperator === op
? 'bg-primary/10 text-primary font-medium'
: 'hover:bg-muted',
)}
>
{OPERATOR_LABELS[op]}
</button>
))}
</PopoverContent>
</Popover>
{/* Date picker trigger - disabled when "No date set" is selected */}
<DatePickerWithNaturalInput
value={currentDate}
onChange={handleDateChange}
showTimeInput={false}
showClearButton={false}
trigger={
<Button
variant="outline"
size="sm"
disabled={currentOperator === 'empty'}
className={cn(
'h-8 flex-1 justify-start text-xs',
!currentDate && 'text-muted-foreground',
currentOperator === 'empty' && 'opacity-50 cursor-not-allowed',
)}
>
<Calendar className="mr-2 h-3.5 w-3.5" />
{currentDate
? isRelative
? `${formatDaysOffset(columnFilter!.dateFromDaysOffset!)} (${format(currentDate, 'MMM d')})`
: format(currentDate, 'MMM d, yyyy')
: currentOperator === 'range'
? 'From...'
: 'Select date...'}
</Button>
}
/>
</div>
{/* Second date picker for range operator */}
{currentOperator === 'range' && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-[90px] shrink-0 pl-1">to</span>
<DatePickerWithNaturalInput
value={currentDateTo}
onChange={handleDateToChange}
showTimeInput={false}
showClearButton={false}
trigger={
<Button
variant="outline"
size="sm"
className={cn(
'h-8 flex-1 justify-start text-xs',
!currentDateTo && 'text-muted-foreground',
)}
>
<Calendar className="mr-2 h-3.5 w-3.5" />
{currentDateTo
? isRelative
? `${formatDaysOffset(columnFilter!.dateToDaysOffset!)} (${format(currentDateTo, 'MMM d')})`
: format(currentDateTo, 'MMM d, yyyy')
: 'To...'}
</Button>
}
/>
</div>
)}
{/* Relative date toggle */}
{currentOperator !== 'empty' && (
<div className="flex items-center gap-2">
<Checkbox
id={`relative-toggle-${field}`}
checked={isRelative}
disabled={!currentDate}
onCheckedChange={(checked) => handleToggleRelative(checked === true)}
/>
<Label
htmlFor={`relative-toggle-${field}`}
className={cn(
'text-xs cursor-pointer select-none',
!currentDate && 'text-muted-foreground opacity-50 cursor-not-allowed',
)}
>
Relative
</Label>
{isRelative && (
<span className="text-muted-foreground text-[10px]">
Updates daily
</span>
)}
</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 date set'
) : currentOperator === 'range' ? (
currentDate && currentDateTo ? (
<>
Showing items from{' '}
{isRelative ? formatDaysOffset(columnFilter!.dateFromDaysOffset!) : format(currentDate, 'MMM d, yyyy')}
{' '}to{' '}
{isRelative ? formatDaysOffset(columnFilter!.dateToDaysOffset!) : format(currentDateTo, 'MMM d, yyyy')}
{isRelative && ' (live)'}
</>
) : currentDate ? (
<>
Showing items from{' '}
{isRelative ? formatDaysOffset(columnFilter!.dateFromDaysOffset!) : format(currentDate, 'MMM d, yyyy')}
{isRelative && ' (live)'}
</>
) : (
'Select a start date to apply filter'
)
) : currentDate ? (
<>
Showing items {currentOperator}{' '}
{isRelative ? formatDaysOffset(columnFilter!.dateFromDaysOffset!) : format(currentDate, 'MMM d, yyyy')}
{isRelative && ' (live)'}
</>
) : (
'Select a date to apply filter'
)}
</div>
)}
</div>
</>
)}
{crossLink}
</div>
);
}
interface DateColumnPopoverProps extends ColumnPopoverPresentation {
field: DateColumnField;
children: React.ReactElement;
canvasId?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
headerAction?: React.ReactNode;
}
export function DateColumnPopover({
field,
children,
canvasId,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
headerAction,
side,
align = 'start',
mode,
crossLink,
}: DateColumnPopoverProps) {
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}>
<DateColumnPopoverContent
field={field}
canvasId={canvasId}
headerAction={headerAction}
mode={mode}
crossLink={crossLink}
/>
</PopoverContent>
</Popover>
);
}
// Export the field type for use in other components
export type { DateColumnField };