CRMConversationOverviewCard.tsx22.6 KBView on GitHub
'use client';

import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type {
  ExternalCrmIntegrationMetadata,
  IntegrationMetadata,
} from '../../../../server/src/db/crm-schema';
import type { ConversationWithMetadata } from '@/modules/conversations/slice/conversationsSlice';
import { DealStatusTimeline } from '@/modules/conversations/components/DealStatusTimeline';
import Container from '@/modules/cedar-os/src/cedar-os-components/containers/Container';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { AlertCircle, Bot, Building2, Maximize2, RefreshCw } from 'lucide-react';
import { useConversationEnumOptions } from '@/modules/crm/hooks/use-crm';
import { Calendar as CalendarComponent } from '@/components/ui/calendar';
import { RelativeDateBadge } from '@/components/ui/relative-date-badge';
import { getTypeColor, getAopColorStyles, getEnumDisplayText } from '@/modules/crm/utils';
import { NaturalDateInput } from '@/components/ui/natural-date-input';
import { useOptimisticConversationActions } from '@/modules/crm';
import { getEnumTextColor } from '@/modules/crm/field-enums';
import { FieldBadge } from './ConversationCellComponents/FieldBadge';
import { useEffect, useMemo, useState } from 'react';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { useAOPs } from '@/modules/aop';
import { useCedarStore } from '@/modules/store';

import { format } from 'date-fns';
import { cn } from '@/lib/utils';

interface CRMConversationOverviewCardProps {
  conversationData: ConversationWithMetadata;
  onAgentClick?: () => void;
  onRefresh?: () => void;
  isRefreshing?: boolean;
  hideHeader?: boolean;
  onOpenConversation?: () => void;
}

export function CRMConversationOverviewCard({
  conversationData,
  onAgentClick,
  onRefresh,
  isRefreshing = false,
  hideHeader = false,
  onOpenConversation,
}: CRMConversationOverviewCardProps) {
  // Optimistic update actions
  const { optimisticUpdateConversation } = useOptimisticConversationActions();

  // Demo mode blurs sensitive figures (deal size / ACV) for screen-sharing.
  const isDemoMode = useCedarStore((state) => state.isDemoMode);

  // An AOP change dispatches a background agent refresh; while its run is live the fields
  // on this card are still being rewritten. Read straight from the store (as NextStepsCard
  // does for `updatingTasks`) rather than prop-drilling through ConversationBodyLayout.
  // The run is watched — and cleared — by useAopRefreshWatcher in ConversationView.
  const isRefreshingFields = useCedarStore(
    (state) => !!state.conversations[conversationData.data.conversation.id]?.aopRefreshRunId,
  );

  // Local state for deal size input (needs intermediary value)
  const [isEditingDealSize, setIsEditingDealSize] = useState(false);
  const [dealSizeInput, setDealSizeInput] = useState('');

  // Local state for last contact date picker
  const [isLastContactOpen, setIsLastContactOpen] = useState(false);
  const [lastContactDate, setLastContactDate] = useState<Date | undefined>(
    conversationData.data.conversation.lastContactedAt
      ? new Date(conversationData.data.conversation.lastContactedAt)
      : undefined,
  );

  // Local state for editable text fields (statusOverview, nextSteps)
  const [statusOverviewValue, setStatusOverviewValue] = useState(
    conversationData.data.conversation.statusOverview || '',
  );

  // Fetch user's AOPs for the Type dropdown (cached at app level)
  const { data: aopsData } = useAOPs();

  const aops = aopsData?.aops || [];
  const typeOptions = aops.map((aop) => aop.name);

  const conversation = conversationData.data.conversation;
  const company = conversationData.data.company;
  const conversationId = conversation.id;
  const aopId = conversation.aopId;

  // Derive conversation type from AOP name
  const aop = aops.find((a) => a.id === aopId);
  const conversationType = aop?.name;

  // Fetch dynamic enum options from AOP (or use defaults)
  const { data: priorityOptionsData } = useConversationEnumOptions(aopId || undefined, 'priority');
  const { data: statusOptionsData } = useConversationEnumOptions(aopId || undefined, 'status');

  // Extract external CRM integration metadata
  const externalCrmMetadata = useMemo(() => {
    const metadata = (conversation.integrationMetadata as IntegrationMetadata) || [];
    return metadata.filter(
      (item): item is ExternalCrmIntegrationMetadata =>
        item.type === 'external_crm' && !!item.provider && !!item.dealId,
    );
  }, [conversation.integrationMetadata]);

  // Format provider name for display
  const formatProviderName = (provider: string): string => {
    const providerLower = provider.toLowerCase();
    if (providerLower === 'attio') return 'Attio';
    if (providerLower === 'hubspot') return 'HubSpot';
    if (providerLower === 'copper') return 'Copper';
    if (providerLower === 'salesforce') return 'Salesforce';
    // Capitalize first letter
    return provider.charAt(0).toUpperCase() + provider.slice(1);
  };

  // Sync local state when conversation data changes
  useEffect(() => {
    setStatusOverviewValue(conversation.statusOverview || '');
    setLastContactDate(
      conversation.lastContactedAt ? new Date(conversation.lastContactedAt) : undefined,
    );
  }, [conversation.statusOverview, conversation.lastContactedAt, conversation.events]);

  // Generic handler for optimistic updates
  const handleUpdate = async (field: string, value: string | number | Date) => {
    await optimisticUpdateConversation(conversationId, { [field]: value });
  };

  // Special handler for AOP selection
  const handleAopSelection = async (aopName: string) => {
    const selectedAop = aops.find((aop) => aop.name === aopName);
    if (!selectedAop) return;

    // Update aopId when AOP changes (type is derived from AOP)
    await optimisticUpdateConversation(conversationId, {
      aopId: selectedAop.id,
    });
  };

  // Priority options (from AOP or defaults)
  const priorityOptions = useMemo(() => {
    return (
      priorityOptionsData?.options?.map((opt) => opt.value) || ['low', 'medium', 'high', 'urgent']
    );
  }, [priorityOptionsData]);

  // Status options (only if AOP name is "Deals") - passed directly to DealStatusTimeline

  // Format deal value
  const formatDealValue = (value: number | null | undefined) => {
    if (!value) return '—';
    if (value >= 1000000) return `$${(value / 1000000).toFixed(1)}M`;
    if (value >= 1000) return `$${(value / 1000).toFixed(1)}K`;
    return `$${value.toLocaleString()}`;
  };

  const getDealSizeColor = (value: number | null | undefined) => {
    if (!value) return 'bg-gray-100 text-gray-600';
    if (value >= 100000) return 'bg-emerald-100 text-emerald-700';
    if (value >= 50000) return 'bg-blue-100 text-blue-700';
    if (value >= 10000) return 'bg-purple-100 text-purple-700';
    return 'bg-gray-100 text-gray-600';
  };

  const handleDealSizeClick = () => {
    setDealSizeInput(conversation.dealValue?.toString() || '');
    setIsEditingDealSize(true);
  };

  const handleDealSizeBlur = async () => {
    const value = parseInt(dealSizeInput, 10);
    if (!isNaN(value) && value !== conversation.dealValue) {
      await handleUpdate('dealValue', value);
    }
    setIsEditingDealSize(false);
  };

  const handleDealSizeKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter') {
      handleDealSizeBlur();
    } else if (e.key === 'Escape') {
      setIsEditingDealSize(false);
    }
  };

  // Handle last contact date changes
  const handleLastContactDateSelect = (date: Date | undefined) => {
    if (date) {
      // Preserve time if lastContactDate exists, otherwise use current time
      if (lastContactDate) {
        date.setHours(lastContactDate.getHours());
        date.setMinutes(lastContactDate.getMinutes());
      } else {
        const now = new Date();
        date.setHours(now.getHours());
        date.setMinutes(now.getMinutes());
      }
    }
    setLastContactDate(date);
  };

  // Update time while keeping date
  const handleLastContactTimeChange = (timeString: string) => {
    const [hours, minutes] = timeString.split(':').map(Number);
    const date = lastContactDate ? new Date(lastContactDate) : new Date();
    date.setHours(hours);
    date.setMinutes(minutes);
    setLastContactDate(date);
  };

  // Save last contact date to database
  const handleLastContactDateSave = async () => {
    if (lastContactDate) {
      await handleUpdate('lastContactedAt', lastContactDate);
      setIsLastContactOpen(false);
    }
  };

  return (
    <Container shadowSize="s">
      <div className="group flex flex-col gap-2 px-3 py-2">
        {/* Header */}
        {!hideHeader && (
          <div className="flex items-start justify-between gap-2">
            <div className="flex min-w-0 flex-1 flex-col gap-2">
              {/* Company Badge inline with Title - Badge first so it's not truncated */}
              <div className="line-clamp-2 text-base font-semibold">
                <Badge variant="outline" className="inline-flex gap-1.5 rounded-sm align-middle">
                  <Building2 className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
                  {company?.name || 'No company'}
                </Badge>{' '}
                <span>{conversation.name}</span>
              </div>
              {/* External CRM Badges */}
              {externalCrmMetadata.length > 0 && (
                <div className="flex flex-wrap items-center gap-2">
                  {externalCrmMetadata.map((metadata) => {
                    const providerName = formatProviderName(metadata.provider);

                    return (
                      <Badge
                        key=[redacted]
                        variant="outline"
                        className="rounded-sm"
                      >
                        <span className="text-xs">Synced from {providerName}</span>
                      </Badge>
                    );
                  })}
                </div>
              )}
            </div>

            <div className="flex shrink-0 items-start gap-1">
              {/* Agent Button - Shows on hover and takes space only when visible */}
              {onAgentClick && (
                <Tooltip>
                  <TooltipTrigger asChild>
                    <Button
                      variant="ghost"
                      size="icon"
                      onClick={onAgentClick}
                      className="hidden h-7 gap-1.5 group-hover:inline-flex"
                    >
                      <Bot className="h-4 w-4" />
                    </Button>
                  </TooltipTrigger>
                  <TooltipContent>Agent Executions</TooltipContent>
                </Tooltip>
              )}
              {/* Background AOP refresh in flight — the agent is rewriting this
                  conversation's fields right now. Always visible (unlike the hover-only
                  refresh button) because it explains values changing under the user. */}
              {isRefreshingFields && (
                <Tooltip>
                  <TooltipTrigger asChild>
                    <span className="text-muted-foreground flex items-center gap-1 text-xs">
                      <Bot className="h-3.5 w-3.5 animate-pulse" />
                      <span className="animate-pulse">Updating fields…</span>
                    </span>
                  </TooltipTrigger>
                  <TooltipContent>
                    The agent is refreshing this conversation&apos;s fields for the new AOP
                  </TooltipContent>
                </Tooltip>
              )}
              {/* Refresh Button - Shows on hover and takes space only when visible */}
              {onRefresh && (
                <Tooltip>
                  <TooltipTrigger asChild>
                    <Button
                      variant="ghost"
                      size="icon"
                      onClick={onRefresh}
                      disabled={isRefreshing}
                      className="hidden h-7 w-7 group-hover:inline-flex"
                    >
                      <RefreshCw className={cn('h-3.5 w-3.5', isRefreshing && 'animate-spin')} />
                    </Button>
                  </TooltipTrigger>
                  <TooltipContent>Refresh Conversation</TooltipContent>
                </Tooltip>
              )}
              {/* Open Full Conversation Button - Always visible */}
              {onOpenConversation && (
                <Tooltip>
                  <TooltipTrigger asChild>
                    <Button
                      variant="ghost"
                      size="icon"
                      onClick={onOpenConversation}
                      className="h-7 w-7"
                    >
                      <Maximize2 className="h-3.5 w-3.5" />
                    </Button>
                  </TooltipTrigger>
                  <TooltipContent>Open Full Conversation</TooltipContent>
                </Tooltip>
              )}
            </div>
          </div>
        )}

        {/* Two-Column Layout */}
        <div className="grid grid-cols-2 gap-4">
          {/* Left Column: Status & Status Overview */}
          <div className="flex flex-col -mt-1">
            {/* Deal Status Timeline */}
            <div className="mr-1 flex flex-row items-center justify-between gap-3">
              <span className="text-muted-foreground text-xs">Status:</span>
              <DealStatusTimeline
                currentStatus={conversation.status || 'active'}
                onStatusChange={(status) => handleUpdate('status', status)}
                statusOptions={statusOptionsData?.options || undefined}
              />
            </div>

            {/* Status Overview - Editable */}
            <div className="space-y-1">
              <span className="text-muted-foreground text-xs">Status Overview:</span>
              <Textarea
                value={statusOverviewValue}
                onChange={(e) => setStatusOverviewValue(e.target.value)}
                onBlur={() => {
                  if (statusOverviewValue !== conversation.statusOverview) {
                    handleUpdate('statusOverview', statusOverviewValue);
                  }
                }}
                placeholder="empty"
                className="-ml-1 border-none bg-transparent px-1 py-0.5 leading-tight focus-visible:ring-1"
              />
            </div>
          </div>

          {/* Right Column: Fields Grid */}
          <div className="grid grid-cols-[auto_1fr_auto_1fr] items-center gap-x-3 gap-y-2 text-sm h-fit">
            {/* Row 1 - Left: Type */}
            <span className="text-muted-foreground text-xs">Type:</span>
            <div className="min-w-0">
              <DropdownMenu>
                <DropdownMenuTrigger asChild>
                  {!conversationType ? (
                    <Badge
                      variant="secondary"
                      className="max-w-full cursor-pointer justify-center rounded-sm bg-gray-100 px-2 py-0.5 text-gray-800 dark:bg-gray-800 dark:text-gray-300"
                    >
                      <AlertCircle className="mr-1.5 h-3 w-3 shrink-0" />
                      <span className="truncate">No AOP</span>
                    </Badge>
                  ) : (
                    <Badge
                      variant="secondary"
                      className={cn(
                        'max-w-full cursor-pointer rounded-sm',
                        !getAopColorStyles(aop?.color) && getTypeColor(conversationType),
                      )}
                      style={getAopColorStyles(aop?.color) || undefined}
                    >
                      <span className="truncate">{conversationType}</span>
                    </Badge>
                  )}
                </DropdownMenuTrigger>
                <DropdownMenuContent>
                  {typeOptions.length > 0 ? (
                    aops.map((aopOption) => {
                      const optionColorStyles = getAopColorStyles(aopOption.color);
                      return (
                        <DropdownMenuItem
                          key=[redacted]
                          onClick={() => handleAopSelection(aopOption.name)}
                        >
                          <Badge
                            variant="secondary"
                            className={cn(
                              'mr-2 max-w-full rounded-sm',
                              !optionColorStyles && getTypeColor(aopOption.name),
                            )}
                            style={optionColorStyles || undefined}
                          >
                            <span className="truncate">{aopOption.name}</span>
                          </Badge>
                        </DropdownMenuItem>
                      );
                    })
                  ) : (
                    <DropdownMenuItem disabled>No AOPs available</DropdownMenuItem>
                  )}
                </DropdownMenuContent>
              </DropdownMenu>
            </div>

            {/* Row 1 - Right: Last Contact */}
            <span className="text-muted-foreground text-xs">Last Contact:</span>
            <div className="min-w-0">
              <Popover open={isLastContactOpen} onOpenChange={setIsLastContactOpen}>
                <PopoverTrigger asChild>
                  <div className="w-fit cursor-pointer">
                    <RelativeDateBadge
                      date={conversation.lastContactedAt}
                      className="truncate rounded-sm transition-opacity hover:opacity-80"
                    />
                  </div>
                </PopoverTrigger>
                <PopoverContent className="w-auto p-0" align="start">
                  <div className="flex">
                    {/* Natural Language Input - Left Side */}
                    <div className="w-[280px] border-r">
                      <NaturalDateInput
                        value={lastContactDate}
                        onChange={(date: Date | undefined) => {
                          setLastContactDate(date);
                        }}
                        placeholder="Type a date..."
                      />
                    </div>

                    {/* Calendar Picker - Right Side */}
                    <div className="p-3">
                      <CalendarComponent
                        mode="single"
                        selected={lastContactDate}
                        onSelect={handleLastContactDateSelect}
                      />
                      <div className="mt-3 border-t pt-3">
                        <label className="text-muted-foreground mb-1 block text-xs">Time</label>
                        <Input
                          type="time"
                          value={
                            lastContactDate
                              ? format(lastContactDate, 'HH:mm')
                              : format(new Date(), 'HH:mm')
                          }
                          onChange={(e) => handleLastContactTimeChange(e.target.value)}
                          className="h-8 w-full text-xs"
                        />
                      </div>
                      <div className="mt-3 border-t pt-3">
                        <button
                          onClick={handleLastContactDateSave}
                          className="hover:bg-primary/90 bg-primary text-primary-foreground h-8 w-full rounded-sm text-xs font-medium transition-colors"
                        >
                          Save
                        </button>
                      </div>
                    </div>
                  </div>
                </PopoverContent>
              </Popover>
            </div>

            {/* Row 2 - Left: Priority */}
            <span className="text-muted-foreground text-xs">Priority:</span>
            <div className="min-w-0">
              <DropdownMenu>
                <DropdownMenuTrigger asChild>
                  <FieldBadge
                    colorClass={getEnumTextColor(conversation.priority || 'medium', 'priority', priorityOptionsData?.options)}
                    className="max-w-full cursor-pointer"
                  >
                    <span className="truncate">
                      {getEnumDisplayText(conversation.priority || 'medium', priorityOptionsData?.options)}
                    </span>
                  </FieldBadge>
                </DropdownMenuTrigger>
                <DropdownMenuContent>
                  {priorityOptions.map((option) => (
                    <DropdownMenuItem key=[redacted] onClick={() => handleUpdate('priority', option)}>
                      <FieldBadge
                        colorClass={getEnumTextColor(option, 'priority', priorityOptionsData?.options)}
                        className="mr-2 max-w-full"
                      >
                        <span className="truncate">
                          {getEnumDisplayText(option, priorityOptionsData?.options)}
                        </span>
                      </FieldBadge>
                    </DropdownMenuItem>
                  ))}
                </DropdownMenuContent>
              </DropdownMenu>
            </div>

            {/* Row 2 - Right: Deal Size */}
            <span className="text-muted-foreground text-xs">Deal Size:</span>
            <div className="min-w-0">
              {isEditingDealSize ? (
                <Input
                  type="number"
                  value={dealSizeInput}
                  onChange={(e) => setDealSizeInput(e.target.value)}
                  onBlur={handleDealSizeBlur}
                  onKeyDown={handleDealSizeKeyDown}
                  className="h-7 w-32 text-sm"
                  placeholder="Enter amount"
                  autoFocus
                />
              ) : (
                <Badge
                  variant="secondary"
                  className={cn(
                    'max-w-full cursor-pointer rounded-sm transition-all hover:opacity-80',
                    getDealSizeColor(conversation.dealValue),
                  )}
                  onClick={handleDealSizeClick}
                  title={isDemoMode ? 'Hidden in demo mode' : undefined}
                >
                  <span
                    className={cn(
                      'truncate transition-all',
                      isDemoMode && 'select-none blur-[2px]',
                    )}
                  >
                    {formatDealValue(conversation.dealValue)}
                  </span>
                </Badge>
              )}
            </div>
          </div>
        </div>
      </div>
    </Container>
  );
}