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