crm-table.tsx30.7 KBView on GitHub /**
* CRM Table Component
* Main table component for displaying and managing CRM contacts
*/
import type {
CompanyCombined,
NextStep,
ScheduledAction,
UserTask,
WorkingMemoryField,
} from '../store/crmSlice';
import { Columns, LayoutGrid, Loader2, Plus, RefreshCw, Table as TableIcon, X } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useOptimisticConversationActions } from '../hooks/use-optimistic-conversation-actions';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useUpsertWorkingMemory } from '../hooks/use-working-memory';
import { useIsFetching, useIsMutating } from '@tanstack/react-query';
import { useConversationNavigation } from '@/modules/conversations';
import { useSession } from '@/modules/auth/utils/auth-client';
import { useVirtualizer } from '@tanstack/react-virtual';
import type { CRMColumn } from '../store/crmSlice';
import type { ConversationEvent } from '../types';
import { useSearchParams } from 'react-router';
import { DEFAULT_STATUS_ENUM } from '../types';
import { format } from 'date-fns';
import { ComposeDisplay } from '@/modules/drafting/components/compose-display';
import { ThreadDisplay } from '@/modules/threads/thread/components/thread-display';
import { ConversationListHotkeys } from './conversation-list-hotkeys';
import { useCRMConversations } from '../hooks/use-crm-conversations';
import { useCRMConfiguration } from '../hooks/use-crm-configuration';
import { useBulkSelection } from '../hooks/use-bulk-selection';
import { VirtualizedTableRow } from './VirtualizedTableRow';
import { useCreateConversation } from '../hooks/use-crm';
import { useAOPs } from '@/modules/aop/hooks/use-aops';
import { HotkeyBar } from '@/components/ui/hotkey-bar';
import { ConversationView } from './ConversationView';
import { CRMSearchInput } from './crm-search-input';
import { CRMTableHeader } from './CRMTableHeader';
import { Button } from '@/components/ui/button';
import { useCedarStore } from '@/modules/store';
import { CRMPresetBar } from './CRMPresetBar';
import { cn } from '@/lib/utils';
export function CRMTable() {
// Get view mode from URL params (for toggle button state)
const [searchParams, setSearchParams] = useSearchParams();
const viewMode = searchParams.get('view') || 'table';
// Fetch conversations with infinite scroll
const [conversationsQuery, hasNextPage, loadMore] = useCRMConversations();
// Get session for working memory updates
const { data: session } = useSession();
const { mutateAsync: upsertWorkingMemory } = useUpsertWorkingMemory();
// Use the unified CRM configuration hook
// This handles: AOP selection, column generation, enum options, validation
// Returns only visible columns by default, allColumns includes hidden ones
const { columns: visibleColumns, allColumns } = useCRMConfiguration();
// On mount, clear activePresetId and show all columns.
// HomeView sets activePresetId for its tab presets; the full CRM table shows everything.
const setActivePresetId = useCedarStore((state) => state.setActivePresetId);
const setColumnVisibilities = useCedarStore((state) => state.setColumnVisibilities);
const columns = useCedarStore((state) => state.columns);
// Run once when columns first become available (replaces removed columnsInitialized flag)
const columnsInitAppliedRef = useRef(false);
useEffect(() => {
if (columnsInitAppliedRef.current || Object.keys(columns).length === 0) return;
columnsInitAppliedRef.current = true;
// Clear preset so late-arriving columns aren't hidden
setActivePresetId(null);
// Make all columns visible (full CRM table view)
const visibilities: Record<string, boolean> = {};
Object.keys(columns).forEach((columnId) => {
visibilities[columnId] = true;
});
setColumnVisibilities(visibilities);
}, [columns, setActivePresetId, setColumnVisibilities]);
const { mutateAsync: createConversation } = useCreateConversation();
const { optimisticUpdateConversation } = useOptimisticConversationActions();
// Get list ordering from conversationsSlice (for keyboard navigation and rendering)
const conversationList = useCedarStore((state) => state.getCurrentConversationList());
// Get selection from crmSlice (bulk operations)
const selectedIds = useCedarStore((state) => state.conversationSelection);
const clearConversationSelection = useCedarStore((state) => state.clearConversationSelection);
const toggleConversationSelection = useCedarStore((state) => state.toggleConversationSelection);
const setConversationSelection = useCedarStore((state) => state.setConversationSelection);
const selectionAnchorId = useCedarStore((state) => state.selectionAnchorId);
const setSelectionAnchorId = useCedarStore((state) => state.setSelectionAnchorId);
// Bulk selection hook
const { getSelectMode, handleClickWithSelection } = useBulkSelection({
items: conversationList,
selectedIds,
setSelection: setConversationSelection,
toggleSelection: toggleConversationSelection,
clearSelection: clearConversationSelection,
anchorId: selectionAnchorId,
setAnchorId: setSelectionAnchorId,
});
// Get filters for clear all button (computed from columns)
const getFilters = useCedarStore((state) => state.getFilters);
const filters = getFilters();
// Get selectedAopIds and derive names for display (from AOPs data)
const selectedAopIds = useCedarStore((state) => state.selectedAopIds);
const { data: aopsData } = useAOPs();
const selectedAopNames = useMemo(() => {
if (!selectedAopIds || selectedAopIds.length === 0 || !aopsData?.aops) return [];
return selectedAopIds
.map((id) => aopsData.aops.find((a) => a.id === id)?.name)
.filter((name): name is string => !!name);
}, [selectedAopIds, aopsData?.aops]);
// Get column actions from store
const toggleColumnVisibility = useCedarStore((state) => state.toggleColumnVisibility);
const reorderColumns = useCedarStore((state) => state.reorderColumns);
const setColumnWidth = useCedarStore((state) => state.setColumnWidth);
const resetColumnConfig = useCedarStore((state) => state.resetColumnConfig);
// Calculate hidden columns for "Show All" button
const hiddenColumns = useMemo(() => {
return allColumns.filter((col) => !col.visible);
}, [allColumns]);
const hiddenColumnCount = hiddenColumns.length;
// Helper to check if column should have textarea width (needed for resetColumnConfig)
const isTextareaColumn = useCallback((id: string) => {
return (
id === 'primaryCompany' ||
id === 'status' ||
id === 'scheduledAction' ||
id === 'nextSteps' ||
id === 'history'
);
}, []);
// Reset all columns to visible
const handleResetColumns = useCallback(() => {
resetColumnConfig(isTextareaColumn);
}, [resetColumnConfig, isTextareaColumn]);
// Column state for width/resize tracking (temporary state during drag)
const [columnSizing, setColumnSizing] = useState<Record<string, number>>({});
// Count active filters (excluding search and type since type is always set)
const activeFilterCount = Object.entries(filters).filter(([key, value]) => {
if (key === 'search' || key === 'type') return false;
if (Array.isArray(value)) return value.length > 0;
// crmSynced is a boolean - only count if it's explicitly set (not undefined)
if (key === 'crmSynced') return value !== undefined;
return value !== undefined && value !== '' && value !== null;
}).length;
const renderCountRef = useRef(0);
renderCountRef.current += 1;
useEffect(() => {
if (process.env.NODE_ENV !== 'development') return;
console.debug(
`[CRMTable] Main table render count: ${renderCountRef.current} | Conversations: ${conversationList.length}`,
);
});
// Ref for the table container (for virtualization and keyboard navigation)
const tableContainerRef = useRef<HTMLDivElement>(null);
// Enable keyboard navigation (j/k, arrow keys, Enter, Escape, s to select hovered)
const { handleMouseEnter } = useConversationNavigation({
items: conversationList,
containerRef: tableContainerRef,
});
// Check if conversation is open
const isConversationOpen = useCedarStore((state) => state.isConversationOpen);
const setIsConversationOpen = useCedarStore((state) => state.setIsConversationOpen);
const threadId = useCedarStore((state) => state.selectedThreadId);
const isThreadOpen = useCedarStore((state) => state.isThreadOpen);
const newEmail = useCedarStore((state) => state.newEmail);
const isViewOpen = isConversationOpen || isThreadOpen || newEmail;
// Calculate column widths from visible columns
const columnWidths = useMemo(() => {
const widths: Record<string, number> = {};
visibleColumns.forEach((col) => {
// Use stored width from column config, or fall back to columnSizing, or defaults
// Column config already has type-aware default widths from initializeColumns
widths[col.id] = columnSizing[col.id] || col.width || 200;
});
return widths;
}, [visibleColumns, columnSizing]);
// Calculate total table width
const totalSize = useMemo(() => {
return Object.values(columnWidths).reduce((sum, width) => sum + width, 0);
}, [columnWidths]);
// Handle column resize (disabled when conversation or thread is open)
const handleResizeStart = useCallback(
(columnId: string, startX: number, startWidth: number) => {
if (isViewOpen) return; // Disable resizing when conversation or thread is open
let finalWidth = startWidth;
const handleMouseMove = (moveEvent: MouseEvent | TouchEvent) => {
const currentX = 'touches' in moveEvent ? moveEvent.touches[0].clientX : moveEvent.clientX;
const diff = currentX - startX;
const newWidth = Math.max(80, startWidth + diff);
finalWidth = newWidth;
setColumnSizing((prev) => ({ ...prev, [columnId]: newWidth }));
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('touchmove', handleMouseMove);
document.removeEventListener('touchend', handleMouseUp);
// Persist the final width to Zustand store
setColumnWidth(columnId, finalWidth);
// Clear temporary sizing state
setColumnSizing((prev) => {
const next = { ...prev };
delete next[columnId];
return next;
});
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('touchmove', handleMouseMove);
document.addEventListener('touchend', handleMouseUp);
},
[isViewOpen, setColumnWidth],
);
// Save cell value to database (called on blur) - using optimistic updates
const handleCellBlur = useCallback(
async (
rowId: string,
columnId: string,
value:
| string
| boolean
| number
| ConversationEvent[]
| NextStep
| CompanyCombined
| ScheduledAction
| UserTask[]
| WorkingMemoryField[]
| null
| undefined,
) => {
// rowId is the conversationId, so we can use it directly
if (!rowId) return;
// Handle custom fields (columns starting with wm_)
if (columnId.startsWith('wm_') && columnId !== 'wm_custom') {
const fieldName = columnId.substring(3); // Remove 'wm_' prefix
if (!session?.user?.id) return;
await upsertWorkingMemory({
conversationId: rowId,
name: fieldName,
value: typeof value === 'string' ? value : String(value || ''),
});
return;
}
// Skip custom memory column (read-only for now)
if (columnId === 'wm_custom') {
return;
}
// Skip user tasks column (read-only)
if (columnId === 'currentTasks') {
return;
}
// Map UI columns to database fields
const updates: Record<string, string | number | Date | null> = {};
if (columnId === 'nextSteps') {
updates.nextSteps = (value as string) || null;
} else if (columnId === 'nextStepDate') {
const dateStr = value as string;
if (dateStr && dateStr.trim()) {
const date = new Date(dateStr);
updates.nextStepDate = isNaN(date.getTime()) ? null : date;
} else {
updates.nextStepDate = null;
}
} else if (columnId === 'status') {
updates.status = (value as string) || null;
} else if (columnId === 'statusOverview') {
updates.statusOverview = (value as string) || null;
} else if (columnId === 'primaryCompany') {
// Handle combined company column - only update name (company is read-only)
const companyCombined = value as CompanyCombined;
updates.name = companyCombined.name || null;
} else if (columnId === 'type') {
// Type is now derived from AOP, skip updating (handled by aop-type cell)
return;
} else if (columnId === 'priority') {
updates.priority = value as string;
} else if (columnId === 'dealValue') {
const numValue = Number(value);
updates.dealValue = isNaN(numValue) ? null : numValue;
}
// Use optimistic update - will immediately update Zustand and call server
await optimisticUpdateConversation(rowId, updates);
},
[optimisticUpdateConversation, upsertWorkingMemory, session?.user?.id],
);
// Add new row
const addRow = async () => {
await createConversation({
name: `New Conversation ${Date.now()}`,
status: DEFAULT_STATUS_ENUM.PROSPECTING,
});
};
const handleRowClick = useCallback(
(rowId: string) => {
handleClickWithSelection(rowId, () => {
// Simple selection - just highlight the row
useCedarStore.getState().setActiveConversationId(rowId);
});
},
[handleClickWithSelection],
);
// Handle mouse enter for keyboard navigation
const handleRowMouseEnter = useCallback(
(rowId: string) => {
handleMouseEnter(rowId);
},
[handleMouseEnter],
);
const isFetching = useIsFetching();
const isMutating = useIsMutating();
const isLoading = isFetching > 0 || isMutating > 0;
const handleCloseConversation = useCallback(() => {
// Only close the conversation view, keep the conversation selected
setIsConversationOpen(false);
}, [setIsConversationOpen]);
// Handle column reordering
const handleReorderColumns = useCallback(
(newOrder: CRMColumn[]) => {
const columnIds = newOrder.map((col) => col.id);
reorderColumns(columnIds);
},
[reorderColumns],
);
// Delete column (hide instead of delete)
const deleteColumn = useCallback(
(columnId: string) => {
toggleColumnVisibility(columnId);
},
[toggleColumnVisibility],
);
// Update column (placeholder for compatibility)
const updateColumn = useCallback(() => {
console.warn('updateColumn called - column updates should use specific Zustand actions');
}, []);
// Determine if we should measure elements (not Firefox)
const shouldMeasure =
typeof window !== 'undefined' &&
typeof navigator !== 'undefined' &&
navigator.userAgent.indexOf('Firefox') === -1;
// Virtualize rows with TanStack Virtual
const rowVirtualizer = useVirtualizer({
count: conversationList.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: () => 60,
measureElement: shouldMeasure
? (element) => element?.getBoundingClientRect().height
: undefined,
overscan: 5,
});
const virtualItems = rowVirtualizer.getVirtualItems();
// Scroll detection for infinite scroll - load more when near bottom
useEffect(() => {
const container = tableContainerRef.current;
if (!container) return;
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
// Trigger load more when within 500px of bottom
if (
distanceFromBottom < 500 &&
hasNextPage &&
!conversationsQuery.isFetching &&
!conversationsQuery.isFetchingNextPage
) {
void loadMore();
}
};
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, [hasNextPage, conversationsQuery.isFetching, conversationsQuery.isFetchingNextPage, loadMore]);
return (
<TooltipProvider delayDuration={300}>
{/* URL⇄store sync is now a single root-level LayoutUrlSync (url-driven-layout Phase 3). */}
<ConversationListHotkeys />
<div className="flex h-full flex-col py-2">
{/* Header - Hidden when conversation is open */}
{!isViewOpen && (
<div className="shrink-0 space-y-2 pb-2">
<div className="flex items-center">
{/* Left side: Search Input and Action Buttons */}
<div className="flex items-center gap-2 flex-1">
<CRMSearchInput />
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => conversationsQuery.refetch()}
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
disabled={isLoading}
>
<RefreshCw className={cn('h-4 w-4', isLoading && 'animate-spin')} />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Refresh all conversations</p>
</TooltipContent>
</Tooltip>
{activeFilterCount > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => {
// Clear all column filters and search query
useCedarStore.getState().clearAllColumnFilters();
useCedarStore.getState().setSearchQuery(undefined);
}}
size="sm"
variant="outline"
className="gap-2"
>
<X className="h-4 w-4" />
Clear all filters ({activeFilterCount})
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<div className="space-y-1.5">
<p className="font-semibold">Active Filters:</p>
{/* Type is always shown (AOP selection), shown as context not filter */}
{selectedAopNames.length > 0 && (
<div className="text-xs">
<span className="text-muted-foreground">Types (AOPs): </span>
<span>{selectedAopNames.join(', ')}</span>
</div>
)}
{filters.status && filters.status.length > 0 && (
<div className="text-xs">
<span className="text-muted-foreground">Status: </span>
<span>{filters.status.join(', ')}</span>
</div>
)}
{filters.excludedStatus && filters.excludedStatus.length > 0 && (
<div className="text-xs">
<span className="text-muted-foreground">Excluded Status: </span>
<span>{filters.excludedStatus.join(', ')}</span>
</div>
)}
{filters.priority && filters.priority.length > 0 && (
<div className="text-xs">
<span className="text-muted-foreground">Priority: </span>
<span>{filters.priority.join(', ')}</span>
</div>
)}
{filters.excludedPriority && filters.excludedPriority.length > 0 && (
<div className="text-xs">
<span className="text-muted-foreground">Excluded Priority: </span>
<span>{filters.excludedPriority.join(', ')}</span>
</div>
)}
{filters.lastContact && (
<div className="text-xs">
<span className="text-muted-foreground">
Last Contact{' '}
{filters.lastContact.operator === 'empty'
? 'Not Set'
: filters.lastContact.operator === 'before'
? 'Before'
: filters.lastContact.operator === 'after'
? 'After'
: 'On'}
{filters.lastContact.operator !== 'empty' && ': '}
</span>
{filters.lastContact.operator !== 'empty' && (
<span>
{format(new Date(filters.lastContact.date), 'MMM d, yyyy')}
</span>
)}
</div>
)}
{filters.dealValue && (
<div className="text-xs">
<span className="text-muted-foreground">
Deal Size{' '}
{filters.dealValue.operator === 'empty'
? 'Not Set'
: filters.dealValue.operator === 'gt'
? '>'
: filters.dealValue.operator === 'lt'
? '<'
: '='}
{filters.dealValue.operator !== 'empty' && ' '}
</span>
{filters.dealValue.operator !== 'empty' && (
<span>${Number(filters.dealValue.value).toLocaleString()}</span>
)}
</div>
)}
{(filters.dealValueMin || filters.dealValueMax) && (
<div className="text-xs">
<span className="text-muted-foreground">Deal Value: </span>
<span>
{filters.dealValueMin && filters.dealValueMax
? `$${filters.dealValueMin} - $${filters.dealValueMax}`
: filters.dealValueMin
? `≥ $${filters.dealValueMin}`
: `≤ $${filters.dealValueMax}`}
</span>
</div>
)}
{filters.crmSynced !== undefined && (
<div className="text-xs">
<span className="text-muted-foreground">CRM Synced: </span>
<span>{filters.crmSynced ? 'Synced' : 'Not Synced'}</span>
</div>
)}
</div>
</TooltipContent>
</Tooltip>
)}
{/* Show Hidden Columns Button */}
{hiddenColumnCount > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={handleResetColumns}
size="sm"
variant="outline"
className="gap-2"
>
<Columns className="h-4 w-4" />
Show hidden columns ({hiddenColumnCount})
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<div className="space-y-1.5">
<p className="font-semibold">Hidden Columns:</p>
{hiddenColumns.map((col) => (
<div key=[redacted] className="text-xs">
<span>{col.name || col.id}</span>
</div>
))}
</div>
</TooltipContent>
</Tooltip>
)}
{/* Right side: Action Buttons */}
<div className="flex shrink-0 gap-2">
{/* View Toggle Button */}
<Button
variant="outline"
size="sm"
onClick={() => {
const newView = viewMode === 'table' ? 'kanban' : 'table';
setSearchParams({ view: newView });
}}
className="gap-2"
>
{viewMode === 'table' ? (
<>
<LayoutGrid className="h-4 w-4" />
Kanban View
</>
) : (
<>
<TableIcon className="h-4 w-4" />
Table View
</>
)}
</Button>
{selectedIds.length > 0 && (
<Button variant="ghost" size="sm" onClick={clearConversationSelection}>
Clear Selection ({selectedIds.length})
</Button>
)}
{/* COMMENTED OUT: Add Conversation button */}
{/* <Button onClick={addRow} size="sm">
<Plus className="mr-2 h-4 w-4" />
Add Conversation
</Button> */}
{/* COMMENTED OUT: Add Column Popover */}
{/* <AddColumnPopover
isOpen={isAddColumnOpen}
onOpenChange={setIsAddColumnOpen}
newColumn={newColumn}
onColumnChange={setNewColumn}
onAdd={addColumn}
/> */}
</div>
</div>
</div>
{/* Preset Bar */}
<CRMPresetBar />
</div>
)}
{/* Table Container with Virtualization - Grid System */}
<div className="flex min-h-0 flex-1 gap-0 overflow-hidden mb-2">
{/* Conversation/Thread View - Full Width when open */}
{isViewOpen && (
<div className="min-h-0 flex-1 overflow-hidden">
{newEmail ? (
<div className="flex h-full w-full max-w-full flex-col">
<ComposeDisplay />
</div>
) : isThreadOpen && threadId ? (
<ThreadDisplay />
) : (
<ConversationView onClose={handleCloseConversation} />
)}
</div>
)}
{/* Table Section - Hidden when conversation or thread is open */}
{!isViewOpen && (
<div className="overflow-hidden flex-1">
<div
ref={tableContainerRef}
className={cn(
'h-full scrollbar-hide overflow-auto',
getSelectMode() === 'range' && 'select-none',
)}
style={{ position: 'relative' }}
>
<table style={{ display: 'grid' }} className="w-full">
{/* Fixed Header with CSS Grid */}
<CRMTableHeader
columnsConfig={visibleColumns}
columnWidths={columnWidths}
totalSize={totalSize}
onReorder={handleReorderColumns}
onUpdateColumn={updateColumn}
onDeleteColumn={deleteColumn}
onResizeStart={handleResizeStart}
/>
{/* Virtualized Table Body */}
{conversationList.length > 0 && (
<tbody
style={{
display: 'grid',
height: `${rowVirtualizer.getTotalSize()}px`,
width: totalSize,
position: 'relative',
}}
>
{virtualItems.map((virtualRow) => {
const conversation = conversationList[virtualRow.index];
if (!conversation) return null;
return (
<VirtualizedTableRow
key=[redacted]
conversationId={conversation.id}
columns={visibleColumns}
columnWidths={columnWidths}
virtualRow={virtualRow}
rowVirtualizer={rowVirtualizer}
onCellBlur={handleCellBlur}
onClick={handleRowClick}
onMouseEnter={handleRowMouseEnter}
/>
);
})}
</tbody>
)}
</table>
{/* Empty state */}
{conversationList.length === 0 && (
<div className="flex flex-col items-center justify-center h-64">
<p className="text-muted-foreground mb-4">No conversations found</p>
<Button onClick={addRow} variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
Add your first conversation
</Button>
</div>
)}
{/* Loading indicator at bottom */}
{conversationsQuery.isFetchingNextPage && (
<div className="flex justify-center py-6">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
)}
{/* No more results indicator */}
{!hasNextPage && conversationList.length > 0 && (
<div className="text-muted-foreground py-6 text-center text-sm">
No more conversations
</div>
)}
</div>
</div>
)}
</div>
{/* Hotkey bar - fixed at bottom */}
<div className="w-full shrink-0">
<HotkeyBar />
</div>
</div>
</TooltipProvider>
);
}