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'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>
);
}