KanbanConversationCanvas.tsx22.0 KBView on GitHub
/**
 * KanbanConversationCanvas
 *
 * Canvas-scoped kanban board for CRM conversations.
 *
 * - Columns are determined by a configurable groupByField (default: status).
 * - Cards show configurable fields.
 * - Drag & drop moves a conversation to a new column (updates the grouped field).
 * - Configuration is persisted to canvas.viewConfig via updateCanvasViewConfig + saveCanvasViewConfig.
 */

import {
  closestCorners,
  DndContext,
  DragOverlay,
  PointerSensor,
  useSensor,
  useSensors,
  type DragEndEvent,
  type DragStartEvent,
} from '@dnd-kit/core';
import { TooltipProvider } from '@/components/ui/tooltip';
import type {
  KanbanCardField,
  KanbanCardTitleField,
  KanbanConversationViewConfig,
  KanbanGroupByField,
  NextStepDateBucket,
} from '@/modules/canvas/types/canvas-types';
import {
  DEFAULT_KANBAN_CARD_FIELDS,
  NEXT_STEP_DATE_BUCKET_IDS,
  NEXT_STEP_DATE_BUCKET_LABELS,
} from '@/modules/canvas/types/canvas-types';
import type { Canvas } from '@/modules/canvas/types/canvas-types';
import type { ConversationWithMetadata } from '@/modules/conversations/slice/conversationsSlice';
import { useAllConversationEnumOptions } from '@/modules/crm/hooks/use-crm';
import {
  getDotColor,
  getEnumDisplayText,
  getTextColorClass,
  STATUS_ICON_MAP,
} from '@/modules/crm/utils';
import { useCrmCanvasRefresh } from '@/modules/crm/hooks/use-crm-canvas-refresh';
import { sortEnumOptions } from '@/modules/crm/field-enums';
import type { CrmFieldEnumOption } from '@/modules/crm/types';
import { CanvasKanbanColumn } from './CanvasKanbanColumn';
import { CanvasKanbanCard } from './CanvasKanbanCard';
import { KanbanConfigPopover } from './KanbanConfigPopover';
import { CanvasFilterSearchSection } from '../conversation-canvas/FilterSortConfigurationRow';
import { useCanvasConversations } from '@/modules/crm/hooks/use-canvas-conversations';
import { useCedarStore } from '@/modules/store';
import { useTRPC } from '@/providers/query-provider';
import { useMutation } from '@tanstack/react-query';
import { useCallback, useMemo, useState } from 'react';
import { Loader2, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { useScrollActive } from '@/hooks/use-scroll-active';

// ── Next Step Date bucketing ─────────────────────────────────────────────────

function startOfDay(date: Date): Date {
  return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}

function getNextStepDateBucket(nextStepDate: Date | string | null | undefined): NextStepDateBucket {
  if (!nextStepDate) return 'no_date';

  const date = startOfDay(new Date(nextStepDate));
  const today = startOfDay(new Date());
  const diffMs = date.getTime() - today.getTime();
  const diffDays = diffMs / 86_400_000;

  if (diffDays === 0) return 'today';
  if (diffDays > 0) return 'future';
  if (diffDays >= -7) return 'past_week';
  return 'overdue';
}

/**
 * Returns a representative Date to assign when dragging a conversation into a
 * nextStepDate bucket. null = clear the nextStepDate.
 */
function representativeDateForBucket(bucket: NextStepDateBucket): Date | null {
  const today = startOfDay(new Date());
  switch (bucket) {
    case 'today':
      return today;
    case 'future':
      return new Date(today.getTime() + 7 * 86_400_000);
    case 'past_week':
      return new Date(today.getTime() - 3 * 86_400_000);
    case 'overdue':
      return new Date(today.getTime() - 14 * 86_400_000);
    case 'no_date':
      return null;
  }
}

// ─────────────────────────────────────────────────────────────────────────────

interface KanbanConversationCanvasProps {
  canvas: Canvas;
}

const NEXT_STEP_DATE_DOTS: Record<string, string> = {
  overdue: 'bg-red-500',
  past_week: 'bg-amber-500',
  today: 'bg-blue-500',
  future: 'bg-emerald-500',
  no_date: 'bg-muted-foreground/40',
};

/**
 * The column-header glyph: the status option's own icon, tinted with its colour — the icon-led
 * header the task board uses. Returns undefined when the option has no icon (priority, date
 * buckets), so the column falls back to its colour dot.
 */
function columnHeaderIcon(icon: string | null, color: string | null): React.ReactNode | undefined {
  const Icon = icon ? STATUS_ICON_MAP[icon] : undefined;
  if (!Icon) return undefined;
  return <Icon className={cn('size-4 shrink-0', getTextColorClass(color ?? 'gray'))} />;
}

export function KanbanConversationCanvas({ canvas }: KanbanConversationCanvasProps) {
  const trpc = useTRPC();
  const viewConfig = canvas.viewConfig as KanbanConversationViewConfig;

  const groupByField: KanbanGroupByField = viewConfig.groupByField ?? 'status';
  const cardFields: KanbanCardField[] = viewConfig.cardFields ?? DEFAULT_KANBAN_CARD_FIELDS;
  const cardTitleField: KanbanCardTitleField = viewConfig.cardTitleField ?? 'company';
  const summaryField = viewConfig.summaryField;
  const summaryAggregation = viewConfig.summaryAggregation ?? 'sum';

  // Store actions
  const updateCanvasViewConfig = useCedarStore((state) => state.updateCanvasViewConfig);
  const saveCanvasViewConfig = useCedarStore((state) => state.saveCanvasViewConfig);
  const conversations = useCedarStore((state) => state.conversations);
  const currentConversationList = useCedarStore((state) => state.currentConversationList);
  const activeConversationId = useCedarStore((state) => state.activeConversationId);
  const setActiveConversationId = useCedarStore((state) => state.setActiveConversationId);
  const setIsConversationOpen = useCedarStore((state) => state.setIsConversationOpen);

  // Fetch conversations for this canvas (populates store.conversations).
  // Kanban has no infinite scroll, so request a larger first page than the list view.
  const [conversationsQuery] = useCanvasConversations(canvas.id, undefined, {
    limit: 200,
    fetchAllPages: true,
  });

  const conversationIds = currentConversationList.map((c) => c.id);
  const { handleRefresh: handleCrmKanbanRefresh, isPending: isCrmRefreshPending } =
    useCrmCanvasRefresh(conversationIds, conversationsQuery.refetch);

  // Enum options for status / priority columns
  const { data: statusOptionsData } = useAllConversationEnumOptions('status');
  const { data: priorityOptionsData } = useAllConversationEnumOptions('priority');

  // Active drag state
  const [draggingId, setDraggingId] = useState<string | null>(null);
  const boardScroll = useScrollActive<HTMLDivElement>();

  // DnD sensors – 8px minimum movement before drag activates
  const sensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
  );


  // Column definitions from the chosen groupByField
  const columns = useMemo(() => {
    if (groupByField === 'nextStepDate') {
      return NEXT_STEP_DATE_BUCKET_IDS.map((id) => ({
        value: id,
        label: NEXT_STEP_DATE_BUCKET_LABELS[id],
        dotClass: NEXT_STEP_DATE_DOTS[id] ?? 'bg-muted-foreground/40',
        color: null,
        icon: null,
      }));
    }

    const optionsData = groupByField === 'status' ? statusOptionsData : priorityOptionsData;

    if (!optionsData?.options?.length) return [];
    const sorted = sortEnumOptions(optionsData.options);
    // `value: null` marks the synthetic "unset" option used by filter UIs; the
    // board has no column for it (unset conversations are appended to the first
    // column below), and a null id 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, optionsData.options),
        dotClass: getDotColor(opt.color).split(' ')[0] ?? 'bg-muted-foreground/40',
        color: opt.color,
        // Only status options carry an icon; priority falls back to the colour dot.
        icon: opt.icon ?? null,
      }));
  }, [groupByField, statusOptionsData, priorityOptionsData]);

  // Group conversations by the active groupByField, preserving sort order from currentConversationList
  const conversationsByColumn = useMemo(() => {
    const grouped: Record<string, ConversationWithMetadata[]> = {};
    for (const col of columns) {
      grouped[col.value] = [];
    }

    // Iterate in sort order (currentConversationList is ordered by the backend/client sort)
    const orderedConvs = currentConversationList
      .map(({ id }) => conversations[id])
      .filter((c): c is ConversationWithMetadata => Boolean(c));

    if (groupByField === 'nextStepDate') {
      for (const conv of orderedConvs) {
        const bucket = getNextStepDateBucket(conv.data.conversation.nextStepDate);
        grouped[bucket]?.push(conv);
      }
      return grouped;
    }

    const unset: ConversationWithMetadata[] = [];
    for (const conv of orderedConvs) {
      const val = conv.data.conversation[groupByField] as string | null;
      if (val && grouped[val] !== undefined) {
        grouped[val].push(conv);
      } else {
        unset.push(conv);
      }
    }

    // Append unset conversations to the first column
    if (unset.length > 0 && columns.length > 0) {
      grouped[columns[0].value] = [...grouped[columns[0].value], ...unset];
    }

    return grouped;
  }, [conversations, currentConversationList, columns, groupByField]);

  // Mutation to update a conversation field when dragged to a new column
  const { mutateAsync: updateConversation } = useMutation(
    trpc.crm.updateConversation.mutationOptions(),
  );

  const handleDragStart = useCallback((event: DragStartEvent) => {
    setDraggingId(String(event.active.id));
  }, []);

  const handleDragEnd = useCallback(
    async (event: DragEndEvent) => {
      const { active, over } = event;
      setDraggingId(null);

      if (!over) return;

      const conversationId = active.id as string;
      const newColumnValue = over.id as string;

      const conv = useCedarStore.getState().conversations[conversationId];
      if (!conv) return;

      // Determine the actual update payload
      let updatePayload: Record<string, string | null | Date>;
      let optimisticConvUpdate: typeof conv.data.conversation;

      if (groupByField === 'nextStepDate') {
        const currentBucket = getNextStepDateBucket(conv.data.conversation.nextStepDate);
        if (currentBucket === newColumnValue) return;

        const newDate = representativeDateForBucket(newColumnValue as NextStepDateBucket);
        updatePayload = { nextStepDate: newDate };
        optimisticConvUpdate = { ...conv.data.conversation, nextStepDate: newDate };
      } else {
        const currentValue = conv.data.conversation[groupByField] as string | null;
        if (currentValue === newColumnValue) return;

        updatePayload = { [groupByField]: newColumnValue };
        optimisticConvUpdate = { ...conv.data.conversation, [groupByField]: newColumnValue };
      }

      // Optimistically update the single conversation in the store
      const store = useCedarStore.getState();
      store.setConversations({
        [conversationId]: { ...conv.data, conversation: optimisticConvUpdate },
      });

      try {
        await updateConversation({ id: conversationId, ...updatePayload });
      } catch {
        toast.error('Failed to move conversation');
        store.setConversations({ [conversationId]: conv.data });
      }
    },
    [groupByField, updateConversation],
  );

  // Config persistence helpers
  const handleGroupByChange = useCallback(
    (field: KanbanGroupByField) => {
      updateCanvasViewConfig(canvas.id, { ...viewConfig, groupByField: field });
      void saveCanvasViewConfig(canvas.id);
    },
    [canvas.id, viewConfig, updateCanvasViewConfig, saveCanvasViewConfig],
  );

  const handleCardFieldsChange = useCallback(
    (fields: KanbanCardField[]) => {
      updateCanvasViewConfig(canvas.id, { ...viewConfig, cardFields: fields });
      void saveCanvasViewConfig(canvas.id);
    },
    [canvas.id, viewConfig, updateCanvasViewConfig, saveCanvasViewConfig],
  );

  const handleCardTitleFieldChange = useCallback(
    (field: KanbanCardTitleField) => {
      updateCanvasViewConfig(canvas.id, { ...viewConfig, cardTitleField: field });
      void saveCanvasViewConfig(canvas.id);
    },
    [canvas.id, viewConfig, updateCanvasViewConfig, saveCanvasViewConfig],
  );

  const handleSummaryFieldChange = useCallback(
    (field: string | undefined) => {
      updateCanvasViewConfig(canvas.id, { ...viewConfig, summaryField: field });
      void saveCanvasViewConfig(canvas.id);
    },
    [canvas.id, viewConfig, updateCanvasViewConfig, saveCanvasViewConfig],
  );

  const handleSummaryAggregationChange = useCallback(
    (agg: 'sum' | 'average') => {
      updateCanvasViewConfig(canvas.id, { ...viewConfig, summaryAggregation: agg });
      void saveCanvasViewConfig(canvas.id);
    },
    [canvas.id, viewConfig, updateCanvasViewConfig, saveCanvasViewConfig],
  );

  // Enum fields that support column-level hide (nextStepDate uses date ranges — not supported)
  const supportsColumnHide = groupByField !== 'nextStepDate';

  // Set of column values currently excluded via filterSortConfiguration
  const excludedColumnValues = useMemo<Set<string>>(() => {
    if (!supportsColumnHide) return new Set();
    const filter = viewConfig.filterSortConfiguration?.[groupByField]?.filter;
    return new Set<string>((filter?.excluded ?? []).filter((v): v is string => v !== null));
  }, [viewConfig.filterSortConfiguration, groupByField, supportsColumnHide]);

  // Visible columns — filter out any whose value is in the excluded set
  const visibleColumns = useMemo(
    () => columns.filter((col) => !excludedColumnValues.has(col.value)),
    [columns, excludedColumnValues],
  );

  // Per-column formatted summary line (e.g. "SUM $1.2M")
  const summaryLines = useMemo<Record<string, string>>(() => {
    if (!summaryField) return {};
    const result: Record<string, string> = {};
    for (const col of visibleColumns) {
      const items = conversationsByColumn[col.value] ?? [];
      const values = items
        .map((conv) => {
          const v = conv.data.conversation[summaryField as keyof typeof conv.data.conversation];
          if (typeof v === 'number') return v;
          if (typeof v === 'string') { const n = parseFloat(v); return isNaN(n) ? null : n; }
          return null;
        })
        .filter((v): v is number => v !== null);

      const total = values.reduce((a, b) => a + b, 0);
      const computed = summaryAggregation === 'average'
        ? (values.length > 0 ? total / values.length : 0)
        : total;

      // Format value
      let formatted: string;
      if (summaryField === 'dealValue') {
        formatted = `$${computed.toLocaleString(undefined, { maximumFractionDigits: 0 })}`;
      } else {
        formatted = computed.toLocaleString();
      }

      const label = summaryAggregation === 'average' ? 'AVG' : 'SUM';
      result[col.value] = `${label} ${formatted}`;
    }
    return result;
  }, [summaryField, summaryAggregation, visibleColumns, conversationsByColumn]);

  // Toggle a column's visibility by adding/removing its value from excluded filter
  const handleToggleColumnVisibility = useCallback(
    (columnValue: string) => {
      const currentConfig = viewConfig.filterSortConfiguration ?? {};
      const currentFilter = currentConfig[groupByField]?.filter ?? {};
      const excluded = (currentFilter.excluded ?? []).filter((v): v is string => v !== null);
      const isExcluded = excluded.includes(columnValue);
      const newExcluded = isExcluded
        ? excluded.filter((v) => v !== columnValue)
        : [...excluded, columnValue];
      const newFilter = newExcluded.length > 0 || (currentFilter.selected?.length ?? 0) > 0
        ? { ...currentFilter, excluded: newExcluded.length > 0 ? newExcluded : undefined }
        : undefined;
      const nextConfig: NonNullable<KanbanConversationViewConfig['filterSortConfiguration']> = {
        ...currentConfig,
      };
      if (newFilter) {
        nextConfig[groupByField] = { ...currentConfig[groupByField], filter: newFilter };
      } else {
        delete nextConfig[groupByField];
      }
      updateCanvasViewConfig(canvas.id, {
        ...viewConfig,
        filterSortConfiguration: nextConfig,
      });
      void saveCanvasViewConfig(canvas.id);
    },
    [canvas.id, viewConfig, groupByField, updateCanvasViewConfig, saveCanvasViewConfig],
  );

  const isLoading = conversationsQuery.isLoading;
  const draggingConversation = draggingId ? conversations[draggingId] : null;

  if (isLoading && Object.keys(conversations).length === 0) {
    return (
      <div className="flex h-full w-full items-center justify-center">
        <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
      </div>
    );
  }

  return (
    <TooltipProvider delayDuration={300}>
      <DndContext
        sensors={sensors}
        collisionDetection={closestCorners}
        onDragStart={handleDragStart}
        onDragEnd={handleDragEnd}
        onDragCancel={() => setDraggingId(null)}
      >
        <div className="flex h-full w-full flex-col overflow-hidden">
          {/* Filter / search bar */}
          <div className="shrink-0 px-3 pt-2">
            <CanvasFilterSearchSection canvas={canvas} />
          </div>

          {/* Toolbar */}
          <div className="flex shrink-0 items-center justify-between gap-2 px-3 py-1">
            <div className="flex items-center gap-2">
              {isLoading && (
                <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
              )}
              <span className="text-xs text-muted-foreground">
                {currentConversationList.length} conversations
              </span>
            </div>
            <div className="flex items-center gap-1">
              <Button
                variant="ghost"
                size="sm"
                className="h-8 w-8 p-0"
                onClick={() => void handleCrmKanbanRefresh()}
                disabled={conversationsQuery.isFetching || isCrmRefreshPending}
              >
                <RefreshCw
                  className={cn(
                    'h-3.5 w-3.5',
                    (conversationsQuery.isFetching || isCrmRefreshPending) && 'animate-spin',
                  )}
                />
              </Button>
              <KanbanConfigPopover
                groupByField={groupByField}
                cardTitleField={cardTitleField}
                cardFields={cardFields}
                summaryField={summaryField}
                summaryAggregation={summaryAggregation}
                onGroupByChange={handleGroupByChange}
                onCardTitleFieldChange={handleCardTitleFieldChange}
                onCardFieldsChange={handleCardFieldsChange}
                onSummaryFieldChange={handleSummaryFieldChange}
                onSummaryAggregationChange={handleSummaryAggregationChange}
              />
            </div>
          </div>

          {/* Board */}
          {/* The board's horizontal scrollbar was the one that stayed visible at rest — the
              columns are already `scrollbar-none`. It only shows while you're actually
              scrolling; `scrollbar-gutter: stable` reserves its width either way so the
              columns don't shift when it appears. */}
          <div
            ref={boardScroll.ref}
            className={cn(
              // `min-h-0` bounds the columns' height so each one scrolls its own cards internally
              // rather than the whole board growing — the task board's scroll model.
              'flex min-h-0 flex-1 gap-3 overflow-x-auto px-3 pb-3 [scrollbar-gutter:stable]',
              boardScroll.scrolling
                ? '[scrollbar-color:rgba(156,163,175,0.8)_transparent] dark:[scrollbar-color:rgba(255,255,255,0.2)_transparent]'
                : '[scrollbar-color:transparent_transparent]',
            )}
          >
            {visibleColumns.map((column) => {
              const items = conversationsByColumn[column.value] ?? [];
              return (
                <CanvasKanbanColumn
                  key=[redacted]
                  id={column.value}
                  label={column.label}
                  dotClass={column.dotClass}
                  headerIcon={columnHeaderIcon(column.icon, column.color)}
                  items={items}
                  activeItemId={activeConversationId}
                  getItemId={(conv) => conv.data.conversation.id}
                  onHide={supportsColumnHide
                    ? () => handleToggleColumnVisibility(column.value)
                    : undefined}
                  summaryLine={summaryLines[column.value]}
                  renderItem={(conv, isSelected) => (
                    <CanvasKanbanCard
                      conversation={conv}
                      cardFields={cardFields}
                      cardTitleField={cardTitleField}
                      isSelected={isSelected}
                      statusOptions={statusOptionsData?.options}
                      priorityOptions={priorityOptionsData?.options}
                      onClick={() => setActiveConversationId(conv.data.conversation.id)}
                      onExpandClick={() => {
                        setActiveConversationId(conv.data.conversation.id);
                        setIsConversationOpen(true);
                      }}
                    />
                  )}
                />
              );
            })}
          </div>
        </div>

        {/* Drag ghost */}
        <DragOverlay dropAnimation={null}>
          {draggingConversation && (
            <CanvasKanbanCard
              conversation={draggingConversation}
              cardFields={cardFields}
              cardTitleField={cardTitleField}
              isSelected={false}
              isDragging
              statusOptions={statusOptionsData?.options}
              priorityOptions={priorityOptionsData?.options}
            />
          )}
        </DragOverlay>
      </DndContext>
    </TooltipProvider>
  );
}