ConversationInboxLayout.tsx10.0 KBView on GitHub
/**
 * ConversationInboxLayout — Conversation Inbox grouped by AOP.
 *
 * Data comes from useCRMConversations (same hook as the CRM table/kanban).
 * Filtering/sorting is driven by FilterSortPopover — the exact same UI as
 * the CRM canvas, sharing CRMSlice state.
 */

import { FilterSortPopover } from '@/modules/crm/components/conversation-canvas/FilterSortConfigurationRow';
import { useCRMConversations } from '@/modules/crm/hooks/use-crm-conversations';
import { ActiveViewDisplay } from '@/components/ui/active-view-display';
import { EmptyStateIcon } from '@/components/icons/empty-state-svg';
import { PencilCompose } from '@/components/icons/icons';
import type { HydratedConversation } from '@/modules/crm/types';
import { useEffect, useMemo, useRef } from 'react';
import { ConversationGroup } from './ConversationGroup';
import { useAOPs } from '@/modules/aop/hooks/use-aops';
import { useCedarStore } from '@/modules/store';
import { Building2 } from 'lucide-react';
import { cn } from '@/lib/utils';

export function ConversationInboxLayout() {

  const openNewEmail = useCedarStore((state) => state.openNewEmail);
  const isThreadOpen = useCedarStore((state) => state.isThreadOpen);
  const isConversationOpen = useCedarStore((state) => state.isConversationOpen);
  const isTaskOutputOpen = useCedarStore((state) => state.isTaskOutputOpen);
  const isChannelChatOpen = useCedarStore((state) => state.isChannelChatOpen);
  const newEmail = useCedarStore((state) => state.newEmail);
  // Every artifact that can fill the main column, or the overlay never mounts and opening one
  // looks like nothing happened. `?task=` is routed from the ROOT layout, so a task ticket and a
  // Slack channel reach this route too — both used to be set here and never drawn.
  const overlayActive =
    isConversationOpen || isThreadOpen || isTaskOutputOpen || isChannelChatOpen || newEmail;

  const { data: aopsData } = useAOPs();

  // Seed a default "latest contact" sort on first mount if no sort is active.
  // This restores the original "latest email first" behaviour.
  const setColumnSort = useCedarStore((state) => state.setColumnSort);
  const columns = useCedarStore((state) => state.columns);
  useEffect(() => {
    const hasActiveSort = Object.values(columns).some((c) => c.sort?.active);
    if (!hasActiveSort) {
      setColumnSort('lastContactedAt', { active: true, direction: 'desc', priority: 0 });
    }
    // Run once on mount
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Use the same CRM conversations hook as the CRM table — shares CRMSlice filter state
  const [conversationsQuery, hasNextPage, loadMore] = useCRMConversations();

  // Read ordered conversation list from Zustand (populated by useCRMConversations)
  const currentList = useCedarStore((state) => state.getCurrentConversationList());
  const conversationsStore = useCedarStore((state) => state.conversations);

  const conversations = useMemo<HydratedConversation[]>(() => {
    return currentList
      .map((c) => conversationsStore[c.id]?.data)
      .filter((d): d is HydratedConversation => !!d);
  }, [currentList, conversationsStore]);

  // Infinite-scroll sentinel
  const sentinelRef = useRef<HTMLDivElement>(null);
  useEffect(() => {
    if (!sentinelRef.current) return;
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0]?.isIntersecting && hasNextPage && !conversationsQuery.isFetchingNextPage) {
          void loadMore();
        }
      },
      { threshold: 0.1 },
    );
    observer.observe(sentinelRef.current);
    return () => observer.disconnect();
  }, [hasNextPage, conversationsQuery.isFetchingNextPage, loadMore]);

  // Default-expand groups with unread email or overdue next step
  const defaultExpandedIds = useMemo(() => {
    const set = new Set<string>();
    for (const hc of conversations) {
      const hasUnread = hc.conversation.events.some((e) => e.emailEvent?.isRead === false);
      const isOverdue =
        !!hc.conversation.nextStepDate && new Date(hc.conversation.nextStepDate) < new Date();
      if (hasUnread || isOverdue) set.add(hc.conversation.id);
    }
    return set;
  }, [conversations]);

  // Group by AOP, preserving the sort order from CRMSlice
  const aopSections = useMemo(() => {
    const aops = aopsData?.aops ?? [];
    const byAop = new Map<string | null, HydratedConversation[]>();

    for (const hc of conversations) {
      const key=[redacted] ?? null;
      const bucket = byAop.get(key) ?? [];
      bucket.push(hc);
      byAop.set(key, bucket);
    }

    const sections: {
      aopId: string | null;
      aopName: string;
      aopColor: string | null;
      conversations: HydratedConversation[];
    }[] = [];

    for (const aop of aops) {
      const bucket = byAop.get(aop.id);
      if (bucket?.length) {
        sections.push({
          aopId: aop.id,
          aopName: aop.name,
          aopColor: aop.color ?? null,
          conversations: bucket,
        });
      }
    }
    const unassigned = byAop.get(null);
    if (unassigned?.length) {
      sections.push({ aopId: null, aopName: 'Other', aopColor: null, conversations: unassigned });
    }
    return sections;
  }, [conversations, aopsData?.aops]);

  const isLoading = conversationsQuery.isLoading;
  const isFetching = conversationsQuery.isFetching;
  const isFetchingNextPage = conversationsQuery.isFetchingNextPage;

  return (
    <>
      {/* URL⇄store sync is now a single root-level LayoutUrlSync (url-driven-layout Phase 3). */}
      <div className="relative flex h-full w-full flex-col">
        <div className="min-h-0 flex-1">
          <div
            className={cn(
              'flex h-full w-full',
              overlayActive && 'hidden',
            )}
          >
            <div className="flex h-full w-full max-w-full flex-col">
              {/* ── Header ─────────────────────────────────────────── */}
              <div className="z-15 sticky top-0">
                <div className="flex h-[52px] items-center gap-2 px-16">
                  <Building2 className="h-4 w-4 shrink-0 text-foreground" />
                  <span className="shrink-0 text-base font-semibold text-foreground">
                    Conversation Inbox
                  </span>

                  <div className="flex-1" />

                  {/* Filters & Sorting — exactly the same popover as the CRM canvas */}
                  <FilterSortPopover />

                  {/* Compose */}
                  <button
                    onClick={() => openNewEmail()}
                    className="mx-1 shrink-0 text-muted-foreground transition-colors hover:text-foreground"
                  >
                    <PencilCompose className="h-3.5 w-3.5 fill-muted-foreground" />
                  </button>
                </div>
              </div>

              {/* Loading bar */}
              <div
                className={cn(
                  'bg-[#006FFE] h-0.5 w-full transition-opacity',
                  isFetching ? 'opacity-100' : 'opacity-0',
                )}
              />

              {/* ── AOP-grouped list ───────────────────────────────── */}
              <div className="relative z-[1] flex h-full flex-col overflow-y-auto overflow-x-hidden pb-4">
                {isLoading ? (
                  <div className="flex h-32 w-full items-center justify-center">
                    <div className="h-4 w-4 animate-spin rounded-full border-2 border-neutral-900 border-t-transparent dark:border-white dark:border-t-transparent" />
                  </div>
                ) : conversations.length === 0 ? (
                  <div className="flex w-full items-center justify-center pt-16">
                    <div className="flex flex-col items-center gap-2 text-center">
                      <EmptyStateIcon width={200} height={200} />
                      <div className="mt-5">
                        <p className="text-lg">No conversations</p>
                        <p className="text-md text-muted-foreground dark:text-white/50">
                          Try adjusting your filters
                        </p>
                      </div>
                    </div>
                  </div>
                ) : (
                  <>
                    {aopSections.map((section) => (
                      <div key=[redacted] ?? '__other__'}>
                        {/* AOP section header */}
                        <div className="mx-12 mb-1 mt-4 flex items-center gap-2 first:mt-2">
                          {section.aopColor && (
                            <span
                              className="h-2 w-2 shrink-0 rounded-full"
                              style={{ backgroundColor: section.aopColor }}
                            />
                          )}
                          <span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
                            {section.aopName}
                          </span>
                        </div>

                        {section.conversations.map((hc) => (
                          <ConversationGroup
                            key=[redacted]
                            hydratedConv={hc}
                            defaultExpanded={defaultExpandedIds.has(hc.conversation.id)}
                          />
                        ))}
                      </div>
                    ))}

                    <div ref={sentinelRef} className="h-4" />

                    {isFetchingNextPage && (
                      <div className="flex w-full justify-center py-4">
                        <div className="h-4 w-4 animate-spin rounded-full border-2 border-neutral-900 border-t-transparent dark:border-white dark:border-t-transparent" />
                      </div>
                    )}
                  </>
                )}
              </div>
            </div>
          </div>

          {overlayActive && <ActiveViewDisplay />}
        </div>
      </div>
    </>
  );
}