task-column-popover.tsx21.5 KBView on GitHub /**
* Task Column Popover Component
*
* Compound popover for the "Tasks" column that combines three filter dimensions:
* 1. Binary: Has Tasks / No Tasks (existing binaryChoice behavior)
* 2. Task Type: Multi-select filter by task type enum
* 3. Task Due Date: Date filter with operators (before/after/on/range/empty)
*
* All three combine via AND in the backend EXISTS subquery on user_tasks.
*/
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 type { ColumnFilter, ColumnSort, DateFilterOperator } 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, Calendar, Check, ChevronDown, 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 { Label } from '@/components/ui/label';
import { motion } from 'motion/react';
import { format } from 'date-fns';
import { cn } from '@/lib/utils';
// Task types from aop-schema.ts
const TASK_TYPE_OPTIONS = [
{ value: 'response', label: 'Response' },
{ value: 'follow-up', label: 'Follow-up' },
{ value: 'post-meeting', label: 'Post-meeting' },
{ value: 'pre-meeting', label: 'Pre-meeting' },
{ value: 'reactivation', label: 'Reactivation' },
{ value: 'manual', label: 'Manual' },
] as const;
const DATE_OPERATOR_LABELS: Record<DateFilterOperator, string> = {
before: 'Before',
after: 'After',
on: 'On',
range: 'Range',
empty: 'No date set',
};
interface TaskColumnPopoverContentProps {
mode?: ColumnPopoverMode;
crossLink?: React.ReactNode;
canvasId?: string;
headerAction?: React.ReactNode;
columnId?: 'currentTasks' | 'currentAction' | 'futureAction';
}
function TaskColumnPopoverContent({
canvasId,
headerAction,
columnId = 'currentTasks',
mode = 'full',
crossLink,
}: TaskColumnPopoverContentProps) {
const field = columnId;
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';
// Binary choice state — "has something to do" vs "nothing to do".
// futureAction has no backend filter, so it stays sort-only.
const currentChoice = columnFilter?.binaryChoice;
const isNoTasks = currentChoice === 'false';
const isCurrentAction = columnId === 'currentAction' || columnId === 'futureAction';
const supportsBinary = columnId !== 'futureAction';
const binaryLabels =
columnId === 'currentAction'
? { section: 'Has action', yes: 'Has Suggested Action', no: 'No Suggested Action' }
: { section: 'Has tasks', yes: 'Has Todo Tasks', no: 'No Todo Tasks' };
// Task type state
const selectedTypes = columnFilter?.selected ?? [];
// Date filter state
const storedOperator = columnFilter?.dateOperator;
const currentDate = columnFilter?.dateFrom ? new Date(columnFilter.dateFrom) : null;
const currentDateTo = columnFilter?.dateTo ? new Date(columnFilter.dateTo) : null;
const [localOperator, setLocalOperator] = useState<DateFilterOperator>(
storedOperator ?? 'before',
);
useEffect(() => {
if (storedOperator) {
setLocalOperator(storedOperator);
}
}, [storedOperator]);
const currentOperator = storedOperator ?? localOperator;
// Canvas-aware filter write — preserves all filter dimensions
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, setColumnFilter, updateCanvasViewConfig, saveCanvasViewConfig],
);
// Helper to merge a partial filter update with the existing filter
const mergeFilter = useCallback(
(partial: Partial<ColumnFilter>) => {
const current = columnFilter ?? {};
const merged = { ...current, ...partial };
// Clean up undefined values so hasActiveFilter works correctly
if (!merged.binaryChoice) delete merged.binaryChoice;
if (!merged.selected || merged.selected.length === 0) delete merged.selected;
if (!merged.dateFrom) delete merged.dateFrom;
if (!merged.dateTo) delete merged.dateTo;
if (!merged.dateOperator) delete merged.dateOperator;
// If everything cleared, set to undefined
if (!merged.binaryChoice && !merged.selected && !merged.dateFrom && !merged.dateTo && !merged.dateOperator) {
applyFilter(undefined);
} else {
applyFilter(merged);
}
},
[columnFilter, applyFilter],
);
// 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, 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]);
// Binary choice handlers
const handleBinaryToggle = useCallback(
(value: 'true' | 'false') => {
if (currentChoice === value) {
mergeFilter({ binaryChoice: undefined });
} else {
if (value === 'false') {
// "No Tasks" clears task type and date filters since they're meaningless
applyFilter({ binaryChoice: 'false' });
} else {
mergeFilter({ binaryChoice: 'true' });
}
}
},
[currentChoice, mergeFilter, applyFilter],
);
// Task type handlers
const handleTypeToggle = useCallback(
(typeValue: string) => {
const currentSelected = [...selectedTypes];
const idx = currentSelected.indexOf(typeValue);
if (idx >= 0) {
currentSelected.splice(idx, 1);
} else {
currentSelected.push(typeValue);
}
mergeFilter({ selected: currentSelected.length > 0 ? currentSelected : undefined });
},
[selectedTypes, mergeFilter],
);
// Date filter handlers
const handleOperatorChange = useCallback(
(operator: DateFilterOperator) => {
setLocalOperator(operator);
if (operator === 'empty') {
mergeFilter({ dateOperator: 'empty', dateFrom: undefined, dateTo: undefined });
} else if (operator !== 'range') {
mergeFilter({
dateFrom: currentDate?.toISOString(),
dateTo: undefined,
dateOperator: operator,
});
} else {
mergeFilter({
dateFrom: currentDate?.toISOString(),
dateTo: currentDateTo?.toISOString(),
dateOperator: operator,
});
}
setOperatorOpen(false);
},
[currentDate, currentDateTo, mergeFilter],
);
const handleDateChange = useCallback(
(date: Date | null) => {
if (date) {
const operator = currentOperator === 'empty' ? 'before' : currentOperator;
setLocalOperator(operator);
mergeFilter({
dateFrom: date.toISOString(),
dateTo: currentOperator === 'range' ? columnFilter?.dateTo : undefined,
dateOperator: operator,
});
} else {
mergeFilter({ dateFrom: undefined, dateTo: undefined, dateOperator: undefined });
}
},
[currentOperator, columnFilter?.dateTo, mergeFilter],
);
const handleDateToChange = useCallback(
(date: Date | null) => {
mergeFilter({
dateFrom: columnFilter?.dateFrom,
dateTo: date ? date.toISOString() : undefined,
dateOperator: 'range',
});
},
[columnFilter?.dateFrom, mergeFilter],
);
const handleClearFilter = useCallback(() => {
applyFilter(undefined);
}, [applyFilter]);
const hasActiveFilter = (supportsBinary && !!currentChoice) || selectedTypes.length > 0 || currentOperator === 'empty' || !!currentDate;
return (
<div className="space-y-3">
{/* Header */}
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold">
{columnId === 'currentAction' ? 'Suggested Action' : columnId === 'futureAction' ? 'Future Action' : 'Tasks'}
</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-tasks"
checked={isActiveSortField}
onCheckedChange={handleToggleSortingActive}
/>
<Label htmlFor="sort-toggle-tasks" className="text-sm">
Sort
</Label>
</div>
)}
<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' ? 'Ascending' : 'Descending'}</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' && (
<>
{/* Section 1: Has / No — the one-click "only show rows I have to act on" filter */}
{supportsBinary && (
<div className="space-y-2">
<Label className="text-muted-foreground text-sm">{binaryLabels.section}</Label>
<div className="space-y-2">
<button
onClick={() => handleBinaryToggle('true')}
className={cn(
'flex w-full items-center gap-2 rounded-sm px-3 py-2 text-xs font-semibold transition-all',
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
currentChoice === 'true' && 'ring-primary/20 ring-2 ring-offset-1',
'hover:scale-[1.02]',
)}
>
<span className="flex-1 text-left">{binaryLabels.yes}</span>
{currentChoice === 'true' && <Check className="h-3.5 w-3.5 shrink-0" />}
</button>
<button
onClick={() => handleBinaryToggle('false')}
className={cn(
'flex w-full items-center gap-2 rounded-sm px-3 py-2 text-xs font-semibold transition-all',
'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200',
currentChoice === 'false' && 'ring-primary/20 ring-2 ring-offset-1',
'hover:scale-[1.02]',
)}
>
<span className="flex-1 text-left">{binaryLabels.no}</span>
{currentChoice === 'false' && <Check className="h-3.5 w-3.5 shrink-0" />}
</button>
</div>
</div>
)}
{/* Section 2: Task Type multi-select */}
<div className={cn('space-y-2', isNoTasks && 'pointer-events-none opacity-40')}>
<Label className="text-muted-foreground text-sm">Task type</Label>
<div className="grid grid-cols-2 gap-1.5">
{TASK_TYPE_OPTIONS.map((opt) => {
const isSelected = selectedTypes.includes(opt.value);
return (
<button
key=[redacted]
onClick={() => handleTypeToggle(opt.value)}
className={cn(
'flex items-center gap-1.5 rounded-sm px-2.5 py-1.5 text-xs font-medium transition-all',
isSelected
? 'bg-primary/10 text-primary ring-primary/20 ring-1'
: 'bg-muted/50 text-muted-foreground hover:bg-muted',
)}
>
<div
className={cn(
'flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-sm border transition-colors',
isSelected
? 'border-primary bg-primary text-white'
: 'border-muted-foreground/30',
)}
>
{isSelected && <Check className="h-2.5 w-2.5" />}
</div>
<span className="truncate">{opt.label}</span>
</button>
);
})}
</div>
</div>
{/* Section 3: Task Due Date (currentTasks only — currentAction uses implicit "before tomorrow") */}
{!isCurrentAction && <div className={cn('space-y-2', isNoTasks && 'pointer-events-none opacity-40')}>
<Label className="text-muted-foreground text-sm">Due 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"
>
{DATE_OPERATOR_LABELS[currentOperator]}
<ChevronDown className="ml-1 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[120px] p-1" align="start">
{(Object.keys(DATE_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',
)}
>
{DATE_OPERATOR_LABELS[op]}
</button>
))}
</PopoverContent>
</Popover>
{/* Date picker */}
<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' && 'cursor-not-allowed opacity-50',
)}
>
<Calendar className="mr-2 h-3.5 w-3.5" />
{currentDate
? 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-muted-foreground w-[90px] shrink-0 pl-1 text-xs">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 ? format(currentDateTo, 'MMM d, yyyy') : 'To...'}
</Button>
}
/>
</div>
)}
</div>}
</>
)}
{crossLink}
</div>
);
}
interface TaskColumnPopoverProps extends ColumnPopoverPresentation {
children: React.ReactElement;
canvasId?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
headerAction?: React.ReactNode;
columnId?: 'currentTasks' | 'currentAction' | 'futureAction';
}
export function TaskColumnPopover({
children,
canvasId,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
headerAction,
columnId = 'currentTasks',
side,
align = 'start',
mode,
crossLink,
}: TaskColumnPopoverProps) {
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}>
<TaskColumnPopoverContent
canvasId={canvasId}
headerAction={headerAction}
columnId={columnId}
mode={mode}
crossLink={crossLink}
/>
</PopoverContent>
</Popover>
);
}