ColumnConfigurationItem.tsx23.6 KBView on GitHub

Introduced 1 production defect in 180 days, median 106 days to fix.

/**
 * ColumnConfigurationItem Component
 *
 * Displays column headers matching the ConversationItem layout.
 * - Clicking on the Company/Settings area opens a popover to configure visible columns.
 * - Clicking on individual column headers opens the appropriate sorting/filtering popover:
 *   - Enum fields (status, priority, etc.) → UnifiedColumnPopover
 *   - Date fields (lastContactedAt, nextSteps) → DateColumnPopover
 *   - Number fields (dealValue) → NumberColumnPopover
 * - Columns (except Company) can be dragged to reorder them.
 */

import {
  DndContext,
  DragOverlay,
  PointerSensor,
  useSensor,
  useSensors,
  closestCenter,
  type DragEndEvent,
  type DragStartEvent,
} from '@dnd-kit/core';
import { SortableContext, useSortable, horizontalListSortingStrategy } from '@dnd-kit/sortable';
import { NumberColumnPopover, type NumberColumnField } from '../number-column-popover';
import { TaskColumnPopover } from '../task-column-popover';
import { CustomFieldColumnPopover } from '../custom-field-column-popover';
import { DateColumnPopover, type DateColumnField } from '../date-column-popover';
import { useCanvasConfiguration } from '../../hooks/use-canvas-configuration';
import { useGlobalCanvasPresets } from '../../hooks/use-global-canvas-presets';
import { UnifiedColumnPopover } from '../unified-column-popover';
import Container3DButton from '@/modules/cedar-os/src/cedar-os-components/containers/Container3DButton';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { ArrowLeftRight, GripVertical, Star } from 'lucide-react';
import type { CRMColumn } from '../../store/crmSlice';
import { useCedarStore } from '@/modules/store';
import type {
  CanvasColumnConfiguration,
  CanvasPreset,
  ConversationViewConfig,
} from '@/modules/canvas/types/canvas-types';
import { CSS } from '@dnd-kit/utilities';
import { createPortal } from 'react-dom';
import { getExpandedColumnWidthPx, getDefaultColumnWidthPx } from '../../utils/canvas-columns';
import { cn } from '@/lib/utils';

/**
 * Built-in column presets for quick configuration
 */
export interface ColumnPreset {
  id: string;
  name: string;
  description: string;
  columnIds: string[];
}

export const COLUMN_PRESETS: ColumnPreset[] = [
  {
    id: 'current-action',
    name: 'Suggested Action',
    description: 'Suggested action items',
    columnIds: ['currentAction'],
  },
  {
    id: 'status',
    name: 'Stage',
    description: 'Stage badge, overview and deal size',
    columnIds: ['status', 'statusOverview', 'dealValue'],
  },
  {
    id: 'next-steps',
    name: 'Next Steps',
    description: 'Next steps and last contacted',
    columnIds: ['nextSteps', 'nextStepDate', 'lastContactedAt'],
  },
  {
    id: 'timeline-overview',
    name: 'Timeline Overview',
    description: 'Status badge, activity timeline, and last contacted',
    columnIds: ['status', 'statusOverview', 'activityOverview', 'lastContactedAt'],
  },
];

/** Built-in default presets shown when no global user presets are configured. */
export const BUILTIN_CANVAS_PRESETS: CanvasPreset[] = COLUMN_PRESETS.map((p) => ({
  id: p.id,
  name: p.name,
  columnIds: p.columnIds,
}));

// Field type sets (matching SortableColumnHeader pattern)
const ENUM_FIELDS = new Set<string>([
  'type',
  'status',
  'priority',
  'crmSynced',
  'history',
  'hasFutureCalendar',
]);

const DATE_FIELDS = new Set<string>(['lastContactedAt', 'nextSteps', 'nextStepDate', 'lastMeetingTime', 'lastOutboundEmailTime', 'lastInboundEmailTime']);

const NUMBER_FIELDS = new Set<string>(['dealValue']);

const ACTION_FIELDS = new Set<string>(['currentAction']);

/**
 * Props for the sortable column header
 */
interface SortableColumnProps {
  column: CRMColumn;
  isExpanded: boolean;
  columnWidth?: number;
  getColumnLabel: (column: CRMColumn) => string;
  getPlaceholderText: (column: CRMColumn) => string;
  getPopoverType: (columnId: string) => 'enum' | 'date' | 'number' | 'action' | 'customField' | null;
  renderColumnWithPopover: (
    column: CRMColumn,
    columnContent: React.ReactElement,
  ) => React.ReactNode;
  onResizePointerDown: (e: React.PointerEvent, column: CRMColumn) => void;
}

/**
 * Sortable column header component that can be dragged to reorder
 */
function SortableColumn({
  column,
  isExpanded,
  columnWidth,
  getColumnLabel,
  getPlaceholderText,
  getPopoverType,
  renderColumnWithPopover,
  onResizePointerDown,
}: SortableColumnProps) {
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
    id: column.id,
    animateLayoutChanges: () => true,
  });

  const shouldGrow =
    column.type === 'text' ||
    column.type === 'current-action' ||
    column.type === 'activity-overview';

  const hasCustomWidth = columnWidth != null;

  // Compute numeric width for inline style
  const widthPx = isExpanded
    ? (columnWidth ?? getExpandedColumnWidthPx(column.type, column.id))
    : hasCustomWidth
      ? columnWidth
      : shouldGrow
        ? undefined
        : getDefaultColumnWidthPx(column.type, column.id);

  const label = getColumnLabel(column);
  const placeholder = getPlaceholderText(column);
  const supportsPopover = getPopoverType(column.id) !== null;

  const style = {
    transform: CSS.Transform.toString(transform),
    transition: transition || 'transform 200ms ease',
    ...(widthPx != null ? { width: widthPx } : {}),
  };

  const columnContent = (
    <div
      ref={setNodeRef}
      style={style}
      className={cn(
        'group/col relative flex items-center gap-1.5',
        isExpanded || !shouldGrow || hasCustomWidth ? 'shrink-0' : 'min-w-0 flex-1',
        isDragging && 'opacity-30',
      )}
    >
      {/* Drag handle - positioned absolutely to not take up space */}
      <div
        {...attributes}
        {...listeners}
        className="absolute -left-4 top-1/2 -translate-y-1/2 cursor-grab opacity-0 group-hover/col:opacity-50 hover:!opacity-100 transition-opacity active:cursor-grabbing"
      >
        <GripVertical className="h-3.5 w-3.5 text-muted-foreground" />
      </div>
      {/* Column header badge */}
      <span
        className={cn(
          'shrink-0 rounded-sm bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground',
          supportsPopover &&
            'cursor-pointer hover:bg-muted/80 hover:text-foreground transition-colors',
        )}
      >
        {label}
      </span>
      {/* Placeholder text for combined/text types */}
      {placeholder && (
        <span className="min-w-0 flex-1 truncate text-xs text-muted-foreground/60">
          {placeholder}
        </span>
      )}
      {/* Resize handle */}
      <div
        className="absolute right-0 top-0 bottom-0 w-2 cursor-col-resize opacity-0 group-hover/col:opacity-100 flex items-center justify-end pr-0.5"
        onPointerDown={(e) => {
          e.stopPropagation(); // prevent dnd-kit sensor
          onResizePointerDown(e, column);
        }}
        onClick={(e) => e.stopPropagation()}
      >
        <div className="w-0.5 h-4 rounded-full bg-border" />
      </div>
    </div>
  );

  // Wrap in appropriate popover based on column type
  return <>{renderColumnWithPopover(column, columnContent)}</>;
}

/**
 * Overlay component shown during drag
 */
function DragOverlayColumn({
  column,
  getColumnLabel,
}: {
  column: CRMColumn;
  getColumnLabel: (column: CRMColumn) => string;
}) {
  const label = getColumnLabel(column);

  return (
    <div className="flex items-center gap-1.5 rounded-md border border-border bg-background px-2 py-1 shadow-lg">
      <GripVertical className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
      <span className="shrink-0 rounded-sm bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
        {label}
      </span>
    </div>
  );
}

export const ColumnConfigurationItem = memo(function ColumnConfigurationItem({
  canvasId,
}: {
  canvasId?: string;
}) {
  const [activeId, setActiveId] = useState<string | null>(null);
  const [dragOverlayContainer, setDragOverlayContainer] = useState<HTMLElement | null>(null);

  const { columns: visibleColumns, allColumns, selectedAopIds, setSelectedAopIds } = useCanvasConfiguration(canvasId);
  const { syncActivePresetColumns } = useGlobalCanvasPresets();

  // Canvas view config subscription — needed to build merged updates
  const canvasViewConfig = useCedarStore((state) => {
    if (!canvasId) return null;
    return state.canvasesById[canvasId]?.viewConfig as ConversationViewConfig | null | undefined;
  });
  const updateCanvasViewConfig = useCedarStore((state) => state.updateCanvasViewConfig);
  const saveCanvasViewConfig = useCedarStore((state) => state.saveCanvasViewConfig);

  // Derive expanded state from the per-canvas view config
  const isExpanded = canvasViewConfig?.expanded ?? false;

  // Ref mirror so resize effects don't re-register on every drag pixel
  const canvasViewConfigRef = useRef(canvasViewConfig);
  useEffect(() => { canvasViewConfigRef.current = canvasViewConfig; }, [canvasViewConfig]);

  const showConversationName = canvasViewConfig?.showConversationName ?? false;

  // Company column resize state
  const defaultCompanyWidth = showConversationName ? 224 : 144;
  const companyColumnWidth = canvasViewConfig?.companyColumnWidth ?? defaultCompanyWidth;
  const isDraggingResize = useRef(false);
  const resizeStartX = useRef(0);
  const resizeStartWidth = useRef(0);

  const handleResizeMouseDown = useCallback(
    (e: React.MouseEvent) => {
      e.preventDefault();
      e.stopPropagation();
      isDraggingResize.current = true;
      resizeStartX.current = e.clientX;
      resizeStartWidth.current = canvasViewConfig?.companyColumnWidth ?? defaultCompanyWidth;
      document.body.style.cursor = 'col-resize';
      document.body.style.userSelect = 'none';
    },
    [canvasViewConfig, defaultCompanyWidth],
  );

  useEffect(() => {
    const handleMouseMove = (e: MouseEvent) => {
      if (!isDraggingResize.current || !canvasId) return;
      const cfg = canvasViewConfigRef.current;
      if (!cfg) return;
      const delta = e.clientX - resizeStartX.current;
      const newWidth = Math.max(80, Math.min(480, resizeStartWidth.current + delta));
      updateCanvasViewConfig(canvasId, { ...cfg, companyColumnWidth: newWidth });
    };

    const handleMouseUp = (e: MouseEvent) => {
      if (!isDraggingResize.current) return;
      isDraggingResize.current = false;
      document.body.style.cursor = '';
      document.body.style.userSelect = '';
      if (!canvasId) return;
      const cfg = canvasViewConfigRef.current;
      if (!cfg) return;
      const delta = e.clientX - resizeStartX.current;
      const newWidth = Math.max(80, Math.min(480, resizeStartWidth.current + delta));
      updateCanvasViewConfig(canvasId, { ...cfg, companyColumnWidth: newWidth });
      saveCanvasViewConfig(canvasId);
    };

    document.addEventListener('mousemove', handleMouseMove);
    document.addEventListener('mouseup', handleMouseUp);
    return () => {
      document.removeEventListener('mousemove', handleMouseMove);
      document.removeEventListener('mouseup', handleMouseUp);
    };
  }, [canvasId, updateCanvasViewConfig, saveCanvasViewConfig]);

  // Column resize state
  const resizingColumn = useRef<CRMColumn | null>(null);
  const colResizeStartX = useRef(0);
  const colResizeStartWidth = useRef(0);

  const handleColumnResizePointerDown = useCallback(
    (e: React.PointerEvent, col: CRMColumn) => {
      e.preventDefault();
      resizingColumn.current = col;
      colResizeStartX.current = e.clientX;
      const existing = canvasViewConfig?.columnConfiguration?.[col.id]?.width;
      colResizeStartWidth.current =
        existing ??
        (isExpanded
          ? getExpandedColumnWidthPx(col.type, col.id)
          : getDefaultColumnWidthPx(col.type, col.id));
      document.body.style.cursor = 'col-resize';
      document.body.style.userSelect = 'none';
    },
    [canvasViewConfig, isExpanded],
  );

  useEffect(() => {
    const handlePointerMove = (e: PointerEvent) => {
      if (!resizingColumn.current || !canvasId) return;
      const cfg = canvasViewConfigRef.current;
      if (!cfg) return;
      const delta = e.clientX - colResizeStartX.current;
      const newWidth = Math.max(40, Math.min(600, colResizeStartWidth.current + delta));
      const currentConfig = cfg.columnConfiguration ?? {};
      updateCanvasViewConfig(canvasId, {
        ...cfg,
        columnConfiguration: {
          ...currentConfig,
          [resizingColumn.current.id]: { ...currentConfig[resizingColumn.current.id], width: newWidth },
        },
      });
    };

    const handlePointerUp = (e: PointerEvent) => {
      if (!resizingColumn.current) return;
      const col = resizingColumn.current;
      resizingColumn.current = null;
      document.body.style.cursor = '';
      document.body.style.userSelect = '';
      if (!canvasId) return;
      const cfg = canvasViewConfigRef.current;
      if (!cfg) return;
      const delta = e.clientX - colResizeStartX.current;
      const newWidth = Math.max(40, Math.min(600, colResizeStartWidth.current + delta));
      const currentConfig = cfg.columnConfiguration ?? {};
      updateCanvasViewConfig(canvasId, {
        ...cfg,
        columnConfiguration: {
          ...currentConfig,
          [col.id]: { ...currentConfig[col.id], width: newWidth },
        },
      });
      saveCanvasViewConfig(canvasId);
    };

    document.addEventListener('pointermove', handlePointerMove);
    document.addEventListener('pointerup', handlePointerUp);
    return () => {
      document.removeEventListener('pointermove', handlePointerMove);
      document.removeEventListener('pointerup', handlePointerUp);
    };
  }, [canvasId, updateCanvasViewConfig, saveCanvasViewConfig]);

  const toggleCompanyNameMode = useCallback(() => {
    if (!canvasId || !canvasViewConfig) return;
    updateCanvasViewConfig(canvasId, { ...canvasViewConfig, showConversationName: !showConversationName });
    saveCanvasViewConfig(canvasId);
  }, [canvasId, canvasViewConfig, showConversationName, updateCanvasViewConfig, saveCanvasViewConfig]);

  const reorderColumns = useCallback(
    (newOrder: string[]) => {
      if (!canvasId || !canvasViewConfig) return;
      const currentConfig = canvasViewConfig.columnConfiguration ?? {};
      const newColumnConfig: CanvasColumnConfiguration = {
        ...currentConfig,
        primaryCompany: { ...currentConfig['primaryCompany'], order: 0 },
      };
      newOrder.forEach((colId, idx) => {
        newColumnConfig[colId] = { ...newColumnConfig[colId], order: idx + 1 };
      });
      updateCanvasViewConfig(canvasId, { ...canvasViewConfig, columnConfiguration: newColumnConfig });
      saveCanvasViewConfig(canvasId);

      // Sync reordered column IDs to the active preset
      void syncActivePresetColumns(canvasViewConfig.presetId, newOrder);
    },
    [canvasId, canvasViewConfig, updateCanvasViewConfig, saveCanvasViewConfig, syncActivePresetColumns],
  );

  // Create a container for the drag overlay
  useEffect(() => {
    const container = document.createElement('div');
    container.style.position = 'fixed';
    container.style.top = '0';
    container.style.left = '0';
    container.style.zIndex = '10001';
    container.style.pointerEvents = 'none';
    document.body.appendChild(container);
    setDragOverlayContainer(container);

    return () => {
      document.body.removeChild(container);
    };
  }, []);

  // Setup drag sensors
  const sensors = useSensors(
    useSensor(PointerSensor, {
      activationConstraint: {
        distance: 8, // Require 8px drag before starting
      },
    }),
  );

  // Filter out columns we don't want to show in the list view (same as ConversationItem)
  const displayColumns = visibleColumns.filter(
    (col) =>
      col.type !== 'timeline' && col.type !== 'working-memory' && col.id !== 'wm_custom',
  );

  // Get draggable columns (all except primaryCompany which is always first)
  const draggableColumns = displayColumns.filter((col) => col.id !== 'primaryCompany');

  // Handle drag start
  const handleDragStart = useCallback((event: DragStartEvent) => {
    setActiveId(event.active.id as string);
  }, []);

  // Handle drag end - reorder columns in store
  const handleDragEnd = useCallback(
    (event: DragEndEvent) => {
      const { active, over } = event;
      setActiveId(null);

      if (!over || active.id === over.id) return;

      // Get current column order (excluding primaryCompany)
      const currentOrder = draggableColumns.map((col) => col.id);
      const oldIndex = currentOrder.indexOf(active.id as string);
      const newIndex = currentOrder.indexOf(over.id as string);

      if (oldIndex === -1 || newIndex === -1) return;

      // Create new order array
      const newOrder = [...currentOrder];
      const [movedItem] = newOrder.splice(oldIndex, 1);
      newOrder.splice(newIndex, 0, movedItem);

      // Update store (reorderColumns will ensure primaryCompany stays first)
      reorderColumns(newOrder);
    },
    [draggableColumns, reorderColumns],
  );

  // Get the active column for the drag overlay
  const activeColumn = activeId ? draggableColumns.find((col) => col.id === activeId) : null;

  // Get column label based on type
  const getColumnLabel = (column: (typeof displayColumns)[0]) => {
    switch (column.type) {
      case 'company-combined':
        return 'Company';
      case 'current-action':
        return 'Suggested Action';
      default:
        return column.name || column.id;
    }
  };

  // Get placeholder text for combined types
  const getPlaceholderText = (column: (typeof displayColumns)[0]) => {
    switch (column.type) {
      case 'current-action':
        return 'Overdue tasks or activity...';
      case 'text':
        return `${column.name || 'Text'}...`;
      default:
        return '';
    }
  };

  // Determine what type of popover a column should have
  const getPopoverType = (columnId: string) => {
    // Score sub-columns have no popover
    const col = allColumns.find((c) => c.id === columnId);
    if (col?.type === 'score') return null;
    if (columnId.startsWith('wm_')) return 'customField';
    if (ENUM_FIELDS.has(columnId)) return 'enum';
    if (DATE_FIELDS.has(columnId)) return 'date';
    if (NUMBER_FIELDS.has(columnId)) return 'number';
    if (ACTION_FIELDS.has(columnId)) return 'action';
    return null;
  };

  // Render column with appropriate popover wrapper
  const renderColumnWithPopover = (
    column: (typeof displayColumns)[0],
    columnContent: React.ReactElement,
  ) => {
    const popoverType = getPopoverType(column.id);

    switch (popoverType) {
      case 'enum': {
        const enumField = column.id as
          | 'status'
          | 'priority'
          | 'type'
          | 'crmSynced'
          | 'history'
          | 'hasFutureCalendar';
        return (
          <UnifiedColumnPopover
            key=[redacted]
            field={enumField}
            enumOptions={column.enumOptions}
            canvasId={canvasId}
            canvasSelectedAopIds={selectedAopIds}
            canvasSetSelectedAopIds={setSelectedAopIds}
          >
            {columnContent}
          </UnifiedColumnPopover>
        );
      }

      case 'date': {
        const dateField = (column.id === 'nextSteps' ? 'nextStepDate' : column.id) as DateColumnField;
        return (
          <DateColumnPopover key=[redacted] field={dateField} canvasId={canvasId}>
            {columnContent}
          </DateColumnPopover>
        );
      }

      case 'number':
        return (
          <NumberColumnPopover
            key=[redacted]
            field={column.id as NumberColumnField}
            title={column.name}
            prefix={column.id === 'dealValue' ? '$' : undefined}
            canvasId={canvasId}
          >
            {columnContent}
          </NumberColumnPopover>
        );

      case 'action':
        return (
          <TaskColumnPopover
            key=[redacted]
            columnId={column.id as 'currentAction' | 'futureAction'}
            canvasId={canvasId}
          >
            {columnContent}
          </TaskColumnPopover>
        );

      case 'customField':
        return (
          <CustomFieldColumnPopover
            key=[redacted]
            columnId={column.id}
            title={column.name}
            fieldType={column.type}
            valueSortable={column.valueSortable ?? true}
            hasSignal={column.hasSignal ?? false}
            enumOptions={column.enumOptions}
            canvasId={canvasId}
          >
            {columnContent}
          </CustomFieldColumnPopover>
        );

      default:
        return columnContent;
    }
  };

  return (
    <div
      className={cn(
        'border-border/30 relative flex select-none items-center gap-3 border-b px-3 py-2 overflow-hidden',
        'bg-muted/30',
        isExpanded && 'w-fit min-w-full',
      )}
    >
      {/* Company Section (Resizable) - matches ConversationItem */}
      <div className="group/company relative shrink-0" style={{ width: companyColumnWidth }}>
        <div className="flex w-full items-center justify-between gap-2 text-muted-foreground">
          <div className="flex min-w-0 items-center gap-2">
            <Container3DButton
              shadowSize="s"
              bgColor="rgba(59, 130, 246, 0.15)"
              className="rounded-full h-6 w-6 !p-0 shrink-0"
              childClassName="!p-0 flex items-center justify-center h-6 w-6"
              onClick={toggleCompanyNameMode}
            >
              <ArrowLeftRight className="h-3.5 w-3.5 text-blue-500" />
            </Container3DButton>
            <span className="text-xs font-medium uppercase tracking-wide truncate">
              {showConversationName ? 'Name' : 'Company'}
            </span>
          </div>
          <span className="shrink-0" title="Press X to star">
            <Star className="h-3.5 w-3.5 text-muted-foreground/40" />
          </span>
        </div>

      {/* Resize handle */}
      <div
        className="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize opacity-0 group-hover/company:opacity-100 hover:!opacity-100 transition-opacity flex items-center justify-center"
        onMouseDown={handleResizeMouseDown}
      >
        <div className="w-0.5 h-4 rounded-full bg-border" />
      </div>
      </div>

      {/* Columns Section (Flexible) - matches ConversationItem */}
      <DndContext
        sensors={sensors}
        collisionDetection={closestCenter}
        onDragStart={handleDragStart}
        onDragEnd={handleDragEnd}
      >
        <SortableContext
          items={draggableColumns.map((col) => col.id)}
          strategy={horizontalListSortingStrategy}
        >
          <div className={cn('flex items-center gap-3 overflow-visible', isExpanded ? 'shrink-0' : 'min-w-0 flex-1')}>
            {draggableColumns.map((column) => (
              <SortableColumn
                key=[redacted]
                column={column}
                isExpanded={isExpanded}
                columnWidth={canvasViewConfig?.columnConfiguration?.[column.id]?.width}
                getColumnLabel={getColumnLabel}
                getPlaceholderText={getPlaceholderText}
                getPopoverType={getPopoverType}
                renderColumnWithPopover={renderColumnWithPopover}
                onResizePointerDown={handleColumnResizePointerDown}
              />
            ))}
          </div>
        </SortableContext>

        {/* Drag overlay for visual feedback */}
        {dragOverlayContainer &&
          createPortal(
            <DragOverlay
              dropAnimation={{
                duration: 200,
                easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)',
              }}
            >
              {activeColumn && (
                <DragOverlayColumn column={activeColumn} getColumnLabel={getColumnLabel} />
              )}
            </DragOverlay>,
            dragOverlayContainer,
          )}
      </DndContext>
    </div>
  );
});