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