ConversationGroup.tsx9.4 KBView on GitHub
/**
 * ConversationGroup — a collapsible card grouping all emails and tasks for a
 * single CRM conversation.
 *
 * Header row:
 *   [↓ chevron (pushed left)] [Company icon] [Company / conversation name] [Next step badge] ··· [Active ▾]
 */

import { Thread } from '@/modules/threads/threadList/threadItem';
import { ConversationTaskRow } from './ConversationTaskRow';
import type { HydratedConversation } from '@/modules/crm/types';
import { DatePickerWithNaturalInput } from '@/components/ui/date-picker-with-natural-input';
import { RelativeDateBadge } from '@/components/ui/relative-date-badge';
import { BimiAvatar } from '@/components/ui/bimi-avatar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useOptimisticConversationActions } from '@/modules/crm/hooks/use-optimistic-conversation-actions';
import { useAllConversationEnumOptions } from '@/modules/crm/hooks/use-crm';
import { getEnumColor } from '@/modules/crm/field-enums';
import { useCedarStore } from '@/modules/store';
import { cn } from '@/lib/utils';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { memo, useCallback, useState } from 'react';

type ActiveFilter = 'all' | 'active' | 'important';

interface ConversationGroupProps {
  hydratedConv: HydratedConversation;
  defaultExpanded?: boolean;
}

export const ConversationGroup = memo(function ConversationGroup({
  hydratedConv,
  defaultExpanded = true,
}: ConversationGroupProps) {
  const { conversation, company, userTasks } = hydratedConv;
  const [isExpanded, setIsExpanded] = useState(defaultExpanded);
  const [activeFilter, setActiveFilter] = useState<ActiveFilter>('active');
  const [filterPopoverOpen, setFilterPopoverOpen] = useState(false);
  const [isHovered, setIsHovered] = useState(false);

  const { data: statusData } = useAllConversationEnumOptions('status');
  const statusOptions = statusData?.options ?? [];

  const setActiveConversationId = useCedarStore((state) => state.setActiveConversationId);
  const setIsConversationOpen = useCedarStore((state) => state.setIsConversationOpen);
  const { optimisticUpdateConversation } = useOptimisticConversationActions();

  // Email events — most recent first, filtered by activeFilter
  const allEmailEvents = (conversation.events ?? []).filter(
    (e) => e.eventType === 'email' && e.emailEvent != null,
  );
  const emailEvents = (() => {
    if (activeFilter === 'important') {
      return allEmailEvents
        .filter((e) => e.emailEvent?.isRead === false)
        .slice(0, 5);
    }
    if (activeFilter === 'active') {
      return allEmailEvents
        .filter((e) => e.emailEvent?.isRead === false)
        .slice(0, 5);
    }
    return allEmailEvents.slice(0, 5);
  })();

  // Tasks — todo only, sorted by due date ascending
  const visibleTasks = (userTasks ?? [])
    .filter((t) => t.status === 'todo')
    .sort((a, b) => {
      if (!a.dueDate && !b.dueDate) return 0;
      if (!a.dueDate) return 1;
      if (!b.dueDate) return -1;
      return (
        new Date(a.dueDate as string | Date).getTime() -
        new Date(b.dueDate as string | Date).getTime()
      );
    });

  const hasUnreadEmail = allEmailEvents.some((e) => e.emailEvent?.isRead === false);
  const nextStepDate = conversation.nextStepDate
    ? typeof conversation.nextStepDate === 'string'
      ? conversation.nextStepDate
      : (conversation.nextStepDate as Date).toISOString()
    : null;

  const displayName = company?.name || conversation.name || 'Unknown';
  const logoEmail = company ? `contact@${company.domain}` : undefined;

  const handleHeaderClick = useCallback(() => {
    setIsExpanded((v) => !v);
  }, []);

  const handleOpenConversation = useCallback(
    (e: React.MouseEvent) => {
      e.stopPropagation();
      setActiveConversationId(conversation.id);
      setIsConversationOpen(true);
    },
    [conversation.id, setActiveConversationId, setIsConversationOpen],
  );

  const handleNextStepDateChange = useCallback(
    async (date: Date | null) => {
      await optimisticUpdateConversation(conversation.id, { nextStepDate: date });
    },
    [conversation.id, optimisticUpdateConversation],
  );

  const filterLabel: Record<ActiveFilter, string> = {
    all: 'All',
    active: 'Active',
    important: 'Important',
  };

  return (
    <div className="mx-12 mb-2 overflow-hidden rounded-xl bg-card">
      {/* ── Header row ─────────────────────────────────────────────────── */}
      {/* pl-5 aligns the avatar with thread-row avatars (mx-2 + px-3 = 20px from card edge) */}
      <div
        className="relative flex cursor-pointer items-center gap-2 py-3 pl-7 pr-4 hover:bg-muted/40 transition-colors"
        onClick={handleHeaderClick}
        onMouseEnter={() => setIsHovered(true)}
        onMouseLeave={() => setIsHovered(false)}
      >
        {/* Chevron — pushed left so it doesn't shift the avatar alignment */}
        <span className="absolute left-0 top-1/2 flex h-4 w-4 -translate-y-1/2 items-center justify-center text-muted-foreground">
          {isExpanded ? (
            <ChevronDown className="h-3.5 w-3.5" />
          ) : (
            <ChevronRight className="h-3.5 w-3.5" />
          )}
        </span>

        {/* Company avatar */}
        <BimiAvatar
          email={logoEmail ?? ''}
          name={displayName}
          className="h-6 w-6 shrink-0 rounded-full border"
          companyLogoUrl={company?.logoUrl ?? undefined}
        />

        {/* Company name + next step badge */}
        <div className="flex min-w-0 flex-1 items-center gap-2">
          <button
            className={cn(
              'min-w-0 truncate text-base font-semibold text-foreground hover:underline',
              hasUnreadEmail && 'font-bold',
            )}
            onClick={handleOpenConversation}
          >
            {displayName}
          </button>

          {/* Next step date — clickable to edit */}
          {nextStepDate && (
            <DatePickerWithNaturalInput
              value={new Date(nextStepDate)}
              onChange={handleNextStepDateChange}
              closeOnSelect
              trigger={
                <div onClick={(e) => e.stopPropagation()}>
                  <RelativeDateBadge
                    date={nextStepDate}
                    colorType="scheduled"
                    className="shrink-0 cursor-pointer rounded-sm px-2 py-0.5 text-xs hover:opacity-80"
                  />
                </div>
              }
            />
          )}
        </div>

        {/* Status badge — shown on hover */}
        {isHovered && conversation.status && (
          <span
            className={cn(
              'shrink-0 rounded-sm px-2 py-0.5 text-xs font-medium',
              getEnumColor(conversation.status, 'status', statusOptions),
            )}
          >
            {statusOptions.find((o) => o.value === conversation.status)?.label ?? conversation.status}
          </span>
        )}

        {/* Active / importance filter toggle */}
        <Popover open={filterPopoverOpen} onOpenChange={setFilterPopoverOpen}>
          <PopoverTrigger asChild>
            <button
              className={cn(
                'flex shrink-0 items-center gap-1 text-sm font-medium transition-colors hover:text-foreground',
                activeFilter !== 'all' ? 'text-foreground' : 'text-muted-foreground',
              )}
              onClick={(e) => e.stopPropagation()}
            >
              {filterLabel[activeFilter]}
              <ChevronDown className="h-3.5 w-3.5" />
            </button>
          </PopoverTrigger>
          <PopoverContent
            className="w-36 p-1"
            align="end"
            onClick={(e) => e.stopPropagation()}
          >
            {(['all', 'active', 'important'] as ActiveFilter[]).map((f) => (
              <button
                key={f}
                className={cn(
                  'flex w-full items-center rounded-md px-3 py-1.5 text-sm transition-colors',
                  activeFilter === f ? 'bg-muted font-medium' : 'hover:bg-muted/60',
                )}
                onClick={() => {
                  setActiveFilter(f);
                  setFilterPopoverOpen(false);
                }}
              >
                {filterLabel[f]}
              </button>
            ))}
          </PopoverContent>
        </Popover>
      </div>

      {/* ── Expanded body ───────────────────────────────────────────────── */}
      {isExpanded && (
        <div className="pb-2">
          {emailEvents.length > 0 && (
            <div>
              {emailEvents.map((event) =>
                event.emailEvent?.threadId ? (
                  <Thread
                    key=[redacted]
                    message={{ id: event.emailEvent.threadId }}
                    noPadding
                  />
                ) : null,
              )}
            </div>
          )}

          {visibleTasks.length > 0 && (
            <div>
              {visibleTasks.map((task) => (
                <ConversationTaskRow key=[redacted] task={task} />
              ))}
            </div>
          )}

          {emailEvents.length === 0 && visibleTasks.length === 0 && (
            <div className="px-4 py-3 text-sm text-muted-foreground">
              No emails or tasks yet.
            </div>
          )}
        </div>
      )}
    </div>
  );
});