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