PipelineView.tsx14.6 KBView on GitHub
import {
  DndContext,
  DragOverlay,
  PointerSensor,
  closestCenter,
  useSensor,
  useSensors,
  type DragEndEvent,
  type DragStartEvent,
} from '@dnd-kit/core';
import {
  NewCanvasScreen,
  buildCanvasViewConfig,
  type CanvasTemplate,
} from '@/modules/home/components/NewCanvasScreen';
import {
  SortableContext,
  arrayMove,
  horizontalListSortingStrategy,
  useSortable,
} from '@dnd-kit/sortable';
import {
  LargeTabs,
  LargeTabsContent,
  LargeTabsList,
  LargeTabsTrigger,
} from '@/components/ui/tabs';
import { CEDAR_COLORS } from '@/components/ui/SexyColourPicker';
import { CanvasRenderer } from '@/modules/canvas/components/CanvasRenderer';
import { ActiveViewDisplay } from '@/components/ui/active-view-display';
import type { Canvas } from '@/modules/canvas/types/canvas-types';
import { useCanvases } from '@/modules/canvas/hooks/use-canvases';
import { restrictToHorizontalAxis } from '@dnd-kit/modifiers';
import { useCallback, useEffect, useState } from 'react';
import { useHasCrmConnected } from '@/modules/integrations/use-has-crm-connected';
import { useTRPC } from '@/providers/query-provider';
import { useMutation } from '@tanstack/react-query';
import { PipelineCanvas } from './PipelineCanvas';
import { useCedarStore } from '@/modules/store';
import { CSS } from '@dnd-kit/utilities';
import { Home } from 'lucide-react';
import { cn } from '@/lib/utils';

const PIPELINE_TAB_ID = '__pipeline__';
const LAST_PIPELINE_TAB_KEY=[redacted];

// ── Sortable tab wrapper ───────────────────────────────────────────────────────

function SortableTabTrigger({
  id,
  value,
  onClose,
  isDraggingActive,
  colour,
  children,
}: {
  id: string;
  value: string;
  onClose?: () => void;
  isDraggingActive: boolean;
  colour?: string;
  children: React.ReactNode;
}) {
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
    id,
  });

  return (
    <div
      ref={setNodeRef}
      className={cn('flex items-end', isDraggingActive && !isDragging && 'transition-transform')}
      style={{
        transform: CSS.Transform.toString(transform),
        transition,
        opacity: isDragging ? 0 : 1,
        zIndex: isDragging ? 10 : undefined,
        cursor: isDragging ? 'grabbing' : 'grab',
      }}
      {...attributes}
      {...listeners}
    >
      <LargeTabsTrigger value={value} onClose={onClose}>
        <span
          className="mr-1 h-2.5 w-2.5 flex-shrink-0 rounded-full ring-1 ring-black/10 dark:ring-white/10"
          style={{ backgroundColor: colour ?? '#9aa0a6' }}
        />
        {children}
      </LargeTabsTrigger>
    </div>
  );
}

// ── PipelineView ───────────────────────────────────────────────────────────────

interface DynamicTab {
  id: string;
  title: string;
  subtitle: string;
}

export function PipelineView() {
  const [dynamicTabs, setDynamicTabs] = useState<DynamicTab[]>([]);
  const [activeDynamicTabId, setActiveDynamicTabId] = useState<string | null>(null);
  const [draggingId, setDraggingId] = useState<string | null>(null);

  const { isLoading: isLoadingCanvases } = useCanvases();

  const homeViewCanvasIds = useCedarStore((state) => state.homeViewCanvasIds);
  const canvasesById = useCedarStore((state) => state.canvasesById);
  const activeHomeViewCanvasId = useCedarStore((state) => state.activeCanvasId);
  const setActiveHomeViewCanvasId = useCedarStore((state) => state.setActiveCanvasId);
  const removeCanvas = useCedarStore((state) => state.removeCanvas);
  const reorderMyHomeView = useCedarStore((state) => state.reorderMyHomeView);
  const upsertCanvas = useCedarStore((state) => state.upsertCanvas);
  const setHomeViewCanvasIds = useCedarStore((state) => state.setHomeViewCanvasIds);

  const trpc = useTRPC();
  const hasCrmConnected = useHasCrmConnected();
  const { mutate: createCanvas } = useMutation(trpc.canvas.createCanvas.mutationOptions());

  const homeViewCanvases = homeViewCanvasIds
    .map((id) => canvasesById[id])
    .filter((c): c is Canvas => !!c && c.type !== 'report' && c.type !== 'statistics');

  // Restore last active pipeline tab on mount
  useEffect(() => {
    if (activeHomeViewCanvasId) return;
    const savedTabId = localStorage.getItem(LAST_PIPELINE_TAB_KEY);
    if (savedTabId && savedTabId !== PIPELINE_TAB_ID && homeViewCanvasIds.includes(savedTabId)) {
      setActiveHomeViewCanvasId(savedTabId);
    }
  }, [homeViewCanvasIds]); // eslint-disable-line react-hooks/exhaustive-deps

  const activeTabId = activeDynamicTabId ?? activeHomeViewCanvasId ?? PIPELINE_TAB_ID;

  useEffect(() => {
    localStorage.setItem(LAST_PIPELINE_TAB_KEY, activeTabId);
  }, [activeTabId]);

  // ── DnD ────────────────────────────────────────────────────────────────────

  const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));

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

  const handleDragEnd = useCallback(
    (event: DragEndEvent) => {
      setDraggingId(null);
      const { active, over } = event;
      if (!over || active.id === over.id) return;

      const oldIndex = homeViewCanvasIds.indexOf(String(active.id));
      const newIndex = homeViewCanvasIds.indexOf(String(over.id));
      if (oldIndex === -1 || newIndex === -1) return;

      const reordered = arrayMove(homeViewCanvasIds, oldIndex, newIndex);
      void reorderMyHomeView(reordered);
    },
    [homeViewCanvasIds, reorderMyHomeView],
  );

  // ── Tab handlers ───────────────────────────────────────────────────────────

  const activeView = useCedarStore((state) => state.getActiveView());
  const newEmail = useCedarStore((state) => state.newEmail);

  const handleAddTab = useCallback(() => {
    setDynamicTabs((prev) => prev.filter((t) => !t.id.startsWith('new-canvas-')));
    const newTabId = `new-canvas-${Date.now()}`;
    const newTab: DynamicTab = {
      id: newTabId,
      title: 'New Canvas',
      subtitle: 'Choose a template',
    };
    setDynamicTabs((prev) => [...prev, newTab]);
    setActiveHomeViewCanvasId(null);
    setActiveDynamicTabId(newTabId);
  }, [setActiveHomeViewCanvasId]);

  const handleCreateForTemplate = useCallback(
    (template: CanvasTemplate) => {
      const viewConfig = buildCanvasViewConfig(template.type, { hasCrmConnected });
      const colour = CEDAR_COLORS.dark[Math.floor(Math.random() * CEDAR_COLORS.dark.length)];

      createCanvas(
        {
          title: template.title,
          type: template.type,
          description: template.description,
          viewConfig,
          data: {},
          homeViewOrder: homeViewCanvasIds.length,
          colour,
        },
        {
          onSuccess: (created) => {
            const canvas: Canvas = {
              id: created.id,
              type: created.type as Canvas['type'],
              title: created.title,
              description: created.description ?? undefined,
              data: (created.data as Canvas['data']) ?? {},
              viewConfig: created.viewConfig as Canvas['viewConfig'],
              primaryOwner: created.primaryOwner,
              orgId: created.orgId ?? undefined,
              homeViewOrder: created.homeViewOrder ?? null,
              colour: (created as { colour?: string }).colour ?? undefined,
              icon: (created as { icon?: string }).icon ?? undefined,
              createdAt:
                created.createdAt instanceof Date
                  ? created.createdAt.toISOString()
                  : String(created.createdAt),
              updatedAt:
                created.updatedAt instanceof Date
                  ? created.updatedAt.toISOString()
                  : String(created.updatedAt),
            };
            upsertCanvas(canvas);
            setHomeViewCanvasIds([...homeViewCanvasIds, canvas.id]);
            setActiveHomeViewCanvasId(canvas.id);
            useCedarStore.getState().switchThread(canvas.id);
          },
        },
      );
    },
    [
      createCanvas,
      hasCrmConnected,
      homeViewCanvasIds,
      setHomeViewCanvasIds,
      upsertCanvas,
      setActiveHomeViewCanvasId,
    ],
  );

  const handleCanvasCreated = useCallback(
    (canvas: Canvas, fromTabId: string) => {
      setDynamicTabs((prev) => prev.filter((t) => t.id !== fromTabId));
      setActiveDynamicTabId(null);
      setActiveHomeViewCanvasId(canvas.id);
    },
    [setActiveHomeViewCanvasId],
  );

  const handleCloseCanvas = useCallback(
    (canvasId: string) => {
      if (activeHomeViewCanvasId === canvasId) {
        const idx = homeViewCanvasIds.indexOf(canvasId);
        const nextId = homeViewCanvasIds[idx - 1] ?? homeViewCanvasIds[idx + 1] ?? null;
        setActiveHomeViewCanvasId(nextId);
      }
      removeCanvas(canvasId);
    },
    [activeHomeViewCanvasId, homeViewCanvasIds, removeCanvas, setActiveHomeViewCanvasId],
  );

  const handleCloseDynamicTab = useCallback(
    (tabId: string) => {
      setDynamicTabs((prev) => prev.filter((t) => t.id !== tabId));
      if (activeDynamicTabId === tabId) {
        setActiveDynamicTabId(null);
        if (homeViewCanvasIds.length > 0) {
          setActiveHomeViewCanvasId(homeViewCanvasIds[0]);
        }
      }
    },
    [activeDynamicTabId, homeViewCanvasIds, setActiveHomeViewCanvasId],
  );

  const handleTabChange = useCallback(
    (tabId: string) => {
      if (tabId === PIPELINE_TAB_ID) {
        setActiveHomeViewCanvasId(null);
        setActiveDynamicTabId(null);
      } else if (canvasesById[tabId]) {
        setActiveHomeViewCanvasId(tabId);
        setActiveDynamicTabId(null);
      } else {
        setActiveHomeViewCanvasId(null);
        setActiveDynamicTabId(tabId);
      }
    },
    [canvasesById, setActiveHomeViewCanvasId],
  );

  const renderCanvasTabContent = useCallback(
    (canvas: Canvas) => (
      <LargeTabsContent key=[redacted] value={canvas.id} className="flex h-full flex-col">
        <CanvasRenderer canvas={canvas} className="min-h-0 flex-1" />
      </LargeTabsContent>
    ),
    [],
  );

  if (isLoadingCanvases && homeViewCanvases.length === 0) {
    return (
      <div className="flex h-full w-full items-center justify-center">
        <p className="text-muted-foreground">Loading...</p>
      </div>
    );
  }

  return (
    <div className="flex h-full w-full flex-col">
      <div className="min-h-0 flex-1">
        <div className={cn('h-full w-full bg-sunken', (activeView !== null || newEmail) && 'hidden')}>
          <div className="flex h-full w-full flex-col overflow-hidden">
            <LargeTabs
              value={activeTabId}
              onValueChange={handleTabChange}
              className="flex h-full flex-col bg-surface"
            >
              <DndContext
                sensors={sensors}
                collisionDetection={closestCenter}
                modifiers={[restrictToHorizontalAxis]}
                onDragStart={handleDragStart}
                onDragEnd={handleDragEnd}
              >
                <LargeTabsList onAddTab={handleAddTab}>
                  {/* Fixed Pipeline home tab */}
                  <LargeTabsTrigger value={PIPELINE_TAB_ID}>
                    <Home className="h-4 w-4" />
                  </LargeTabsTrigger>

                  <SortableContext
                    items={homeViewCanvasIds}
                    strategy={horizontalListSortingStrategy}
                  >
                    {homeViewCanvases.map((canvas) => (
                      <SortableTabTrigger
                        key=[redacted]
                        id={canvas.id}
                        value={canvas.id}
                        onClose={() => handleCloseCanvas(canvas.id)}
                        isDraggingActive={draggingId !== null}
                        colour={canvas.colour}
                      >
                        {canvas.title}
                      </SortableTabTrigger>
                    ))}
                  </SortableContext>

                  {dynamicTabs.map((tab) => (
                    <LargeTabsTrigger
                      key=[redacted]
                      value={tab.id}
                      onClose={() => handleCloseDynamicTab(tab.id)}
                    >
                      {tab.title}
                    </LargeTabsTrigger>
                  ))}
                </LargeTabsList>

                <DragOverlay dropAnimation={null} modifiers={[restrictToHorizontalAxis]}>
                  {draggingId &&
                    canvasesById[draggingId] &&
                    (() => {
                      const draggingCanvas = canvasesById[draggingId];
                      return (
                        <div className="flex cursor-grabbing items-end px-[6px]">
                          <div className="flex items-center gap-1.5 rounded-lg bg-raised px-2 py-2 text-[13px] font-medium text-[#202124] shadow-lg ring-1 ring-black/10 dark:text-[#e8eaed] dark:ring-white/10">
                            <span
                              className="h-2.5 w-2.5 flex-shrink-0 rounded-full ring-1 ring-black/10 dark:ring-white/10"
                              style={{ backgroundColor: draggingCanvas.colour ?? '#9aa0a6' }}
                            />
                            {draggingCanvas.title}
                          </div>
                        </div>
                      );
                    })()}
                </DragOverlay>
              </DndContext>

              {/* Pipeline home tab content */}
              <LargeTabsContent value={PIPELINE_TAB_ID} className="flex h-full flex-col">
                <PipelineCanvas
                  onSwitchToCanvas={(canvasId) => handleTabChange(canvasId)}
                  onCreateForTemplate={handleCreateForTemplate}
                />
              </LargeTabsContent>

              {homeViewCanvases.map((canvas) => renderCanvasTabContent(canvas))}

              {dynamicTabs.map((tab) => (
                <LargeTabsContent key=[redacted] value={tab.id} className="flex h-full flex-col">
                  <NewCanvasScreen
                    onCanvasCreated={(canvas) => handleCanvasCreated(canvas, tab.id)}
                  />
                </LargeTabsContent>
              ))}
            </LargeTabs>
          </div>
        </div>

        {(activeView || newEmail) && <ActiveViewDisplay />}
      </div>

    </div>
  );
}