crm-kanban.tsx27.8 KBView on GitHub /**
* CRM Kanban View Component
*
* Displays CRM conversations in a kanban board grouped by status.
* Supports drag & drop to change conversation status.
*/
import { useCrmCanvasRefresh } from '@/modules/crm/hooks/use-crm-canvas-refresh';
import {
closestCorners,
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from '@dnd-kit/core';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import type { ConversationWithMetadata } from '@/modules/conversations/slice/conversationsSlice';
import type { ConversationSummary } from '@/modules/conversations/slice/conversationsSlice';
import { ThreadDisplay } from '@/modules/threads/thread/components/thread-display';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Table as TableIcon, RefreshCw, X, Loader2 } from 'lucide-react';
import { useCRMConversations } from '../hooks/use-crm-conversations';
import { matchesEnumFilters } from '@/modules/crm/utils/compute-canvas-filters';
import { useConversationNavigation } from '@/modules/conversations';
import { useAllConversationEnumOptions } from '../hooks/use-crm';
import { KanbanColumn } from '@/components/ui/kanban-column';
import { getColorClass, getEnumDisplayText } from '../utils';
import { useAOPs } from '@/modules/aop/hooks/use-aops';
import { ConversationView } from './ConversationView';
import { useTRPC } from '@/providers/query-provider';
import { Skeleton } from '@/components/ui/skeleton';
import { useMutation } from '@tanstack/react-query';
import { CRMSearchInput } from './crm-search-input';
import { CRMKanbanCard } from './crm-kanban-card';
import { sortEnumOptions } from '../field-enums';
import type { CrmFieldEnumOption } from '../types';
import { Button } from '@/components/ui/button';
import { useCedarStore } from '@/modules/store';
import { useSearchParams } from 'react-router';
import { CRMFilterBar } from './CRMFilterBar';
import { format } from 'date-fns';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
export const CRMKanban = memo(function CRMKanban() {
const trpc = useTRPC();
// Fetch conversations with infinite scroll
const [conversationsQuery] = useCRMConversations();
const conversations = useCedarStore((state) => state.conversations);
const activeConversationId = useCedarStore((state) => state.activeConversationId);
const setActiveConversationId = useCedarStore((state) => state.setActiveConversationId);
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 setCurrentConversationList = useCedarStore((state) => state.setCurrentConversationList);
const getFilters = useCedarStore((state) => state.getFilters);
const filters = getFilters();
const setColumnFilter = useCedarStore((state) => state.setColumnFilter);
const selectedAopIds = useCedarStore((state) => state.selectedAopIds);
const clearAllColumnFilters = useCedarStore((state) => state.clearAllColumnFilters);
const setSearchQuery = useCedarStore((state) => state.setSearchQuery);
// Count active filters (excluding search)
const activeFilterCount = Object.entries(filters).filter(([key, value]) => {
if (key === 'search') return false;
if (Array.isArray(value)) return value.length > 0;
return value !== undefined && value !== '' && value !== null;
}).length;
// Check if data is still loading
const isLoading = conversationsQuery.isLoading || conversationsQuery.isFetching;
// Get view mode from URL params for toggle button
const [, setSearchParams] = useSearchParams();
// Get AOPs for type options
const { data: aopsData } = useAOPs();
// Fetch dynamic enum options from AOPs
const { data: statusOptionsData } = useAllConversationEnumOptions('status');
const { data: priorityOptionsData } = useAllConversationEnumOptions('priority');
// Track if we've initialized the default filters
const hasInitializedRef = useRef(false);
// Container ref for keyboard navigation
const containerRef = useRef<HTMLDivElement>(null);
// State for active drag
const [activeConversation, setActiveConversation] = useState<ConversationWithMetadata | null>(
null,
);
// Compute dynamic TYPE_OPTIONS from AOPs and loaded conversations
const TYPE_OPTIONS = useMemo(() => {
const typeSet = new Set<string>();
const aops = aopsData?.aops || [];
// Add AOP names
aops.forEach((aop) => {
if (aop.name) {
typeSet.add(aop.name);
}
});
// Add types from loaded conversations (derived from AOP names)
Object.values(conversations).forEach((convWithMetadata) => {
const aopId = convWithMetadata.data.conversation.aopId;
const aop = aopsData?.aops?.find((a) => a.id === aopId);
const type = aop?.name;
if (type) {
typeSet.add(type);
}
});
// Convert to sorted array
return Array.from(typeSet).sort((a, b) => a.localeCompare(b));
}, [aopsData?.aops, conversations]);
// Initialize default filters on mount
// NOTE: Type selection is now handled by useCRMConfiguration via setSelectedAop
// This effect only handles status default filter
useEffect(() => {
// Only run once when TYPE_OPTIONS first becomes available
if (hasInitializedRef.current || TYPE_OPTIONS.length === 0) return;
// Set default filters if none are set
let needsInitialization = false;
// Type filter is now derived from selectedAopIds (multi-select)
// The default AOP selection is handled by useCRMConfiguration
// Default to exclude "closed_lost" status (new status value)
if (!filters.excludedStatus || filters.excludedStatus.length === 0) {
setColumnFilter('status', { excluded: ['closed_lost'] });
needsInitialization = true;
}
if (needsInitialization || (selectedAopIds && selectedAopIds.length > 0)) {
hasInitializedRef.current = true;
}
}, [TYPE_OPTIONS, selectedAopIds, filters.excludedStatus, setColumnFilter]);
// Configure sensors for drag detection
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // Require 8px movement before drag starts
},
}),
);
// Convert conversations object to array and apply filters
const conversationsArray = useMemo(() => {
let filteredConversations = Object.values(conversations);
// Sort conversations by priority (urgent > high > medium > low), then by last contacted date
filteredConversations.sort((a, b) => {
// Priority order mapping
const priorityOrder: Record<string, number> = {
urgent: 4,
high: 3,
medium: 2,
low: 1,
};
const priorityA = priorityOrder[a.data.conversation.priority || 'medium'] || 2;
const priorityB = priorityOrder[b.data.conversation.priority || 'medium'] || 2;
// First sort by priority (higher priority first)
if (priorityA !== priorityB) {
return priorityB - priorityA;
}
// If priority is the same, sort by last contacted date (most recent first)
const dateA = a.data.conversation.lastContactedAt
? new Date(a.data.conversation.lastContactedAt).getTime()
: 0;
const dateB = b.data.conversation.lastContactedAt
? new Date(b.data.conversation.lastContactedAt).getTime()
: 0;
return dateB - dateA;
});
// Apply client-side filters
filteredConversations = filteredConversations.filter((convWithMetadata) => {
const conversation = convWithMetadata.data.conversation;
// Status / priority, shared with the list canvas so the two views cannot disagree about
// which deals a stage filter admits.
if (!matchesEnumFilters(conversation, filters)) return false;
// Filter by last contact date with operator support
if (filters.lastContact) {
const { date: filterDateStr, operator } = filters.lastContact;
const lastContactedAt = conversation.lastContactedAt
? new Date(conversation.lastContactedAt)
: null;
// Handle 'empty' operator - show only conversations with no lastContactedAt
if (operator === 'empty') {
if (lastContactedAt) return false;
} else {
// For date-based operators, conversations without a date are filtered out
if (!lastContactedAt) return false;
const filterDate = new Date(filterDateStr);
filterDate.setHours(0, 0, 0, 0); // Normalize to start of day
const contactDate = new Date(lastContactedAt);
contactDate.setHours(0, 0, 0, 0); // Normalize to start of day
switch (operator) {
case 'before':
if (contactDate >= filterDate) return false;
break;
case 'after':
if (contactDate <= filterDate) return false;
break;
case 'on':
if (contactDate.getTime() !== filterDate.getTime()) return false;
break;
}
}
}
// Filter by dealValue with operator support
if (filters.dealValue) {
const { value: filterValueStr, operator } = filters.dealValue;
const dealValue = conversation.dealValue;
// Handle 'empty' operator - show only conversations with no dealValue
if (operator === 'empty') {
if (dealValue !== null && dealValue !== undefined) return false;
} else {
// For comparison operators, conversations without a value are filtered out
if (dealValue === null || dealValue === undefined) return false;
const numValue = typeof dealValue === 'number' ? dealValue : Number(dealValue);
if (isNaN(numValue)) return false;
const filterValue = Number(filterValueStr);
if (isNaN(filterValue)) return false;
switch (operator) {
case 'gt':
if (numValue <= filterValue) return false;
break;
case 'lt':
if (numValue >= filterValue) return false;
break;
case 'eq':
if (numValue !== filterValue) return false;
break;
}
}
}
// Legacy filter by deal value (for backward compatibility)
if (filters.dealValueMin) {
const minValue = parseFloat(filters.dealValueMin);
if (!conversation.dealValue || conversation.dealValue < minValue) {
return false;
}
}
if (filters.dealValueMax) {
const maxValue = parseFloat(filters.dealValueMax);
if (!conversation.dealValue || conversation.dealValue > maxValue) {
return false;
}
}
return true;
});
return filteredConversations;
}, [conversations, filters]);
// Build conversation list for keyboard navigation
const conversationList = useMemo<ConversationSummary[]>(() => {
return conversationsArray.map((conv) => ({
id: conv.data.conversation.id,
name: conv.data.conversation.name || undefined,
}));
}, [conversationsArray]);
// Sync conversation list to store for keyboard navigation
useEffect(() => {
setCurrentConversationList(conversationList);
}, [conversationList, setCurrentConversationList]);
// Enable keyboard navigation (j/k, arrow keys, Enter, Escape, s to select hovered)
const { handleMouseEnter } = useConversationNavigation({
items: conversationList,
containerRef,
});
// Build dynamic status columns from AOP definitions
const STATUS_COLUMNS = useMemo(() => {
if (statusOptionsData?.options && statusOptionsData.options.length > 0) {
// Use dynamic status options from AOPs, sorted by enumOrder
const sorted = sortEnumOptions(statusOptionsData.options);
// `value: null` marks the synthetic "unset" option used by filter UIs; it has no
// board column (null-status conversations are not grouped) and is not a valid
// dnd-kit droppable identifier.
return sorted
.filter((opt): opt is CrmFieldEnumOption & { value: string } => opt.value !== null)
.map((opt) => ({
value: opt.value,
label: getEnumDisplayText(opt.value, statusOptionsData.options),
color: getColorClass(opt.color),
}));
}
// Fallback to empty array if no status options available
return [];
}, [statusOptionsData?.options]);
// Group conversations by status
const conversationsByStatus = useMemo(() => {
const grouped: Record<string, ConversationWithMetadata[]> = {};
// Initialize all columns
for (const column of STATUS_COLUMNS) {
grouped[column.value] = [];
}
// Group conversations by their status
for (const conv of conversationsArray) {
const status = conv.data.conversation.status;
if (status && grouped[status]) {
grouped[status].push(conv);
}
}
return grouped;
}, [conversationsArray, STATUS_COLUMNS]);
// Mutation to update conversation status
const updateConversationMutation = useMutation({
...trpc.crm.updateConversation.mutationOptions(),
onSuccess: (data) => {
// Show success toast only if external CRM was synced
if (data.externalCrmSynced) {
}
// Conversation will be updated in store automatically
},
onError: (error) => {
toast.error('Failed to update conversation', {
description: error.message,
});
},
});
const conversationIds = conversationList.map((c) => c.id);
const { handleRefresh: handleCrmKanbanFullRefresh, isPending: isCrmRefresh } =
useCrmCanvasRefresh(conversationIds, conversationsQuery.refetch);
// Handler to select a conversation (clicking a card just selects it)
const handleConversationClick = useCallback(
(conversation: ConversationWithMetadata) => {
setActiveConversationId(conversation.data.conversation.id);
},
[setActiveConversationId],
);
// Handler to open a conversation in full view (expand button)
const handleConversationExpand = useCallback(
(conversation: ConversationWithMetadata) => {
setActiveConversationId(conversation.data.conversation.id);
setIsConversationOpen(true);
},
[setActiveConversationId, setIsConversationOpen],
);
// Handle drag start
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const conversation = conversationsArray.find(
(c) => c.data.conversation.id === event.active.id,
);
if (conversation) {
setActiveConversation(conversation);
}
},
[conversationsArray],
);
// Handle drag end
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
setActiveConversation(null);
if (!over) return;
const conversationId = active.id as string;
const newStatus = over.id as string;
// Find the conversation being moved
const conversation = conversationsArray.find(
(c) => c.data.conversation.id === conversationId,
);
if (!conversation) return;
// If the conversation is already in this column, don't do anything
if (conversation.data.conversation.status === newStatus) return;
// Update the conversation status
toast.promise(
updateConversationMutation
.mutateAsync({
id: conversationId,
status: newStatus,
})
.then((data) => {
// Show external CRM success toast if applicable
if (data.externalCrmSynced) {
}
return data;
}),
{
loading: 'Moving conversation...',
success: `Moved to ${newStatus}`,
error: 'Failed to move conversation',
},
);
},
[conversationsArray, updateConversationMutation],
);
// Show loading state
if (isLoading && conversationsArray.length === 0) {
return (
<div className="flex h-full w-full gap-4 overflow-x-auto p-4">
{STATUS_COLUMNS.map((column) => (
<div key=[redacted] className="flex min-w-[780px] flex-col gap-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
))}
</div>
);
}
// Show empty state only after loading completes
if (!isLoading && conversationsArray.length === 0) {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="text-muted-foreground text-sm">No conversations found</div>
</div>
);
}
return (
<TooltipProvider delayDuration={300}>
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="flex h-full w-full gap-2 overflow-hidden py-2">
{/* Kanban Board Section - Hide when conversation or thread is open */}
<div
className={cn('flex h-full w-full', (isConversationOpen || isThreadOpen) && 'hidden')}
>
<div className="flex h-full w-full flex-col overflow-hidden dark:bg-sidebar bg-sidebar">
<div ref={containerRef} className="flex h-full flex-col">
{/* Header with Search and Filters */}
<div className="shrink-0 px-4 py-3">
<div className="flex items-center justify-between gap-4">
{/* Left side: Search Input and Action Buttons */}
<div className="flex items-center gap-2 flex-1">
<CRMSearchInput />
{isLoading && (
<div className="flex items-center justify-center">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
</div>
)}
{activeFilterCount > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => {
clearAllColumnFilters();
setSearchQuery(undefined);
}}
size="sm"
variant="secondary"
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>
{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>
)}
</div>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => void handleCrmKanbanFullRefresh()}
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
disabled={conversationsQuery.isFetching || isCrmRefresh}
>
<RefreshCw
className={cn(
'h-4 w-4',
(conversationsQuery.isFetching || isCrmRefresh) && 'animate-spin',
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Refresh list and sync up to 50 linked deals from CRM</p>
</TooltipContent>
</Tooltip>
</div>
{/* Right side: View Toggle Button */}
<div className="flex shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => {
setSearchParams({ view: 'table' });
}}
className="gap-2"
>
<TableIcon className="h-4 w-4" />
Table View
</Button>
</div>
</div>
{/* Filter Bar */}
<div className="mt-3">
<CRMFilterBar />
</div>
</div>
{/* Kanban Board */}
<div className="flex flex-1 gap-6 overflow-x-auto px-4">
{STATUS_COLUMNS.map((column) => {
const items = conversationsByStatus[column.value] || [];
return (
<KanbanColumn
key=[redacted]
id={column.value}
label={column.label}
color={column.color}
items={items}
activeItemId={activeConversationId}
getItemId={(conversation) => conversation.data.conversation.id}
renderItem={(conversation, isSelected) => (
<CRMKanbanCard
conversation={conversation}
isSelected={isSelected}
statusOptions={statusOptionsData?.options}
priorityOptions={priorityOptionsData?.options}
onMouseEnter={handleMouseEnter}
onClick={() => handleConversationClick(conversation)}
onExpandClick={() => handleConversationExpand(conversation)}
/>
)}
/>
);
})}
</div>
</div>
</div>
</div>
{/* Show conversation or thread display when open */}
{(isConversationOpen || isThreadOpen) && (
<div className="flex h-full w-full">
{isConversationOpen && !isThreadOpen ? (
// Show ConversationView when conversation is open without thread
<ConversationView
onClose={() => {
setIsConversationOpen(false);
}}
/>
) : isThreadOpen && threadId ? (
// Show ThreadDisplay when thread is open
<div className="flex h-full w-full max-w-full flex-col">
<ThreadDisplay />
</div>
) : null}
</div>
)}
</div>
{/* Drag overlay */}
<DragOverlay>
{activeConversation ? (
<div className="opacity-80">
<CRMKanbanCard
conversation={activeConversation}
isSelected={false}
isDragging
statusOptions={statusOptionsData?.options}
priorityOptions={priorityOptionsData?.options}
/>
</div>
) : null}
</DragOverlay>
</DndContext>
</TooltipProvider>
);
});