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