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