crm-kanban-card.tsx10.5 KBView on GitHub /**
* CRM Kanban Card Component
*
* Individual card component for displaying conversations in the CRM kanban board.
* Shows company information, status, last contact info, next steps, and user tasks.
*/
import type { ConversationWithMetadata } from '@/modules/conversations/slice/conversationsSlice';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { formatDealValue, getDealSizeColor } from '@/modules/crm/utils';
import { Building2, ListTodo, Circle, Maximize2 } from 'lucide-react';
import type { CrmFieldEnumOption } from '@/modules/crm/types';
import { KanbanCard } from '@/components/ui/kanban-card';
import { getEnumTextColor } from '@/modules/crm/field-enums';
import { getEnumDisplayText } from '@/modules/crm/utils';
import { FieldBadge } from './ConversationCellComponents/FieldBadge';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { memo, useMemo } from 'react';
import { cn } from '@/lib/utils';
interface CRMKanbanCardProps {
conversation: ConversationWithMetadata;
isSelected: boolean;
isDragging?: boolean;
statusOptions?: CrmFieldEnumOption[];
priorityOptions?: CrmFieldEnumOption[];
onMouseEnter?: (conversationId: string) => void;
onClick?: () => void;
onExpandClick?: () => void;
}
export const CRMKanbanCard = memo(function CRMKanbanCard({
conversation,
isSelected,
isDragging = false,
statusOptions,
priorityOptions,
onMouseEnter,
onClick,
onExpandClick,
}: CRMKanbanCardProps) {
const conv = conversation.data.conversation;
const company = conversation.data.company;
// Get company name
const companyName = company?.name || 'No company';
// Get formatted last contact date
const lastContactInfo = useMemo(() => {
if (!conv.lastContactedAt) {
return {
text: 'Never',
color: 'bg-muted/50 text-muted-foreground',
};
}
const lastContactedAt = new Date(conv.lastContactedAt);
const daysSince = Math.floor((Date.now() - lastContactedAt.getTime()) / (1000 * 60 * 60 * 24));
let text: string;
let color: string;
// Handle negative values (future dates or very recent contacts within milliseconds)
if (daysSince < 0) {
text = 'Today';
color = 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300';
} else if (daysSince === 0) {
text = 'Today';
color = 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300';
} else if (daysSince === 1) {
text = 'Yesterday';
color = 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300';
} else if (daysSince <= 3) {
text = `${daysSince}d ago`;
color = 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300';
} else if (daysSince <= 7) {
text = `${daysSince}d ago`;
color = 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
} else if (daysSince <= 14) {
text = `${Math.floor(daysSince / 7)}w ago`;
color = 'bg-lime-100 text-lime-800 dark:bg-lime-900/30 dark:text-lime-300';
} else if (daysSince <= 30) {
text = `${Math.floor(daysSince / 7)}w ago`;
color = 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
} else if (daysSince <= 60) {
text = `${Math.floor(daysSince / 30)}mo ago`;
color = 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300';
} else if (daysSince <= 90) {
text = `${Math.floor(daysSince / 30)}mo ago`;
color = 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300';
} else {
text = `${Math.floor(daysSince / 30)}mo ago`;
color = 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
}
return { text, color };
}, [conv.lastContactedAt]);
// Get user tasks for this conversation
const userTasks = conversation.data.userTasks || [];
return (
<KanbanCard
id={conv.id}
isSelected={isSelected}
isDragging={isDragging}
onClick={onClick}
onMouseEnter={() => {
if (onMouseEnter) {
onMouseEnter(conv.id);
}
}}
>
{/* Header Row - Company and Conversation Name */}
<div className="group mb-3 flex items-center justify-between gap-2">
<div className="line-clamp-2 min-w-0 flex-1 text-sm 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" />
{companyName}
</Badge>{' '}
<span>{conv.name}</span>
</div>
<div className="flex shrink-0 items-center gap-1">
{conv.priority && (
<FieldBadge
colorClass={getEnumTextColor(conv.priority, 'priority', priorityOptions)}
className="text-xs"
>
{getEnumDisplayText(conv.priority, priorityOptions)}
</FieldBadge>
)}
{/* Expand Button - Shows on hover */}
{onExpandClick && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation(); // Prevent card selection
onExpandClick();
}}
className="h-6 w-6"
>
<Maximize2 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Open Full Conversation</TooltipContent>
</Tooltip>
)}
</div>
</div>
{/* Two-column layout */}
<div className="flex gap-4">
{/* LEFT COLUMN - Overview */}
<div className="flex flex-1 flex-col gap-2">
{/* Status Overview Section */}
{conv.statusOverview && (
<div className="space-y-0.5">
<h4 className="text-sm font-semibold">Status Overview</h4>
<div className="text-sm">{conv.statusOverview}</div>
</div>
)}
{/* Status row */}
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">Status:</span>
<FieldBadge
colorClass={getEnumTextColor(conv.status, 'status', statusOptions)}
className="text-xs"
>
{conv.status ? getEnumDisplayText(conv.status, statusOptions) : 'Unknown'}
</FieldBadge>
</div>
{/* Deal size row */}
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">Deal size:</span>
<Badge
variant="secondary"
className={cn('rounded-sm text-xs', getDealSizeColor(conv.dealValue))}
>
{formatDealValue(conv.dealValue)}
</Badge>
</div>
{/* Last contacted row */}
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">Last contacted:</span>
<Badge variant="secondary" className={cn('rounded-sm text-xs', lastContactInfo.color)}>
{lastContactInfo.text}
</Badge>
</div>
</div>
{/* RIGHT COLUMN - Actions */}
<div className="flex w-1/2 flex-col gap-3 border-l pl-4">
{/* Next Steps Section */}
<div className="space-y-0.5">
{/* Next steps label (left) and date (right) */}
<div className="flex items-center justify-between gap-2">
<h4 className="text-sm font-semibold">Next Steps</h4>
{conv.nextStepDate && (
<Badge variant="secondary" className="shrink-0 rounded-sm text-xs font-medium">
{new Date(conv.nextStepDate).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})}
</Badge>
)}
</div>
{/* Next steps text */}
{conv.nextSteps && <div className="whitespace-pre-wrap text-sm">{conv.nextSteps}</div>}
</div>
{/* Tasks */}
{userTasks.length > 0 && (
<div className="mt-1 space-y-2">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold">Tasks</h4>
<Badge variant="secondary" className="text-xs">
{userTasks.length}
</Badge>
</div>
<div className="space-y-1">
{userTasks.slice(0, 1).map((task) => {
// The output axis decides what this task produces; payload presence on it
// decides whether the artifact already exists.
const output = task.taskOutput;
const metaDescription =
output?.kind === 'email'
? output.draftId
? 'Email draft ready to send'
: 'Reply needed'
: output?.kind === 'slack'
? `Reply in ${output.channelName || 'Slack'}`
: task.description || 'Task';
return (
<div
key=[redacted]
className={cn(
'flex items-center gap-2 rounded-md border px-2 py-1.5 transition-colors',
'border-orange-200 bg-orange-50/50 dark:border-orange-900/50 dark:bg-orange-950/20',
)}
>
<div className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-orange-100 text-orange-600 dark:bg-orange-900/50 dark:text-orange-400">
<ListTodo className="h-3 w-3" />
</div>
<div className="min-w-0 flex-1">
<span className="truncate text-xs font-medium">{metaDescription}</span>
</div>
<Circle className="h-2.5 w-2.5 flex-shrink-0 text-orange-300 dark:text-orange-700" />
</div>
);
})}
{userTasks.length > 1 && (
<div className="text-muted-foreground text-xs">
+{userTasks.length - 1} more task{userTasks.length - 1 !== 1 ? 's' : ''}
</div>
)}
</div>
</div>
)}
</div>
</div>
</KanbanCard>
);
});