use-crm-conversations.ts38.2 KBView on GitHub /**
* useCRMConversations Hook
*
* Handles infinite scroll for CRM conversations with cursor-based pagination.
* Similar to useThreads, but for CRM conversations.
*
* Features:
* - Uses useInfiniteQuery for cursor-based pagination
* - Syncs to Zustand stores (conversationsSlice + crmSlice)
* - Supports client-side multi-select filtering
* - Auto-fetches more when filtered down to few results
* - Returns loadMore function for manual triggering
*/
import type { CanvasFilterSortConfiguration } from '@/modules/cedar-os/src/store/messages/MessageTypes';
import type { ConversationSummary } from '@/modules/conversations';
import {
computeFiltersFromConfig,
computeBackendSortFromConfig,
getClientSortColumnsFromConfig,
type CustomFieldFilterEntry,
} from '../utils/compute-canvas-filters';
import { useTRPCClient } from '@/providers/query-provider';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useAOPs } from '@/modules/aop/hooks/use-aops';
import type { HydratedConversation } from '../types';
import { useMemo, useEffect, useRef, useState } from 'react';
import { getCustomFieldsRevision } from '@/modules/crm/utils/custom-fields-revision';
import { normalizeSortDimension } from '@/modules/crm/store/crmSlice';
import { useCedarStore } from '@/modules/store';
/**
* Optional canvas viewConfig overrides that bypass the global CRMSlice.
* When provided, filters/sorts/selectedAopIds come from viewConfig instead of Zustand.
*/
export interface CRMConversationsOverrides {
selectedAopIds?: (string | null)[] | null;
filterSortConfiguration?: CanvasFilterSortConfiguration;
searchQuery?: string;
}
export function useCRMConversations(
pinnedConversationIds?: string[],
viewConfigOverrides?: CRMConversationsOverrides,
) {
const trpcClient = useTRPCClient();
// Always read from store (hooks called unconditionally)
const searchQueryFromStore = useCedarStore((state) => state.searchQuery);
const getFilters = useCedarStore((state) => state.getFilters);
const columns = useCedarStore((state) => state.columns);
const setConversations = useCedarStore((state) => state.setConversations);
const setCurrentConversationList = useCedarStore((state) => state.setCurrentConversationList);
const previousSignatureRef = useRef<string | null>(null);
const { data: aopsData } = useAOPs();
const aops = aopsData?.aops;
const aopsById = useCedarStore((state) => state.aopsById);
// Get selectedAopIds — from viewConfig override or store
const selectedAopIdsFromStore = useCedarStore((state) => state.selectedAopIds);
const isOverrideMode = viewConfigOverrides !== undefined;
const selectedAopIds = isOverrideMode
? viewConfigOverrides.selectedAopIds
: selectedAopIdsFromStore;
// Resolve customFieldDefinitions from the single selected AOP (for custom field sort/filter)
const customFieldDefinitions = useMemo(() => {
const realIds = (selectedAopIds ?? []).filter((id): id is string => id !== null);
if (realIds.length !== 1) return undefined;
return aopsById[realIds[0]]?.customFieldDefinitions ?? undefined;
}, [selectedAopIds, aopsById]);
// ── Date epoch for relative filter cache invalidation ─────────────
// When any filter uses dateRelative, we need to re-resolve offsets when the day rolls over.
// A 60-second interval checks for day changes and triggers re-render + cache invalidation.
const hasRelativeFilters = useMemo(() => {
if (isOverrideMode) {
return Object.values(viewConfigOverrides?.filterSortConfiguration ?? {}).some(
(col) => col.filter?.dateRelative,
);
}
return Object.values(columns).some((col) => col.filter?.dateRelative);
}, [isOverrideMode, viewConfigOverrides?.filterSortConfiguration, columns]);
const [dateEpoch, setDateEpoch] = useState(() => new Date().toDateString());
useEffect(() => {
if (!hasRelativeFilters) return;
const interval = setInterval(() => {
const today = new Date().toDateString();
setDateEpoch((prev) => (prev !== today ? today : prev));
}, 60_000);
return () => clearInterval(interval);
}, [hasRelativeFilters]);
// Compute filters from viewConfig override or CRMSlice
const searchQuery = isOverrideMode ? viewConfigOverrides.searchQuery : searchQueryFromStore;
const overrideResult = isOverrideMode
? computeFiltersFromConfig(
viewConfigOverrides.filterSortConfiguration ?? {},
searchQuery,
customFieldDefinitions,
)
: null;
const filters = overrideResult ? overrideResult.filters : getFilters();
// Use selectedAopIds directly for backend filtering
// null = first load (auto-select will happen), [] = show all, [ids] = filter by these
const backendAopIds = useMemo(() => {
// null or empty array means show all
if (!selectedAopIds || selectedAopIds.length === 0) return undefined;
return selectedAopIds;
}, [selectedAopIds]);
// Build nested filter objects for enum columns (include/exclude in one object)
const backendStatus = useMemo(() => {
const hasInclude = filters.status && filters.status.length > 0;
const hasExclude = filters.excludedStatus && filters.excludedStatus.length > 0;
if (!hasInclude && !hasExclude) return undefined;
return {
include: hasInclude ? filters.status : undefined,
exclude: hasExclude ? filters.excludedStatus : undefined,
};
}, [filters.status, filters.excludedStatus]);
const backendPriority = useMemo(() => {
const hasInclude = filters.priority && filters.priority.length > 0;
const hasExclude = filters.excludedPriority && filters.excludedPriority.length > 0;
if (!hasInclude && !hasExclude) return undefined;
return {
include: hasInclude ? filters.priority : undefined,
exclude: hasExclude ? filters.excludedPriority : undefined,
};
}, [filters.priority, filters.excludedPriority]);
// Date filters - send date only if operator is not 'empty'
const backendNextStepDate = useMemo(() => {
if (filters.nextStepDate?.operator === 'empty') return undefined;
return filters.nextStepDate?.date;
}, [filters.nextStepDate?.date, filters.nextStepDate?.operator]);
const backendNextStepDateOperator = useMemo(() => {
return filters.nextStepDate?.operator;
}, [filters.nextStepDate?.operator]);
const backendNextStepDateTo = useMemo(() => {
return filters.nextStepDate?.dateTo;
}, [filters.nextStepDate?.dateTo]);
const backendLastContactDate = useMemo(() => {
if (filters.lastContact?.operator === 'empty') return undefined;
return filters.lastContact?.date;
}, [filters.lastContact?.date, filters.lastContact?.operator]);
const backendLastContactDateOperator = useMemo(() => {
return filters.lastContact?.operator;
}, [filters.lastContact?.operator]);
const backendLastContactDateTo = useMemo(() => {
return filters.lastContact?.dateTo;
}, [filters.lastContact?.dateTo]);
const backendLastMeetingDate = useMemo(() => {
if (filters.lastMeeting?.operator === 'empty') return undefined;
return filters.lastMeeting?.date;
}, [filters.lastMeeting?.date, filters.lastMeeting?.operator]);
const backendLastMeetingDateOperator = useMemo(() => {
return filters.lastMeeting?.operator;
}, [filters.lastMeeting?.operator]);
const backendLastMeetingDateTo = useMemo(() => {
return filters.lastMeeting?.dateTo;
}, [filters.lastMeeting?.dateTo]);
// Number filter - deal value
const backendDealValue = useMemo(() => {
if (filters.dealValue?.operator === 'empty') return undefined;
if (!filters.dealValue?.value) return undefined;
const num = Number(filters.dealValue.value);
return isNaN(num) ? undefined : num;
}, [filters.dealValue?.value, filters.dealValue?.operator]);
const backendDealValueOperator = useMemo(() => {
return filters.dealValue?.operator;
}, [filters.dealValue?.operator]);
// Boolean filters
const backendCrmSynced = useMemo(() => {
return filters.crmSynced;
}, [filters.crmSynced]);
const backendHasFutureCalendar = useMemo(() => {
return filters.hasFutureCalendar;
}, [filters.hasFutureCalendar]);
const backendLastEventByTypeDate = useMemo(() => {
if (filters.lastEventByType?.operator === 'empty') return undefined;
return filters.lastEventByType?.date;
}, [filters.lastEventByType]);
const backendLastEventByTypeDateOperator = useMemo(
() => filters.lastEventByType?.operator,
[filters.lastEventByType?.operator],
);
const backendLastEventByTypeDateTo = useMemo(
() => filters.lastEventByType?.dateTo,
[filters.lastEventByType?.dateTo],
);
const backendLastEventByTypeTypes = useMemo(
() => (filters.lastEventByType as { types?: string[] } | undefined)?.types,
[filters.lastEventByType],
);
const backendHasTodoTasks = useMemo(() => {
return filters.hasTodoTasks;
}, [filters.hasTodoTasks]);
const backendTaskTypes = useMemo(() => filters.taskTypes, [filters.taskTypes]);
const backendTaskDueDate = useMemo(() => {
if (filters.taskDueDate?.operator === 'empty') return undefined;
return filters.taskDueDate?.date;
}, [filters.taskDueDate]);
const backendTaskDueDateOperator = useMemo(
() => filters.taskDueDate?.operator,
[filters.taskDueDate?.operator],
);
const backendTaskDueDateTo = useMemo(
() => filters.taskDueDate?.dateTo,
[filters.taskDueDate?.dateTo],
);
// Current action filter - tasks with due date constraint
const backendCurrentActionHasTasks = useMemo(() => {
return filters.currentActionHasTasks;
}, [filters.currentActionHasTasks]);
const backendCurrentActionDueBefore = useMemo(() => {
return filters.currentActionDueBefore;
}, [filters.currentActionDueBefore]);
const backendCurrentActionTaskTypes = useMemo(() => {
return filters.currentActionTaskTypes;
}, [filters.currentActionTaskTypes]);
// Event filter - combines event types with optional date constraint
const backendEventFilter = useMemo(() => {
const hasInclude = filters.eventTypes && filters.eventTypes.length > 0;
const hasExclude = filters.excludedEventTypes && filters.excludedEventTypes.length > 0;
// Only support before/after/on/range for events (not 'empty' - it doesn't make sense for events)
const eventDateOperator =
filters.eventDate?.operator && filters.eventDate.operator !== 'empty'
? (filters.eventDate.operator as 'before' | 'after' | 'on' | 'range')
: undefined;
const hasEventDate = filters.eventDate?.date && eventDateOperator;
if (!hasInclude && !hasExclude && !hasEventDate) return undefined;
return {
types:
hasInclude || hasExclude
? {
include: hasInclude ? filters.eventTypes : undefined,
exclude: hasExclude ? filters.excludedEventTypes : undefined,
}
: undefined,
date: hasEventDate ? filters.eventDate?.date : undefined,
dateTo: hasEventDate && eventDateOperator === 'range' ? filters.eventDate?.dateTo : undefined,
dateOperator: eventDateOperator,
};
}, [filters.eventTypes, filters.excludedEventTypes, filters.eventDate]);
// Latest event filter — filter by type/date of the most recent event per conversation
const backendLatestEventFilter = useMemo(() => {
const hasTypes = filters.latestEventType && filters.latestEventType.length > 0;
const latestDateOperator =
filters.latestEventDate?.operator && filters.latestEventDate.operator !== 'empty'
? (filters.latestEventDate.operator as 'before' | 'after' | 'on' | 'range')
: undefined;
const hasDate = filters.latestEventDate?.date && latestDateOperator;
if (!hasTypes && !hasDate) return undefined;
return {
types: hasTypes ? filters.latestEventType : undefined,
date: hasDate ? filters.latestEventDate?.date : undefined,
dateTo: hasDate && latestDateOperator === 'range' ? filters.latestEventDate?.dateTo : undefined,
dateOperator: latestDateOperator,
};
}, [filters.latestEventType, filters.latestEventDate]);
// Build sort configuration for backend
const backendSortBy = useMemo(() => {
if (isOverrideMode) {
return computeBackendSortFromConfig(
viewConfigOverrides?.filterSortConfiguration ?? {},
aops,
customFieldDefinitions,
);
}
// Non-override mode: convert CRMSlice columns to the discriminated-union backend format
// Mirrors computeBackendSortFromConfig but reads from Zustand columns directly.
const NON_VALUE_SORTABLE_TYPES = new Set(['text', 'url', 'email', 'phone']);
type BuiltInField =
| 'lastContactedAt'
| 'nextStepDate'
| 'dealValue'
| 'name'
| 'status'
| 'priority'
| 'aopId'
| 'lastMeetingDate';
const fieldMap: Record<string, BuiltInField> = {
lastContactedAt: 'lastContactedAt',
nextSteps: 'nextStepDate',
dealValue: 'dealValue',
name: 'name',
status: 'status',
priority: 'priority',
type: 'aopId',
lastMeetingTime: 'lastMeetingDate',
};
const sortedColumns = Object.values(columns)
.filter((col) => col.sort?.active)
.sort((a, b) => (a.sort!.priority || 0) - (b.sort!.priority || 0));
if (sortedColumns.length === 0) return undefined;
const result = sortedColumns
.map((col) => {
const { id, sort } = col;
if (!sort) return null;
if (
id === 'currentTasks' ||
id === 'history' ||
id === 'lastOutboundEmailTime' ||
id === 'hasFutureCalendar'
) return null;
// Custom field columns
if (id.startsWith('wm_')) {
const fieldKey=[redacted];
const fieldDef = customFieldDefinitions?.[fieldKey];
if (!fieldDef) return null;
const sortDimension = normalizeSortDimension(sort.sortDimension) ?? 'value';
if (sortDimension === 'value' && NON_VALUE_SORTABLE_TYPES.has(fieldDef.type)) return null;
return {
kind: 'customField' as const,
fieldKey,
fieldType: fieldDef.type,
sortDimension,
direction: sort.direction,
customOrder: sort.order && sort.order.length > 0 ? sort.order : undefined,
};
}
// Built-in columns
const field = fieldMap[id];
if (!field) return null;
let customOrder = sort.order;
if (id === 'type' && customOrder && customOrder.length > 0 && aops) {
customOrder = customOrder.map((name) => {
if (name === null) return null;
return aops.find((a) => a.name === name)?.id ?? null;
});
}
return {
kind: 'builtin' as const,
field,
direction: sort.direction,
customOrder: customOrder && customOrder.length > 0 ? customOrder : undefined,
};
})
.filter((s): s is NonNullable<typeof s> => s !== null);
return result.length > 0 ? result : undefined;
}, [isOverrideMode, viewConfigOverrides?.filterSortConfiguration, columns, aops, customFieldDefinitions]);
// Build custom field filter array for backend
const backendCustomFieldFilters = useMemo((): CustomFieldFilterEntry[] | undefined => {
if (isOverrideMode) {
const result = overrideResult?.customFieldFilters;
return result && result.length > 0 ? result : undefined;
}
// Non-override mode: extract wm_* columns with active filters from CRMSlice
if (!customFieldDefinitions) return undefined;
const result: CustomFieldFilterEntry[] = [];
for (const col of Object.values(columns)) {
if (!col.id.startsWith('wm_') || !col.filter) continue;
const fieldKey=[redacted];
const fieldDef = customFieldDefinitions[fieldKey];
if (!fieldDef) continue;
const filter = col.filter;
const filterDimension = filter.filterDimension ?? 'value';
const base = { fieldKey, fieldType: fieldDef.type, filterDimension };
// Signal dimension → red/yellow/green select filter
if (filterDimension === 'signal') {
if (
(filter.selected && filter.selected.length > 0) ||
(filter.excluded && filter.excluded.length > 0)
) {
result.push({ ...base, kind: 'select', include: filter.selected, exclude: filter.excluded });
}
continue;
}
// Value dimension — kind by fieldType
switch (fieldDef.type) {
case 'select':
if ((filter.selected && filter.selected.length > 0) || (filter.excluded && filter.excluded.length > 0)) {
result.push({ ...base, kind: 'select', include: filter.selected, exclude: filter.excluded });
}
break;
case 'number':
case 'currency':
if (filter.numberOperator) {
result.push({ ...base, kind: 'number', operator: filter.numberOperator, value: filter.numberValue });
}
break;
case 'date':
if (filter.dateOperator) {
result.push({
...base,
kind: 'date',
operator: filter.dateOperator as NonNullable<CustomFieldFilterEntry['operator']>,
date: filter.dateFrom,
dateTo: filter.dateTo,
});
}
break;
case 'text':
case 'url':
case 'email':
case 'phone':
if (filter.searchText || filter.selected?.includes(null)) {
result.push({
...base,
kind: 'text',
contains: filter.searchText,
empty: filter.selected?.includes(null) ?? undefined,
});
}
break;
case 'boolean':
if (filter.binaryChoice) {
result.push({ ...base, kind: 'boolean', booleanValue: filter.binaryChoice as 'true' | 'false' });
} else if (filter.selected?.includes(null)) {
result.push({ ...base, kind: 'boolean', booleanValue: 'empty' });
}
break;
}
}
return result.length > 0 ? result : undefined;
}, [isOverrideMode, overrideResult, columns, customFieldDefinitions]);
// When canvas is pinned to specific IDs (e.g. Finding Conversations results), request only those IDs.
// No other filters are sent so the backend applies a single condition (id IN (...)); no special-case logic required.
const isPinnedToList = Boolean(
pinnedConversationIds && pinnedConversationIds.length > 0,
);
const listVariables = useMemo(() => {
if (isPinnedToList) {
return {
conversationIds: pinnedConversationIds,
limit: 50 as const,
search: undefined,
aopIds: undefined,
status: undefined,
priority: undefined,
nextStepDate: undefined,
nextStepDateOperator: undefined,
nextStepDateTo: undefined,
lastContactDate: undefined,
lastContactDateOperator: undefined,
lastContactDateTo: undefined,
lastMeetingDate: undefined,
lastMeetingDateOperator: undefined,
dealValue: undefined,
dealValueOperator: undefined,
crmSynced: undefined,
hasFutureCalendar: undefined,
lastEventByTypeDate: undefined,
lastEventByTypeDateOperator: undefined,
lastEventByTypeDateTo: undefined,
lastEventByTypeTypes: undefined,
hasTodoTasks: undefined,
taskTypes: undefined,
taskDueDate: undefined,
taskDueDateOperator: undefined,
taskDueDateTo: undefined,
currentActionHasTasks: undefined,
currentActionDueBefore: undefined,
currentActionTaskTypes: undefined,
eventFilter: undefined,
latestEventFilter: undefined,
sortBy: undefined,
customFieldFilters: undefined,
};
}
return {
search: searchQuery,
aopIds: backendAopIds,
status: backendStatus,
priority: backendPriority,
nextStepDate: backendNextStepDate,
nextStepDateOperator: backendNextStepDateOperator,
nextStepDateTo: backendNextStepDateTo,
lastContactDate: backendLastContactDate,
lastContactDateOperator: backendLastContactDateOperator,
lastContactDateTo: backendLastContactDateTo,
lastMeetingDate: backendLastMeetingDate,
lastMeetingDateOperator: backendLastMeetingDateOperator,
lastMeetingDateTo: backendLastMeetingDateTo,
dealValue: backendDealValue,
dealValueOperator: backendDealValueOperator,
crmSynced: backendCrmSynced,
hasFutureCalendar: backendHasFutureCalendar,
lastEventByTypeDate: backendLastEventByTypeDate,
lastEventByTypeDateOperator: backendLastEventByTypeDateOperator,
lastEventByTypeDateTo: backendLastEventByTypeDateTo,
lastEventByTypeTypes: backendLastEventByTypeTypes,
hasTodoTasks: backendHasTodoTasks,
taskTypes: backendTaskTypes,
taskDueDate: backendTaskDueDate,
taskDueDateOperator: backendTaskDueDateOperator,
taskDueDateTo: backendTaskDueDateTo,
currentActionHasTasks: backendCurrentActionHasTasks,
currentActionDueBefore: backendCurrentActionDueBefore,
currentActionTaskTypes: backendCurrentActionTaskTypes,
eventFilter: backendEventFilter,
latestEventFilter: backendLatestEventFilter,
sortBy: backendSortBy,
customFieldFilters: backendCustomFieldFilters,
limit: 50 as const,
conversationIds: undefined,
};
}, [
isPinnedToList,
pinnedConversationIds,
searchQuery,
backendAopIds,
backendStatus,
backendPriority,
backendNextStepDate,
backendNextStepDateOperator,
backendNextStepDateTo,
backendLastContactDate,
backendLastContactDateOperator,
backendLastContactDateTo,
backendLastMeetingDate,
backendLastMeetingDateOperator,
backendLastMeetingDateTo,
backendDealValue,
backendDealValueOperator,
backendCrmSynced,
backendHasFutureCalendar,
backendLastEventByTypeDate,
backendLastEventByTypeDateOperator,
backendLastEventByTypeDateTo,
backendLastEventByTypeTypes,
backendHasTodoTasks,
backendTaskTypes,
backendTaskDueDate,
backendTaskDueDateOperator,
backendTaskDueDateTo,
backendCurrentActionHasTasks,
backendCurrentActionDueBefore,
backendEventFilter,
backendLatestEventFilter,
backendSortBy,
backendCustomFieldFilters,
]);
// Setup infinite query with cursor-based pagination
// All filters are now handled by the backend
const conversationsQuery = useInfiniteQuery({
queryKey: [
['crm', 'listConversations'],
listVariables,
// dateEpoch busts the cache when the day rolls over for relative date filters
hasRelativeFilters ? dateEpoch : undefined,
],
queryFn: async ({ pageParam }) => {
return await trpcClient.crm.listConversations.query({
...listVariables,
cursor: typeof pageParam === 'number' ? pageParam : undefined,
});
},
initialPageParam: undefined as number | undefined,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getNextPageParam: (lastPage: any) => {
// Return offset for next page, or undefined if no more pages
return lastPage?.nextCursor ?? undefined;
},
staleTime: 30 * 1000, // 30 seconds
placeholderData: (previousData) => previousData,
});
// Flatten all pages into single conversation array
const rawConversations = useMemo(() => {
if (!conversationsQuery.data?.pages) return [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return conversationsQuery.data.pages.flatMap((page: any) => page.conversations || []);
}, [conversationsQuery.data?.pages]);
// Sync to Zustand when data arrives
useEffect(() => {
// Create signature to detect actual changes
// Extract active sorts — from canvas override config when in override mode, else from columns
const activeSorts = isOverrideMode
? Object.entries(viewConfigOverrides?.filterSortConfiguration ?? {})
.filter(([, col]) => col.sort?.active)
.map(([id, col]) => ({ id, sort: col.sort }))
: Object.values(columns)
.filter((col) => col.sort?.active)
.map((col) => ({ id: col.id, sort: col.sort }));
const signature = JSON.stringify({
ids: rawConversations.map((conv) => conv.id),
updatedAt: rawConversations.map((conv) => conv.updatedAt),
customFieldsRevision: rawConversations.map((conv) =>
getCustomFieldsRevision(conv.customFields),
),
filters: filters,
activeSorts: activeSorts,
});
// Skip if data hasn't changed
if (previousSignatureRef.current === signature) return;
previousSignatureRef.current = signature;
try {
// Transform to Record<conversationId, HydratedConversation>
const conversationsMap: Record<string, HydratedConversation> = {};
const conversationIds: string[] = [];
rawConversations.forEach((conv) => {
const people =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
conv.conversationUsers?.map((cu: any) => cu.person).filter(Boolean) || [];
// Use primary company from backend (resolved via primary_company_id)
// Falls back to conversationCompanies[0] for backward compatibility
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const company = (conv as any).primaryCompany || conv.conversationCompanies?.[0]?.company || null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const latestEvent = (conv.events?.[0] as any) || null;
conversationsMap[conv.id] = {
conversation: conv,
people,
company,
userTasks: conv.userTasks || [],
latestEvent,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
scheduledCalendarEvents: (conv as any).scheduledCalendarEvents || [],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
customFields: (conv as any).customFields || [],
};
conversationIds.push(conv.id);
});
// ============================================
// CLIENT-SIDE FILTERING
// ============================================
// All filters are now handled by the backend.
// No client-side filtering needed.
// ============================================
// SORTING
// ============================================
// Sorting is mostly handled by the BACKEND for better performance.
// Exceptions: currentTasks, currentAction, futureAction, lastMeetingTime, lastOutboundEmailTime
// are sorted client-side since the data is already loaded in memory.
// Get all client-side sortable columns
const CLIENT_SORT_COLUMNS = new Set([
'currentTasks',
'currentAction',
'futureAction',
'lastOutboundEmailTime',
'hasFutureCalendar',
]);
const clientSortColumns = isOverrideMode
? getClientSortColumnsFromConfig(viewConfigOverrides?.filterSortConfiguration ?? {})
: Object.values(columns)
.filter((col) => CLIENT_SORT_COLUMNS.has(col.id) && col.sort?.active)
.sort((a, b) => (a.sort!.priority || 0) - (b.sort!.priority || 0));
if (clientSortColumns.length > 0) {
// Helper to get the earliest due date for current/future action sorting
const getActionDueDate = (
tasks: (typeof conversationsMap)[string]['userTasks'],
isCurrent: boolean,
): number => {
if (!tasks || tasks.length === 0) return Infinity;
const now = new Date();
const endOfToday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
23,
59,
59,
999,
);
const todoTasks = tasks.filter((t) => t.status === 'todo');
const relevantTasks = isCurrent
? todoTasks.filter((t) => t.dueDate && new Date(t.dueDate) <= endOfToday)
: todoTasks.filter((t) => t.dueDate && new Date(t.dueDate) > endOfToday);
if (relevantTasks.length === 0) return Infinity;
// Find the earliest due date
const earliestDueDate = relevantTasks.reduce((earliest, task) => {
const taskDate = new Date(task.dueDate!).getTime();
return taskDate < earliest ? taskDate : earliest;
}, Infinity);
return earliestDueDate;
};
conversationIds.sort((aId, bId) => {
for (const column of clientSortColumns) {
let comparison = 0;
const direction = column.sort!.direction;
if (column.id === 'currentTasks') {
const aTasks = conversationsMap[aId].userTasks || [];
const bTasks = conversationsMap[bId].userTasks || [];
const aCount = aTasks.filter((t) => t.status === 'todo').length;
const bCount = bTasks.filter((t) => t.status === 'todo').length;
comparison = aCount - bCount;
} else if (column.id === 'currentAction') {
// Sort by earliest overdue/current task due date
const aTasks = conversationsMap[aId].userTasks || [];
const bTasks = conversationsMap[bId].userTasks || [];
const aDueDate = getActionDueDate(aTasks, true);
const bDueDate = getActionDueDate(bTasks, true);
comparison = aDueDate - bDueDate;
} else if (column.id === 'futureAction') {
// Sort by earliest future task due date
const aTasks = conversationsMap[aId].userTasks || [];
const bTasks = conversationsMap[bId].userTasks || [];
const aDueDate = getActionDueDate(aTasks, false);
const bDueDate = getActionDueDate(bTasks, false);
comparison = aDueDate - bDueDate;
} else if (column.id === 'lastOutboundEmailTime') {
// The scalar, not a scan of `events` — that array is capped, so this optimistic
// sort used to disagree with the server's ORDER BY, which has always computed
// MAX(occurred_at) over the full history.
const latestOutbound = (id: string) => {
const at = conversationsMap[id].conversation.lastOutboundEmailAt;
return at ? new Date(at).getTime() : 0;
};
comparison = latestOutbound(aId) - latestOutbound(bId);
} else if (column.id === 'hasFutureCalendar') {
const now = new Date();
const getNextMeetingTime = (id: string): number => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const events: any[] = conversationsMap[id].scheduledCalendarEvents || [];
for (const event of events) {
const endTime = new Date(event.endTime);
if (event.isAllDay || endTime <= now) continue;
return new Date(event.startTime).getTime();
}
return Infinity;
};
const aTime = getNextMeetingTime(aId);
const bTime = getNextMeetingTime(bId);
if (aTime === bTime) comparison = 0;
else if (aTime === Infinity) comparison = 1;
else if (bTime === Infinity) comparison = -1;
else comparison = aTime - bTime;
}
// Apply sort direction
const result = direction === 'asc' ? comparison : -comparison;
// If this field produces a non-zero result, use it
if (result !== 0) {
return result;
}
}
// All sort fields were equal
return 0;
});
}
// const sortedColumns = Object.values(columns)
// .filter((col) => col.sort?.active)
// .sort((a, b) => (a.sort!.priority || 0) - (b.sort!.priority || 0));
// if (sortedColumns.length > 0) {
// conversationIds.sort((aId, bId) => {
// const aConv = conversationsMap[aId].conversation;
// const bConv = conversationsMap[bId].conversation;
// // Apply each sorted column in priority order
// for (const column of sortedColumns) {
// let comparison = 0;
// const columnId = column.id;
// const sortDirection = column.sort!.direction;
// const customOrder = column.sort!.order;
// switch (columnId) {
// case 'type': {
// const aopA = aops?.find((a) => a.id === aConv.aopId);
// const aopB = aops?.find((a) => a.id === bConv.aopId);
// const aType = aopA?.name || '';
// const bType = aopB?.name || '';
// // Use custom order if available
// if (customOrder && customOrder.length > 0) {
// const aIndex = customOrder.indexOf(aType);
// const bIndex = customOrder.indexOf(bType);
// comparison = (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex);
// } else {
// comparison = aType.localeCompare(bType);
// }
// break;
// }
// case 'status': {
// const aStatus = aConv.status || '';
// const bStatus = bConv.status || '';
// // Use custom order if available
// if (customOrder && customOrder.length > 0) {
// const aIndex = customOrder.indexOf(aStatus);
// const bIndex = customOrder.indexOf(bStatus);
// comparison = (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex);
// } else {
// comparison = aStatus.localeCompare(bStatus);
// }
// break;
// }
// case 'priority': {
// const aPriority = aConv.priority || '';
// const bPriority = bConv.priority || '';
// // Use custom order if available
// if (customOrder && customOrder.length > 0) {
// const aIndex = customOrder.indexOf(aPriority);
// const bIndex = customOrder.indexOf(bPriority);
// comparison = (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex);
// } else {
// comparison = aPriority.localeCompare(bPriority);
// }
// break;
// }
// case 'responsiveness': {
// const aResponsiveness = aConv.responsiveness || 'unknown';
// const bResponsiveness = bConv.responsiveness || 'unknown';
// // Use custom order if available
// if (customOrder && customOrder.length > 0) {
// const aIndex = customOrder.indexOf(aResponsiveness);
// const bIndex = customOrder.indexOf(bResponsiveness);
// comparison = (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex);
// } else {
// comparison = aResponsiveness.localeCompare(bResponsiveness);
// }
// break;
// }
// case 'lastContactedAt': {
// const aDate = aConv.lastContactedAt ? new Date(aConv.lastContactedAt).getTime() : 0;
// const bDate = bConv.lastContactedAt ? new Date(bConv.lastContactedAt).getTime() : 0;
// comparison = aDate - bDate;
// break;
// }
// case 'dealValue': {
// const aDealValue = aConv.dealValue || 0;
// const bDealValue = bConv.dealValue || 0;
// comparison = aDealValue - bDealValue;
// break;
// }
// case 'name': {
// const aName = aConv.name || '';
// const bName = bConv.name || '';
// comparison = aName.localeCompare(bName);
// break;
// }
// }
// // Apply sort direction
// const result = sortDirection === 'asc' ? comparison : -comparison;
// // If this field produces a non-zero result, use it
// // Otherwise, continue to the next sort field
// if (result !== 0) {
// return result;
// }
// }
// // All sort fields were equal
// return 0;
// });
// }
// Update conversationsSlice with conversation data
// 'list' — these rows come from crm.listConversations, whose events are the overview
// projection. The flag stops them replacing the full timeline of a conversation that is
// open. See setConversations in conversationsSlice.
setConversations(conversationsMap, { projection: 'list' });
// Update conversationsSlice with ordered list (for keyboard navigation)
// Transform conversationIds to ConversationSummary objects
const conversationList: ConversationSummary[] = conversationIds.map((id) => {
const conv = conversationsMap[id];
return {
id,
name: conv.conversation.name || undefined,
};
});
setCurrentConversationList(conversationList);
} catch (error) {
console.error('[useCRMConversations] Failed to update state:', error);
}
}, [
rawConversations,
filters,
columns,
aops,
setConversations,
setCurrentConversationList,
conversationsQuery.data,
selectedAopIds,
isOverrideMode,
viewConfigOverrides?.filterSortConfiguration,
]);
// Auto-fetch more when filtered down to too few results
useEffect(() => {
const visibleCount = useCedarStore.getState().getCurrentConversationList().length;
const hasNextPage = conversationsQuery.hasNextPage;
if (
visibleCount < 20 &&
hasNextPage &&
!conversationsQuery.isFetching &&
!conversationsQuery.isFetchingNextPage
) {
void conversationsQuery.fetchNextPage();
}
}, [
rawConversations.length,
filters,
conversationsQuery.hasNextPage,
conversationsQuery.isFetching,
conversationsQuery.isFetchingNextPage,
conversationsQuery,
]);
// Load more function
const loadMore = async () => {
// Prevent concurrent fetches
if (
conversationsQuery.isLoading ||
conversationsQuery.isFetching ||
conversationsQuery.isFetchingNextPage
) {
return;
}
if (!conversationsQuery.hasNextPage) {
return;
}
await conversationsQuery.fetchNextPage();
};
return [conversationsQuery, conversationsQuery.hasNextPage ?? false, loadMore] as const;
}