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