GroupedConversationCanvas.tsx10.5 KBView on GitHub /**
* GroupedConversationCanvas Component
*
* A variant of ConversationCanvas that groups conversations into sections:
* - Upcoming Meetings (calendar events in the next 24 hours)
* - Meeting Followups (post-meeting tasks, excluding upcoming meetings)
* - Tasks (remaining followups, excluding upcoming meetings)
*
* Upcoming meetings are sorted by event start time (ascending).
* Meeting followups and tasks are sorted by task due date (ascending - soonest first).
* This is used in the "Deal Actions" tab to prioritize upcoming and post-meeting tasks.
*
* Phase B: Subscribes to canvas.viewConfig directly, bypassing the CRMSlice singleton
* for filters, sorts, selectedAopIds, and presetId.
*/
import { useCrmCanvasRefresh } from '@/modules/crm/hooks/use-crm-canvas-refresh';
import type { Canvas } from '@/modules/canvas/types/canvas-types';
import { useGroupedConversations } from '../../hooks/use-grouped-conversations';
import { useCanvasConversations } from '../../hooks/use-canvas-conversations';
import { ConversationListHotkeys } from '../conversation-list-hotkeys';
import { PresetHotkeysLayer } from './PresetNavigationBar';
import { useIsFetching, useIsMutating } from '@tanstack/react-query';
import { ColumnConfigurationItem } from './ColumnConfigurationItem';
import { useConversationNavigation } from '@/modules/conversations';
import { useBulkSelection } from '../../hooks/use-bulk-selection';
import { CanvasFilterSearchSection } from './FilterSortConfigurationRow';
import { useCallback, useRef, useState, useEffect } from 'react';
import { ConversationItem } from './ConversationItem';
import { CRMSearchInput } from '../crm-search-input';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useCedarStore } from '@/modules/store';
import { motion } from 'motion/react';
import { cn } from '@/lib/utils';
export interface GroupedConversationCanvasProps {
title?: string;
/**
* Canvas entity — used to subscribe to viewConfig directly (Phase B).
* When provided, display config (selectedAopIds, presetId, filterSortConfig)
* comes from canvas.viewConfig instead of the global CRMSlice.
*/
canvas?: Canvas;
}
export function GroupedConversationCanvas({ canvas }: GroupedConversationCanvasProps) {
// Track if this is the first load for animation purposes
const [isFirstLoad, setIsFirstLoad] = useState(true);
// Fetch conversations via canvas-scoped hook.
// useCanvasConversations reads viewConfig + AOP data from useCanvasConfiguration
// internally — no manual override construction needed.
const [conversationsQuery, hasNextPage, loadMore] = useCanvasConversations(canvas?.id);
// Get list ordering from conversationsSlice
const conversationList = useCedarStore((state) => state.getCurrentConversationList());
// Group conversations into: Upcoming Meetings → Meeting Followups → Tasks
// The hook also syncs the grouped order back to the store's currentConversationList
const { groupedList, flatConversationList } = useGroupedConversations({ hoursAhead: 24 });
// Mark first load as complete after conversations are loaded
useEffect(() => {
if (conversationList.length > 0 && isFirstLoad) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setIsFirstLoad(false);
});
});
}
}, [conversationList.length, isFirstLoad]);
// Get selection from conversationsSlice (bulk operations — moved from crmSlice in Phase B)
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 - use flatConversationList for selection
const { getSelectMode, handleClickWithSelection, toggleAndSetAnchor } = useBulkSelection({
items: flatConversationList,
selectedIds,
setSelection: setConversationSelection,
toggleSelection: toggleConversationSelection,
clearSelection: clearConversationSelection,
anchorId: selectionAnchorId,
setAnchorId: setSelectionAnchorId,
});
// Loading state for refresh indicator
const isFetching = useIsFetching();
const isMutating = useIsMutating();
const conversationIds = conversationList.map((c) => c.id);
const { handleRefresh: handleCrmListRefresh, isPending: isCrmRefreshPending } =
useCrmCanvasRefresh(conversationIds, conversationsQuery.refetch);
const isLoading = isFetching > 0 || isMutating > 0 || isCrmRefreshPending;
// Refs for navigation
const parentRef = useRef<HTMLDivElement>(null);
// Enable keyboard navigation - use flatConversationList
const { handleMouseEnter } = useConversationNavigation({
items: flatConversationList,
containerRef: parentRef,
});
const handleItemClick = useCallback(
(conversationId: string) => {
handleClickWithSelection(conversationId, () => {
// Simple selection - just highlight the row
useCedarStore.getState().setActiveConversationId(conversationId);
});
},
[handleClickWithSelection],
);
const handleOpenConversation = useCallback((conversationId: string) => {
const state = useCedarStore.getState();
state.setActiveConversationId(conversationId);
state.setIsConversationOpen(true);
}, []);
const handleItemMouseEnter = useCallback(
(conversationId: string) => {
handleMouseEnter(conversationId);
// Dispatch custom event for hotkeys to track hovered conversation
window.dispatchEvent(
new CustomEvent('conversationHover', { detail: { id: conversationId } }),
);
},
[handleMouseEnter],
);
// Track animation index across items for stagger effect
let animationIndex = 0;
return (
<div className="flex h-full flex-col overflow-hidden">
{/* Hotkey scope management for CRM */}
<ConversationListHotkeys />
<PresetHotkeysLayer canvasId={canvas?.id} />
<div className="flex h-full flex-col">
{/* Top Bar: Search + Filters + Fields + Refresh */}
{canvas?.id ? (
<div className="shrink-0">
<CanvasFilterSearchSection
canvas={canvas}
onRefresh={() => void handleCrmListRefresh()}
isRefreshing={isLoading}
/>
</div>
) : (
<div className="flex shrink-0 items-center gap-2 px-2 py-1">
<CRMSearchInput />
</div>
)}
{/* Conversation List Area */}
<div
ref={parentRef}
className={cn(
'hide-link-indicator flex h-full w-full flex-1 overflow-hidden',
getSelectMode() === 'range' && 'select-none',
)}
>
{conversationsQuery.isLoading ? (
<div className="flex h-32 w-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : conversationList.length === 0 ? (
<div className="flex w-full items-center justify-center">
<div className="flex flex-col items-center justify-center gap-2 text-center">
<p className="text-lg">No conversations found</p>
<p className="text-muted-foreground text-md">
Try adjusting your filters or search criteria
</p>
</div>
</div>
) : (
<div className="flex flex-1 flex-col overflow-hidden">
{/* Scrollable grouped list — header + items scroll horizontally together */}
<div className="scrollbar-none flex-1 overflow-x-auto overflow-y-auto">
{/* Column Configuration Header */}
<ColumnConfigurationItem canvasId={canvas?.id} />
{groupedList.map((item) => {
if (item.type === 'header') {
return (
<div
key=[redacted]
className="bg-background sticky top-0 z-10 flex items-center gap-2 px-4 py-2"
>
<span className="text-foreground text-sm">{item.title}</span>
<span className="text-muted-foreground rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium dark:bg-neutral-800">
{item.count}
</span>
</div>
);
}
const currentAnimationIndex = animationIndex++;
const isFirst = currentAnimationIndex === 0;
return (
<motion.div
key=[redacted]
initial={isFirstLoad ? { opacity: 0 } : false}
animate={isFirstLoad ? { opacity: 1 } : false}
transition={
isFirstLoad
? {
delay: currentAnimationIndex * 0.01 + 0.1,
ease: 'easeOut',
}
: undefined
}
>
<ConversationItem
canvasId={canvas?.id ?? ''}
conversationId={item.id}
isFirst={isFirst}
onClick={handleItemClick}
onToggleWithAnchor={toggleAndSetAnchor}
onMouseEnter={handleItemMouseEnter}
onOpenConversation={handleOpenConversation}
/>
</motion.div>
);
})}
{/* Loading indicator at bottom */}
{conversationsQuery.isFetchingNextPage && (
<div className="flex w-full justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{/* Load more trigger area */}
{hasNextPage && !conversationsQuery.isFetchingNextPage && (
<div className="flex w-full justify-center py-4">
<Button variant="ghost" size="sm" onClick={loadMore}>
Load more
</Button>
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
}