crmSlice.ts46.1 KBView on GitHub
/**
 * CRM Slice for Mail Store
 *
 * Centralized CRM state management:
 * - Selected AOP (controls available columns)
 * - Column configuration (widths, order, visibility)
 * - Filters (search, status, priority, etc.)
 * - Sorting (multi-field with custom order)
 * - Selection (bulk operations)
 *
 * Note: Actual conversation data is stored in conversationsSlice
 * Note: Active conversation ID is stored in conversationsSlice.activeConversationId
 */

import type { CedarStore } from '@/modules/store/CedarStoreTypes';
import type { StateCreator } from 'zustand';
import { currentActionDueCutoff, resolveOffsetToISO } from '../utils/relative-dates';

// Re-export conversation event type from index
export type { ConversationEvent } from '../types/index';
// Re-export ConversationUserTask as UserTask for backwards compatibility
export type { ConversationUserTask as UserTask } from '../types/index';
import type { ConversationEvent, ConversationUserTask, CrmFieldEnumOption, CustomFieldType } from '../types';

/**
 * Multi-select AOP state interpretation:
 * - null = first load, auto-select default AOP
 * - [] = show all AOPs (no custom fields)
 * - ['uuid1'] = single AOP selected (show custom fields)
 * - ['uuid1', 'uuid2'] = multiple AOPs selected (no custom fields)
 *
 * Custom fields are only shown when exactly one AOP is selected.
 */

/**
 * CRM sync status filter values
 */
export const CRM_SYNC_STATUS = {
  SYNCED: 'synced',
  NOT_SYNCED: 'not_synced',
} as const;

// ============ DATA MODEL TYPES ============

export type ColumnType =
  // Custom field types — single source of truth in ../types/index.ts
  | CustomFieldType
  // Built-in CRM column types
  | 'checkbox' // Legacy checkbox type
  | 'timeline'
  | 'activity-overview' // Horizontal timeline showing event activity over time
  | 'next-step'
  | 'status-badge'
  | 'company-combined'
  | 'working-memory'
  | 'scheduled-action'
  | 'user-tasks'
  | 'aop-type'
  | 'crm-synced'
  // Action column types (unified task + activity view)
  | 'current-action' // Shows overdue/current tasks or last activity if no tasks
  | 'future-action' // Shows future tasks or next steps if no tasks
  // Signal column (red/yellow/green sub-column of a custom field)
  | 'score'
  // Owner user column — shows the user who created/owns the conversation
  | 'user';

/**
 * Base column metadata (without state)
 * Used for static column definitions in config files
 */
export interface CRMColumnMetadata {
  id: string;
  name: string;
  type: ColumnType;
  description: string;
  isFixed?: boolean;
  enumOptions?: CrmFieldEnumOption[];
  /** Custom field columns only: whether the value dimension is sortable (false for text/url/email/phone) */
  valueSortable?: boolean;
  /** Custom field columns only: whether the field has signal scoring enabled */
  hasSignal?: boolean;
  /** Custom field columns only: whether filtering is supported */
  filterable?: boolean;
  /** Custom field columns only: available filter dimensions (value + signal if enabled) */
  filterDimensions?: ('value' | 'signal')[];
  /** Signal sub-columns only: ID of the parent custom field column */
  parentColumnId?: string;
  /** Signal sub-columns only: which dimension this column displays */
  signalDimension?: 'signal';
  /** Agent-state columns only: output type of the agent */
  agentOutputType?: 'score' | 'notification';
}

/**
 * Unified column type with ALL state and metadata.
 * This is the single source of truth for columns throughout the CRM table.
 *
 * Combines:
 * - Base metadata: id, name, type, description, isFixed
 * - Enum options: from AOP or static config (runtime-enriched)
 * - Display preferences: width, order, visible, minWidth
 * - Sorting state: sort
 * - Filtering state: filter
 */
export interface CRMColumn extends CRMColumnMetadata {
  // Display preferences
  width: number;
  order: number;
  visible: boolean;
  minWidth: number;
  // Sorting state
  sort?: ColumnSort;
  // Filtering state
  filter?: ColumnFilter;
}

export interface NextStep {
  action: string;
  date?: string;
}

export interface CompanyCombined {
  primaryCompany: string | null;
  name: string | null;
}

export interface ScheduledAction {
  runId: string;
  prompt: string | null;
  status: 'pending' | 'executing' | 'final' | 'completed' | 'canceled' | 'failed';
  scheduledFor: Date | null;
}

export interface WorkingMemoryField {
  name: string;
  value: string;
}

export interface CrmSyncedValue {
  isSynced: boolean;
  provider: string | null;
}

/**
 * Represents a task for display in action columns
 */
export interface ActionTask {
  id: string;
  taskOutput: ConversationUserTask['taskOutput'];
  dueDate: Date | null;
  description: string | null;
  taskType: string | null;
}

/**
 * Last activity event for display when no tasks
 */
export interface LastActivity {
  type: 'email_sent' | 'email_received' | 'meeting' | 'slack' | 'other';
  date: Date;
  title: string;
}

/**
 * Next calendar event for display in current action column
 */
export interface NextCalendarEvent {
  title: string;
  startTime: Date;
  endTime: Date;
  isInProgress: boolean;
  hangoutLink: string | null;
  conferenceUri: string | null;
  attendeeCount: number;
}

/**
 * Current action data - shows overdue/current tasks or last activity
 */
export interface CurrentActionData {
  // Conversation ID for task creation
  conversationId: string;
  // Most overdue task (earliest due date in past or today) - full task for TimelineTaskItem
  primaryTask: ConversationUserTask | null;
  // All overdue/current tasks (primaryTask first), ordered by priority then due date.
  // Used to render every followup stacked when the row is selected.
  overdueTasks: ConversationUserTask[];
  // All sibling tasks for context
  // Count of additional overdue/current tasks
  additionalTaskCount: number;
  // If no tasks, show last activity
  lastActivity: LastActivity | null;
  // Next scheduled calendar event (if any)
  nextCalendarEvent: NextCalendarEvent | null;
  // Nearest upcoming task due in the future (after today), if any
  nextFutureTask: ConversationUserTask | null;
}

/**
 * Future action data - shows future tasks or next steps
 */
export interface FutureActionData {
  // Conversation ID for task creation
  conversationId: string;
  // Nearest future task - full task for TimelineTaskItem
  primaryTask: ConversationUserTask | null;
  // All sibling tasks for context
  // Count of additional future tasks
  additionalTaskCount: number;
  // If no future tasks, show next step text
  nextSteps: string | null;
  nextStepDate: string | null;
}

export interface CRMRow {
  id: string;
  [key=[redacted]
    | string
    | boolean
    | number
    | ConversationEvent[]
    | NextStep
    | CompanyCombined
    | WorkingMemoryField[]
    | ScheduledAction
    | ConversationUserTask[]
    | CrmSyncedValue
    | CurrentActionData
    | FutureActionData
    | null
    | undefined;
}

export type CRMRowDictionary = Record<string, CRMRow>;

// Server-side contact type (from TRPC)
export interface CRMContact {
  id: string;
  userId: string;
  // Nullable since the person-keyed migration (person_id is the anchor; email optional).
  personEmail: string | null;
  relationshipType: string | null;
  relationshipStrength: string | null;
  nextStep: string | null;
  objective: string | null;
  notes: string | null;
  priority: string | null;
  status: string | null;
  customFields: Record<string, unknown> | null;
  lastContactedAt: Date | null;
  createdAt: Date;
  updatedAt: Date;
  person?: {
    email: string;
    name: string | null;
    currentRole: string | null;
    currentCompany?: {
      domain: string;
      name: string;
    } | null;
  } | null;
}

// ============ COLUMN CONFIGURATION ============

/**
 * Date filter operator for date columns
 */
export type DateFilterOperator = 'before' | 'after' | 'on' | 'range' | 'empty';

/**
 * Number filter operator for number columns
 */
export type NumberFilterOperator = 'gt' | 'lt' | 'eq' | 'empty';

/**
 * Filter configuration for a column
 */
export interface ColumnFilter {
  // For enum/select columns
  selected?: (string | null)[]; // Selected values to include (null represents no value)
  excluded?: (string | null)[]; // Values to exclude (null represents no value)
  // For text columns
  searchText?: string;
  // For number columns
  min?: string;
  max?: string;
  numberValue?: string; // The number value to compare against
  numberOperator?: NumberFilterOperator; // Operator for number filtering (gt/lt/eq/empty)
  // For date columns
  dateFrom?: string; // ISO date string
  dateTo?: string; // ISO date string
  dateOperator?: DateFilterOperator; // Operator for date filtering (before/after/on)
  // For binary choice columns (e.g., tasks, boolean fields)
  binaryChoice?: 'true' | 'false'; // Selected binary value
  /** Which dimension to filter on for custom field columns with signal (default: 'value') */
  filterDimension?: 'value' | 'signal';
  // Relative date support — when true, offsets are resolved to absolute dates at query time
  dateRelative?: boolean;
  dateFromDaysOffset?: number; // Days from today (0 = today, -7 = 7 days ago, +3 = 3 days from now)
  dateToDaysOffset?: number; // Same for dateTo (range operator)
  // For history column — filter by any event occurrence of specific types (used in Event Date section)
  eventOccurrenceType?: string[]; // Filter conversations that have any event of these types (combined with eventDate)
  // For history column — filter by the most recent event date (separate from eventDate)
  latestEventFrom?: string; // ISO date string
  latestEventTo?: string; // ISO date string (range operator)
  latestEventOperator?: DateFilterOperator;
  latestEventRelative?: boolean;
  latestEventFromDaysOffset?: number;
  latestEventToDaysOffset?: number;
  latestEventType?: string[]; // Filter conversations where the most recent event is one of these types
  // "Last [event type]" filter — MAX(occurred_at WHERE type IN [...]) semantics, unlike latestEvent which is EXISTS-based
  lastEventByTypeTypes?: string[];
  lastEventByTypeFrom?: string;
  lastEventByTypeTo?: string;
  lastEventByTypeDateOperator?: DateFilterOperator;
  lastEventByTypeRelative?: boolean;
  lastEventByTypeDaysOffset?: number;
  lastEventByTypeDaysOffsetTo?: number;
}

/**
 * Sort configuration for a column
 */
export interface ColumnSort {
  active: boolean; // Is this column being sorted?
  direction: 'asc' | 'desc'; // Sort direction
  priority: number; // Sort priority (0 = highest priority, for multi-column sort)
  order?: (string | null)[]; // For enum columns: custom order of enum values (replaces customOrder)
  /** For custom field columns with signal: which dimension to sort by (default: 'value') */
  sortDimension?: 'value' | 'signal';
}

/**
 * Coerce a persisted `sortDimension` into a currently-valid value.
 *
 * The signal dimension was historically named 'strength' (and 'completeness');
 * those were renamed to 'signal'. Persisted stores (localStorage columns,
 * server-side presets) may still hold the old names, which the backend rejects.
 * Map legacy names to 'signal', pass through valid values, and drop anything
 * unrecognized so the caller's `'value'` default applies.
 */
export function normalizeSortDimension(raw: unknown): 'value' | 'signal' | undefined {
  if (raw === 'value' || raw === 'signal') return raw;
  if (raw === 'strength' || raw === 'completeness') return 'signal';
  return undefined;
}

/**
 * Push every active sort down one priority slot so a freshly-activated sort can
 * take priority 0. Mutates in place (immer draft).
 */
function demoteActiveSorts(columns: Record<string, ColumnConfigState>, exceptColumnId: string) {
  Object.entries(columns).forEach(([id, column]) => {
    if (id === exceptColumnId) return;
    if (column.sort?.active) column.sort.priority += 1;
  });
}

/**
 * Minimal column config stored in Zustand state (without base metadata)
 * @internal - Used internally by the store, components should use CRMColumn
 */
export interface ColumnConfigState {
  id: string;
  width: number;
  order: number;
  visible: boolean;
  minWidth: number;
  sort?: ColumnSort;
  filter?: ColumnFilter;
}

// ============ GLOBAL FILTERS (non-column filters) ============
// Only global search remains here - all other filters are in column.filter

/**
 * Date filter for a specific date column
 */
export interface DateFilter {
  date: string; // ISO date string or 'today'
  operator: DateFilterOperator;
  dateTo?: string; // ISO date string — upper bound for 'range' operator
}

/**
 * Number filter for a specific number column
 */
export interface NumberFilter {
  value: string; // The number value as string
  operator: NumberFilterOperator;
}

/**
 * CRM Filters - computed from column filters + search query
 *
 * Note: All filtering is CLIENT-SIDE except search.
 * The backend query does NOT use these filters (except search).
 *
 * IMPORTANT: Type filtering is NOT in this interface.
 * - selectedAopId (in CRMState) controls which AOP's conversations are shown
 * - useCRMConversations filters directly by selectedAopId (conversation.aopId === selectedAopId)
 * - This avoids translating between aopId and aopName
 * - UI components derive the AOP name for display from AOPs data when needed
 */
export interface CRMFilters {
  search?: string; // Global search across all text fields (only filter sent to backend)
  // Column-based filters (derived from column.filter via getFilters())
  // NOTE: 'type' is NOT included here - filtering by AOP is via selectedAopId directly
  status?: (string | null)[];
  excludedStatus?: (string | null)[];
  priority?: (string | null)[];
  excludedPriority?: (string | null)[];
  // Date filters with operator support
  lastContact?: DateFilter;
  nextStepDate?: DateFilter;
  lastMeeting?: DateFilter;
  /** `types` alone is a valid filter ("has any event of these types"); the date bound is optional. */
  lastEventByType?: Partial<DateFilter> & { types?: string[] };
  // Number filters with operator support
  dealValue?: NumberFilter;
  // Legacy number filters (for backward compatibility)
  dealValueMin?: string;
  dealValueMax?: string;
  // CRM synced filter
  crmSynced?: boolean; // true = only synced, false = only not synced, undefined = all
  // Calendar filter
  hasFutureCalendar?: boolean; // true = has upcoming meeting, false = no upcoming meetings, undefined = all
  // Tasks filter
  hasTodoTasks?: boolean; // true = has todo tasks, false = no todo tasks, undefined = all
  taskTypes?: ('response' | 'follow-up' | 'post-meeting' | 'pre-meeting' | 'reactivation' | 'manual')[]; // Filter by task type
  taskDueDate?: DateFilter; // Filter by task due date
  // Current action filter - tasks with due date constraint (tomorrow or before)
  currentActionHasTasks?: boolean; // true = has tasks due before cutoff, false = no tasks due before cutoff
  currentActionDueBefore?: string; // ISO date string for the due date cutoff
  currentActionTaskTypes?: ('response' | 'follow-up' | 'post-meeting' | 'pre-meeting' | 'reactivation' | 'manual')[]; // Filter by task type within currentAction
  // Event filter (for history/timeline column)
  eventTypes?: string[]; // Filter by event type+direction (e.g., 'meeting', 'email_outbound', 'email_inbound')
  excludedEventTypes?: string[]; // Excluded event types
  eventDate?: DateFilter; // Date constraint for event filtering (before/after/on/range)
  latestEventDate?: DateFilter; // Filter by the date of the most recent event
  latestEventType?: string[]; // Filter by the type of the most recent event (e.g., 'email_outbound')
}

// ============ STATE ============

export interface CRMState {
  // Selected AOP IDs (controls which columns are available AND which conversations are shown)
  // This is the SINGLE SOURCE OF TRUTH for both schema and type filtering
  // - null = first load, auto-select default AOP
  // - [] = show all AOPs (no custom fields)
  // - ['uuid'] = single AOP (show custom fields)
  // - ['uuid1', 'uuid2'] = multiple AOPs (no custom fields)
  // - [null] or [..., null] = include conversations with no AOP assigned
  selectedAopIds: (string | null)[] | null;

  // Column configuration (widths, order, visibility, sort, filter)
  columns: Record<string, ColumnConfigState>;

  // Default widths for column types
  defaultWidths: {
    textarea: number;
    select: number;
    number: number;
    default: number;
  };

  // Active column preset ID (persisted). Used by useCRMConfiguration to apply
  // correct visibility when new columns are added (e.g., when AOP data loads).
  // Set by HomeView on tab switch and by PresetNavigationBar on user click.
  activePresetId: string | null;

  // Global search query (non-column filter)
  searchQuery: string | undefined;
}

const initialCRMState: CRMState = {
  selectedAopIds: null,
  columns: {},
  defaultWidths: {
    textarea: 300,
    select: 100,
    number: 80,
    default: 150,
  },
  activePresetId: null,
  searchQuery: undefined,
};

// ============ ACTIONS ============

export interface CRMActions {
  // AOP Selection (single source of truth for schema AND type filtering)
  // The AOP name for display is derived from AOPs data, not stored here
  setSelectedAopIds: (aopIds: (string | null)[] | null) => void;
  getSelectedAopIds: () => (string | null)[] | null;
  toggleSelectedAopId: (aopId: string | null) => void;
  addSelectedAopId: (aopId: string | null) => void;
  removeSelectedAopId: (aopId: string | null) => void;
  clearSelectedAopIds: () => void;

  // Column Management
  initializeColumns: (
    baseColumnIds: string[],
    aopColumnIds: string[],
    isTextareaColumn: (id: string) => boolean,
    getColumnType?: (id: string) => ColumnType | undefined,
    initialVisibility?: Record<string, boolean>,
  ) => void;
  setActivePresetId: (presetId: string | null) => void;
  setColumnWidth: (columnId: string, width: number) => void;
  reorderColumns: (newOrder: string[]) => void;
  toggleColumnVisibility: (columnId: string) => void;
  setColumnVisibilities: (visibilities: Record<string, boolean>) => void;
  resetColumnConfig: (isTextareaColumn: (id: string) => boolean) => void;
  getVisibleColumns: () => ColumnConfigState[];
  getAllColumns: () => ColumnConfigState[];

  // Column Sort Management (per-column sorting)
  setColumnSort: (columnId: string, sort: ColumnSort | undefined) => void;
  /** Activate a sort as the #1 priority, demoting every other active sort. */
  setColumnSortAtTopPriority: (columnId: string, sort: Omit<ColumnSort, 'priority'>) => void;
  toggleColumnSort: (columnId: string) => void;
  clearColumnSort: (columnId: string) => void;
  clearAllSorts: () => void;
  setColumnSortCustomOrder: (columnId: string, customOrder: (string | null)[]) => void;

  // Column Filter Management (per-column filtering)
  setColumnFilter: (columnId: string, filter: ColumnFilter | undefined) => void;
  clearColumnFilter: (columnId: string) => void;
  clearAllColumnFilters: () => void;

  // Global Search
  setSearchQuery: (query: string | undefined) => void;
  getSearchQuery: () => string | undefined;

  // Computed Filters (constructs CRMFilters from columns + search)
  getFilters: () => CRMFilters;

  // Reset
  resetCRMState: () => void;
}

export interface CRMSlice extends CRMState, CRMActions {}

// ============ SLICE CREATOR ============

export const createCRMSlice: StateCreator<
  CedarStore,
  [['zustand/immer', never], ['zustand/devtools', never]],
  [],
  CRMSlice
> = (set, get) => ({
  ...initialCRMState,

  // ============================================
  // AOP Selection (Multi-select)
  // ============================================

  setSelectedAopIds: (aopIds) =>
    set(
      (state) => {
        state.selectedAopIds = aopIds;
        // Clear the type column filter since type is controlled by selectedAopIds
        if (state.columns['type']?.filter) {
          state.columns['type'].filter = undefined;
        }
      },
      false,
      'crm/setSelectedAopIds',
    ),

  getSelectedAopIds: () => {
    return get().selectedAopIds;
  },

  toggleSelectedAopId: (aopId) =>
    set(
      (state) => {
        // Initialize as empty array if null (first load)
        if (state.selectedAopIds === null) {
          state.selectedAopIds = [aopId];
          return;
        }

        const index = state.selectedAopIds.indexOf(aopId);
        if (index >= 0) {
          // Remove the AOP (or null)
          state.selectedAopIds = state.selectedAopIds.filter((id) => id !== aopId);
        } else {
          // Add the AOP (or null for "no AOP" option)
          state.selectedAopIds = [...state.selectedAopIds, aopId];
        }
      },
      false,
      'crm/toggleSelectedAopId',
    ),

  addSelectedAopId: (aopId) =>
    set(
      (state) => {
        if (state.selectedAopIds === null) {
          state.selectedAopIds = [aopId];
          return;
        }
        if (!state.selectedAopIds.includes(aopId)) {
          state.selectedAopIds = [...state.selectedAopIds, aopId];
        }
      },
      false,
      'crm/addSelectedAopId',
    ),

  removeSelectedAopId: (aopId) =>
    set(
      (state) => {
        if (state.selectedAopIds === null) return;
        state.selectedAopIds = state.selectedAopIds.filter((id) => id !== aopId);
      },
      false,
      'crm/removeSelectedAopId',
    ),

  clearSelectedAopIds: () =>
    set(
      (state) => {
        state.selectedAopIds = [];
      },
      false,
      'crm/clearSelectedAopIds',
    ),

  // ============================================
  // Column Management
  // ============================================

  initializeColumns: (
    baseColumnIds,
    aopColumnIds,
    isTextareaColumn,
    getColumnType,
    initialVisibility,
  ) =>
    set(
      (state) => {
        const allColumnIds = [...baseColumnIds, ...aopColumnIds];
        const validColumnIds = new Set(allColumnIds);
        // isFirstInit: true when no columns have been set up yet in this session.
        // Replaces the removed `columnsInitialized` boolean flag.
        const isFirstInit = Object.keys(state.columns).length === 0;

        // CLEANUP: Remove old wm_* columns that are not in the current AOP
        // This ensures stale custom fields from previous AOPs don't persist
        Object.keys(state.columns).forEach((columnId) => {
          if (columnId.startsWith('wm_') && !validColumnIds.has(columnId)) {
            delete state.columns[columnId];
          }
        });

        // Initialize columns that don't exist yet
        // This preserves user customizations (widths, order) for existing columns
        allColumnIds.forEach((columnId, index) => {
          if (!state.columns[columnId]) {
            const columnType = getColumnType?.(columnId);

            // Determine default width based on column type
            let defaultWidth = state.defaultWidths.default;
            let minWidth = 100;

            if (isTextareaColumn(columnId)) {
              defaultWidth = state.defaultWidths.textarea;
              minWidth = 200;
            } else if (columnType === 'select') {
              defaultWidth = state.defaultWidths.select;
              minWidth = 100;
            } else if (columnType === 'number' || columnType === 'currency') {
              defaultWidth = state.defaultWidths.number;
              minWidth = 80;
            } else if (columnType === 'boolean') {
              // Compact width for checkbox
              defaultWidth = 80;
              minWidth = 60;
            } else if (columnType === 'date') {
              // Medium width for dates
              defaultWidth = 150;
              minWidth = 120;
            } else if (columnType === 'email' || columnType === 'url') {
              // Wider for emails/URLs
              defaultWidth = 200;
              minWidth = 150;
            } else if (columnType === 'phone') {
              // Medium width for phone numbers
              defaultWidth = 140;
              minWidth = 120;
            } else if (columnType === 'activity-overview') {
              // Wide width for activity timeline (3x default)
              defaultWidth = 450;
              minWidth = 300;
            }

            // Determine initial visibility:
            // 1. If initialVisibility is provided (from active preset), use it for any init
            // 2. On first init without preset, default to true
            // 3. On subsequent inits without preset, default to false (hidden until preset applied)
            let visible: boolean;
            if (initialVisibility && columnId in initialVisibility) {
              visible = initialVisibility[columnId];
            } else if (isFirstInit) {
              visible = true;
            } else {
              // Late-arriving column with no active preset — hide by default
              visible = false;
            }

            state.columns[columnId] = {
              id: columnId,
              width: defaultWidth,
              order: index,
              visible,
              minWidth,
            };
          }
        });

        // If there are existing columns, update their order to match new column list
        // (in case new AOP columns were added)
        const existingIds = Object.keys(state.columns);
        const newIds = allColumnIds;

        // Find columns that were added
        const addedIds = newIds.filter((id) => !existingIds.includes(id));

        // Update order for added columns (append to end)
        if (addedIds.length > 0) {
          const maxOrder = Math.max(...Object.values(state.columns).map((c) => c.order), -1);
          addedIds.forEach((id, idx) => {
            if (state.columns[id]) {
              state.columns[id].order = maxOrder + idx + 1;
            }
          });
        }

      },
      false,
      'crm/initializeColumns',
    ),

  setActivePresetId: (presetId) =>
    set(
      (state) => {
        state.activePresetId = presetId;
      },
      false,
      'crm/setActivePresetId',
    ),

  setColumnWidth: (columnId, width) =>
    set(
      (state) => {
        if (state.columns[columnId]) {
          // Ensure width is at least minWidth and round to integer
          state.columns[columnId].width = Math.max(
            Math.round(width),
            state.columns[columnId].minWidth,
          );
        }
      },
      false,
      'crm/setColumnWidth',
    ),

  reorderColumns: (newOrder) =>
    set(
      (state) => {
        // Ensure primaryCompany is always first (order 0)
        // Filter it out from newOrder if present
        const reorderedColumns = newOrder.filter((id) => id !== 'primaryCompany');

        // Set primaryCompany to order 0
        if (state.columns['primaryCompany']) {
          state.columns['primaryCompany'].order = 0;
        }

        // Update order for all other columns (starting from 1)
        reorderedColumns.forEach((columnId, index) => {
          if (state.columns[columnId]) {
            state.columns[columnId].order = index + 1;
          }
        });
      },
      false,
      'crm/reorderColumns',
    ),

  toggleColumnVisibility: (columnId) =>
    set(
      (state) => {
        if (state.columns[columnId]) {
          state.columns[columnId].visible = !state.columns[columnId].visible;
        }
      },
      false,
      'crm/toggleColumnVisibility',
    ),

  setColumnVisibilities: (visibilities) =>
    set(
      (state) => {
        Object.entries(visibilities).forEach(([columnId, visible]) => {
          if (state.columns[columnId]) {
            state.columns[columnId].visible = visible;
          }
        });
      },
      false,
      'crm/setColumnVisibilities',
    ),

  resetColumnConfig: (isTextareaColumn) =>
    set(
      (state) => {
        // Reset all columns to default widths and visibility
        Object.values(state.columns).forEach((col) => {
          const defaultWidth = isTextareaColumn(col.id)
            ? state.defaultWidths.textarea
            : state.defaultWidths.default;
          col.width = defaultWidth;
          col.visible = true;
        });
      },
      false,
      'crm/resetColumnConfig',
    ),

  getVisibleColumns: () => {
    const state = get();
    const columns = Object.values(state.columns)
      .filter((col) => col.visible)
      .sort((a, b) => a.order - b.order);

    // Ensure primaryCompany is always first
    const companyIndex = columns.findIndex((col) => col.id === 'primaryCompany');
    if (companyIndex > 0) {
      const [company] = columns.splice(companyIndex, 1);
      columns.unshift(company);
    }

    return columns;
  },

  getAllColumns: () => {
    const state = get();
    const columns = Object.values(state.columns).sort((a, b) => a.order - b.order);

    // Ensure primaryCompany is always first
    const companyIndex = columns.findIndex((col) => col.id === 'primaryCompany');
    if (companyIndex > 0) {
      const [company] = columns.splice(companyIndex, 1);
      columns.unshift(company);
    }

    return columns;
  },

  // ============================================
  // Column Sort Management (NEW)
  // ============================================

  setColumnSort: (columnId, sort) =>
    set(
      (state) => {
        if (state.columns[columnId]) {
          state.columns[columnId].sort = sort;
        }
        // Sync sort state between status and statusBadge since they represent the same field
        if (columnId === 'status' && state.columns['statusBadge']) {
          state.columns['statusBadge'].sort = sort;
        } else if (columnId === 'statusBadge' && state.columns['status']) {
          state.columns['status'].sort = sort;
        }
      },
      false,
      'crm/setColumnSort',
    ),

  setColumnSortAtTopPriority: (columnId, sort) =>
    set(
      (state) => {
        if (!state.columns[columnId]) return;
        demoteActiveSorts(state.columns, columnId);
        const next: ColumnSort = { ...sort, priority: 0 };
        state.columns[columnId].sort = next;
        // Sync sort state between status and statusBadge since they represent the same field
        if (columnId === 'status' && state.columns['statusBadge']) {
          state.columns['statusBadge'].sort = next;
        } else if (columnId === 'statusBadge' && state.columns['status']) {
          state.columns['status'].sort = next;
        }
      },
      false,
      'crm/setColumnSortAtTopPriority',
    ),

  toggleColumnSort: (columnId) =>
    set(
      (state) => {
        const column = state.columns[columnId];
        if (!column) return;

        if (!column.sort || !column.sort.active) {
          // Not currently sorted — becomes the #1 sort, everything else shifts down
          demoteActiveSorts(state.columns, columnId);
          column.sort = {
            active: true,
            direction: 'asc',
            priority: 0,
            order: column.sort?.order,
          };
        } else if (column.sort.direction === 'asc') {
          // Currently ascending - switch to descending
          column.sort.direction = 'desc';
        } else {
          // Currently descending - disable sorting (set to undefined)
          const oldPriority = column.sort.priority;
          const savedOrder = column.sort.order; // Preserve custom order for next time

          // Clear the sort but keep the order for next activation
          column.sort = savedOrder
            ? { active: false, direction: 'asc', priority: 0, order: savedOrder }
            : undefined;

          // Adjust priorities of other sorted columns
          Object.values(state.columns).forEach((c) => {
            if (c.sort?.active && c.sort.priority > oldPriority) {
              c.sort.priority -= 1;
            }
          });
        }

        // Sync sort state between status and statusBadge since they represent the same field
        if (columnId === 'status' && state.columns['statusBadge']) {
          state.columns['statusBadge'].sort = column.sort;
        } else if (columnId === 'statusBadge' && state.columns['status']) {
          state.columns['status'].sort = column.sort;
        }
      },
      false,
      'crm/toggleColumnSort',
    ),

  clearColumnSort: (columnId) =>
    set(
      (state) => {
        const column = state.columns[columnId];
        if (!column?.sort) return;

        const oldPriority = column.sort.priority;
        column.sort = {
          active: false,
          direction: 'asc',
          priority: 0,
          order: column.sort.order,
        };

        // Adjust priorities of other sorted columns
        Object.values(state.columns).forEach((c) => {
          if (c.sort?.active && c.sort.priority > oldPriority) {
            c.sort.priority -= 1;
          }
        });

        // Sync sort state between status and statusBadge since they represent the same field
        if (columnId === 'status' && state.columns['statusBadge']) {
          state.columns['statusBadge'].sort = column.sort;
        } else if (columnId === 'statusBadge' && state.columns['status']) {
          state.columns['status'].sort = column.sort;
        }
      },
      false,
      'crm/clearColumnSort',
    ),

  clearAllSorts: () =>
    set(
      (state) => {
        Object.values(state.columns).forEach((col) => {
          if (col.sort) {
            col.sort = {
              active: false,
              direction: 'asc',
              priority: 0,
              order: col.sort.order,
            };
          }
        });
      },
      false,
      'crm/clearAllSorts',
    ),

  setColumnSortCustomOrder: (columnId, order) =>
    set(
      (state) => {
        const column = state.columns[columnId];
        if (!column) return;

        if (!column.sort) {
          column.sort = {
            active: false,
            direction: 'asc',
            priority: 0,
            order,
          };
        } else {
          column.sort.order = order;
        }

        // Sync sort state between status and statusBadge since they represent the same field
        if (columnId === 'status' && state.columns['statusBadge']) {
          state.columns['statusBadge'].sort = column.sort;
        } else if (columnId === 'statusBadge' && state.columns['status']) {
          state.columns['status'].sort = column.sort;
        }
      },
      false,
      'crm/setColumnSortCustomOrder',
    ),

  // ============================================
  // Column Filter Management
  // ============================================

  setColumnFilter: (columnId, filter) =>
    set(
      (state) => {
        if (state.columns[columnId]) {
          state.columns[columnId].filter = filter;
        }
        // Sync filter state between status and statusBadge since they represent the same field
        if (columnId === 'status' && state.columns['statusBadge']) {
          state.columns['statusBadge'].filter = filter;
        } else if (columnId === 'statusBadge' && state.columns['status']) {
          state.columns['status'].filter = filter;
        }
      },
      false,
      'crm/setColumnFilter',
    ),

  clearColumnFilter: (columnId) =>
    set(
      (state) => {
        if (state.columns[columnId]) {
          state.columns[columnId].filter = undefined;
        }
        // Sync filter state between status and statusBadge since they represent the same field
        if (columnId === 'status' && state.columns['statusBadge']) {
          state.columns['statusBadge'].filter = undefined;
        } else if (columnId === 'statusBadge' && state.columns['status']) {
          state.columns['status'].filter = undefined;
        }
      },
      false,
      'crm/clearColumnFilter',
    ),

  clearAllColumnFilters: () =>
    set(
      (state) => {
        Object.values(state.columns).forEach((col) => {
          col.filter = undefined;
        });
      },
      false,
      'crm/clearAllColumnFilters',
    ),

  // ============================================
  // Global Search
  // ============================================

  setSearchQuery: (query) =>
    set(
      (state) => {
        state.searchQuery = query;
      },
      false,
      'crm/setSearchQuery',
    ),

  getSearchQuery: () => {
    return get().searchQuery;
  },

  // ============================================
  // Computed Filters
  // ============================================

  getFilters: () => {
    const state = get();
    const filters: CRMFilters = {};

    // Add global search
    if (state.searchQuery) {
      filters.search = state.searchQuery;
    }

    // NOTE: Type filtering is NOT included here.
    // Type filtering is handled directly via selectedAopId in useCRMConversations.
    // This avoids the need to translate between aopId and aopName.

    // Extract filters from columns (type column filter is ignored - controlled by selectedAopId)
    Object.entries(state.columns).forEach(([columnId, column]) => {
      if (!column.filter) return;
      if (columnId === 'type') return; // Type is controlled by selectedAopId, not column filter

      const filter = column.filter;

      switch (columnId) {
        case 'status':
          if (filter.selected) filters.status = filter.selected;
          if (filter.excluded) filters.excludedStatus = filter.excluded;
          break;
        case 'priority':
          if (filter.selected) filters.priority = filter.selected;
          if (filter.excluded) filters.excludedPriority = filter.excluded;
          break;
        case 'lastContactedAt': {
          if (filter.dateOperator === 'empty') {
            filters.lastContact = { date: '', operator: 'empty' };
          } else if (filter.dateOperator) {
            const lcFrom = filter.dateRelative && filter.dateFromDaysOffset !== undefined
              ? resolveOffsetToISO(filter.dateFromDaysOffset)
              : filter.dateFrom;
            const lcTo = filter.dateRelative && filter.dateToDaysOffset !== undefined
              ? resolveOffsetToISO(filter.dateToDaysOffset)
              : filter.dateTo;
            if (lcFrom) {
              filters.lastContact = { date: lcFrom, operator: filter.dateOperator, dateTo: lcTo };
            }
          }
          break;
        }
        case 'nextSteps': {
          if (filter.dateOperator === 'empty') {
            filters.nextStepDate = { date: '', operator: 'empty' };
          } else if (filter.dateOperator) {
            const nsFrom = filter.dateRelative && filter.dateFromDaysOffset !== undefined
              ? resolveOffsetToISO(filter.dateFromDaysOffset)
              : filter.dateFrom;
            const nsTo = filter.dateRelative && filter.dateToDaysOffset !== undefined
              ? resolveOffsetToISO(filter.dateToDaysOffset)
              : filter.dateTo;
            if (nsFrom) {
              filters.nextStepDate = { date: nsFrom, operator: filter.dateOperator, dateTo: nsTo };
            }
          }
          break;
        }
        case 'lastMeetingTime': {
          if (filter.dateOperator === 'empty') {
            filters.lastMeeting = { date: '', operator: 'empty' };
          } else if (filter.dateOperator) {
            const lmFrom = filter.dateRelative && filter.dateFromDaysOffset !== undefined
              ? resolveOffsetToISO(filter.dateFromDaysOffset)
              : filter.dateFrom;
            const lmTo = filter.dateRelative && filter.dateToDaysOffset !== undefined
              ? resolveOffsetToISO(filter.dateToDaysOffset)
              : filter.dateTo;
            if (lmFrom) {
              filters.lastMeeting = { date: lmFrom, operator: filter.dateOperator, dateTo: lmTo };
            }
          }
          break;
        }
        case 'dealValue':
          // Number filter with operator support
          if (filter.numberOperator === 'empty') {
            // Special case: "No value set" operator doesn't need a number value
            filters.dealValue = { value: '', operator: 'empty' };
          } else if (filter.numberValue && filter.numberOperator) {
            filters.dealValue = { value: filter.numberValue, operator: filter.numberOperator };
          }
          // Legacy min/max support (for backward compatibility)
          if (filter.min) filters.dealValueMin = filter.min;
          if (filter.max) filters.dealValueMax = filter.max;
          break;
        case 'crmSynced':
          // Handle CRM synced filter - 'synced' means true, 'not_synced' means false
          if (filter.selected && filter.selected.length > 0) {
            if (
              filter.selected.includes(CRM_SYNC_STATUS.SYNCED) &&
              !filter.selected.includes(CRM_SYNC_STATUS.NOT_SYNCED)
            ) {
              filters.crmSynced = true;
            } else if (
              filter.selected.includes(CRM_SYNC_STATUS.NOT_SYNCED) &&
              !filter.selected.includes(CRM_SYNC_STATUS.SYNCED)
            ) {
              filters.crmSynced = false;
            }
            // If both are selected or neither, crmSynced stays undefined (show all)
          }
          break;
        case 'hasFutureCalendar':
          if (filter.selected && filter.selected.length > 0) {
            const hasTrue = filter.selected.includes('true');
            const hasFalse = filter.selected.includes('false');
            if (hasTrue && !hasFalse) filters.hasFutureCalendar = true;
            else if (hasFalse && !hasTrue) filters.hasFutureCalendar = false;
          }
          break;
        case 'currentTasks': {
          if (filter.binaryChoice) {
            filters.hasTodoTasks = filter.binaryChoice === 'true';
          }
          if (filter.selected && filter.selected.length > 0) {
            filters.taskTypes = filter.selected.filter((s): s is string => s !== null) as CRMFilters['taskTypes'];
          }
          if (filter.dateOperator === 'empty') {
            filters.taskDueDate = { date: '', operator: 'empty' };
          } else if (filter.dateOperator) {
            const tdFrom = filter.dateRelative && filter.dateFromDaysOffset !== undefined
              ? resolveOffsetToISO(filter.dateFromDaysOffset)
              : filter.dateFrom;
            const tdTo = filter.dateRelative && filter.dateToDaysOffset !== undefined
              ? resolveOffsetToISO(filter.dateToDaysOffset)
              : filter.dateTo;
            if (tdFrom) {
              filters.taskDueDate = { date: tdFrom, operator: filter.dateOperator, dateTo: tdTo };
            }
          }
          break;
        }
        case 'currentAction': {
          // Handle current action filter - tasks with due date constraint + optional task type.
          // binaryChoice 'false' = "nothing due" rows only, so task types are meaningless there.
          const hasTypeFilter = filter.selected && filter.selected.length > 0;
          // A `before` cutoff also means "has something due" (the Deal Actions tab shape).
          const isDueBefore = filter.dateOperator === 'before';
          const caFrom = filter.dateRelative && filter.dateFromDaysOffset !== undefined
            ? resolveOffsetToISO(filter.dateFromDaysOffset)
            : filter.dateFrom;
          const hasTasks = filter.binaryChoice
            ? filter.binaryChoice === 'true'
            : Boolean(hasTypeFilter || isDueBefore);
          if (hasTasks || filter.binaryChoice === 'false') {
            filters.currentActionHasTasks = hasTasks;
            filters.currentActionDueBefore =
              isDueBefore && caFrom ? caFrom : currentActionDueCutoff();
            if (hasTasks && hasTypeFilter) {
              filters.currentActionTaskTypes = filter.selected!.filter(
                (s): s is 'response' | 'follow-up' | 'post-meeting' | 'pre-meeting' | 'reactivation' | 'manual' => s !== null,
              );
            }
          }
          break;
        }
        case 'history': {
          const baseSelected = filter.selected ? (filter.selected as string[]) : [];
          const occurrenceTypes = filter.eventOccurrenceType ?? [];
          const allEventTypes = [...new Set([...baseSelected, ...occurrenceTypes])];
          if (allEventTypes.length > 0) filters.eventTypes = allEventTypes;
          if (filter.excluded) filters.excludedEventTypes = filter.excluded as string[];
          if (filter.dateOperator && filter.dateOperator !== 'empty') {
            const evFrom = filter.dateRelative && filter.dateFromDaysOffset !== undefined
              ? resolveOffsetToISO(filter.dateFromDaysOffset)
              : filter.dateFrom;
            const evTo = filter.dateOperator === 'range'
              ? (filter.dateRelative && filter.dateToDaysOffset !== undefined
                ? resolveOffsetToISO(filter.dateToDaysOffset)
                : filter.dateTo)
              : undefined;
            if (evFrom) {
              filters.eventDate = { date: evFrom, operator: filter.dateOperator, dateTo: evTo };
            }
          }
          if (filter.latestEventType && filter.latestEventType.length > 0) {
            filters.latestEventType = filter.latestEventType;
          }
          if (filter.latestEventOperator && filter.latestEventOperator !== 'empty') {
            const leFrom = filter.latestEventRelative && filter.latestEventFromDaysOffset !== undefined
              ? resolveOffsetToISO(filter.latestEventFromDaysOffset)
              : filter.latestEventFrom;
            const leTo = filter.latestEventOperator === 'range'
              ? (filter.latestEventRelative && filter.latestEventToDaysOffset !== undefined
                ? resolveOffsetToISO(filter.latestEventToDaysOffset)
                : filter.latestEventTo)
              : undefined;
            if (leFrom) {
              filters.latestEventDate = { date: leFrom, operator: filter.latestEventOperator, dateTo: leTo };
            }
          }
          break;
        }
      }
    });

    return filters;
  },

  // ============================================
  // Reset
  // ============================================

  resetCRMState: () =>
    set(
      (state) => {
        state.selectedAopIds = initialCRMState.selectedAopIds;
        state.searchQuery = initialCRMState.searchQuery;
        // Don't reset columns - preserve user customizations
      },
      false,
      'crm/reset',
    ),
});

// ============ SELECTORS ============

export const selectSearchQuery = (state: CRMState) => state.searchQuery;
export const selectSelectedAopIds = (state: CRMState) => state.selectedAopIds;
export const selectColumns = (state: CRMState) => state.columns;

/**
 * Helper to check if custom fields should be shown.
 * Custom fields are only shown when exactly one AOP is selected.
 */
export const selectShouldShowCustomFields = (state: CRMState) => {
  return state.selectedAopIds !== null && state.selectedAopIds.length === 1;
};

/**
 * Helper to check if "all AOPs" mode is active.
 * This is true when selectedAopIds is an empty array.
 */
export const selectIsAllAopsMode = (state: CRMState) => {
  return state.selectedAopIds !== null && state.selectedAopIds.length === 0;
};