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>
  );
}