CanvasKanbanCard.tsx14.1 KBView on GitHub /**
* CanvasKanbanCard
*
* Compact kanban card for the KanbanConversationCanvas.
*
* Layout — 2-column grid [icon 16px] [content]:
* - justify-items-start so badges don't stretch to fill the cell
* - text spans use w-full min-w-0 to still allow truncation
* - "tasks" field renders as a grid row: [icon] [count ... date badge]
* Only overdue/today tasks are shown; future tasks are hidden.
*/
import { useDraggable } from '@dnd-kit/core';
import { motion } from 'motion/react';
import {
ArrowRight,
Calendar,
DollarSign,
FileText,
HelpCircle,
ListTodo,
Maximize2,
Circle,
User,
Clock,
Zap,
Layers,
Link,
Tag,
BarChart2,
Dot,
} from 'lucide-react';
import React, { memo, useMemo } from 'react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { RelativeDateBadge } from '@/components/ui/relative-date-badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import type { ConversationWithMetadata } from '@/modules/conversations/slice/conversationsSlice';
import type { CrmFieldEnumOption, HydratedConversation, ConversationUserTask } from '@/modules/crm/types';
import type { Conversation } from '@/modules/crm/types';
import type { KanbanCardField, KanbanCardTitleField } from '@/modules/canvas/types/canvas-types';
import { formatDealValue, getDotColor, getEnumDisplayText, STATUS_ICON_MAP } from '@/modules/crm/utils';
import { parseAsUTC } from '@/modules/crm/utils/time';
import { conversationColumnsConfig } from '@/modules/crm/config/conversation-columns';
import type { ColumnType } from '@/modules/crm/store/crmSlice';
import { cn } from '@/lib/utils';
interface CanvasKanbanCardProps {
conversation: ConversationWithMetadata;
cardFields: KanbanCardField[];
cardTitleField: KanbanCardTitleField;
isSelected: boolean;
isDragging?: boolean;
statusOptions?: CrmFieldEnumOption[];
priorityOptions?: CrmFieldEnumOption[];
onMouseEnter?: (conversationId: string) => void;
onClick?: () => void;
onExpandClick?: () => void;
}
/** Small colored circle with a lucide icon inside */
function StatusIconDot({ color, iconName }: { color: string; iconName?: string }) {
const dotClass = getDotColor(color);
const Icon = (iconName ? STATUS_ICON_MAP[iconName] : undefined) ?? HelpCircle;
return (
<div className={cn('flex h-4 w-4 shrink-0 items-center justify-center rounded-full', dotClass)}>
<Icon size={9} strokeWidth={2.5} />
</div>
);
}
/** Small plain colored dot */
function ColorDot({ color }: { color: string }) {
const dotClass = getDotColor(color).split(' ')[0];
return <span className={cn('h-2 w-2 shrink-0 self-center rounded-full', dotClass)} />;
}
/** Returns the raw value for a given fieldId. */
function getFieldValue(conv: Conversation, hydrated: HydratedConversation, fieldId: string): unknown {
switch (fieldId) {
case 'nextStepDate': {
if (!conv.nextStepDate) return null;
const s = typeof conv.nextStepDate === 'string' ? conv.nextStepDate : conv.nextStepDate.toISOString();
return parseAsUTC(s) ?? null;
}
case 'nextSteps': return conv.nextSteps;
case 'dealValue': return conv.dealValue;
case 'status': return conv.status;
case 'statusOverview': return conv.statusOverview;
case 'lastContactedAt': return parseAsUTC(conv.lastContactedAt) ?? null;
case 'priority': return conv.priority;
case 'primaryUser': return hydrated.ownerUser ?? null;
case 'tasks':
return (hydrated.userTasks ?? []).filter((t) => t.status === 'todo');
// Scalars computed over the conversation's whole history by the list query. Scanning
// `conv.events` instead read a capped, most-recent-first window, so a card whose last
// meeting predated that window showed nothing. See ConversationItem for the same fix.
case 'lastMeetingTime':
return parseAsUTC(conv.lastMeetingAt) ?? null;
case 'lastOutboundEmailTime':
return parseAsUTC(conv.lastOutboundEmailAt) ?? null;
default: return null;
}
}
/** Icon for a column type */
function getFieldIcon(colType: ColumnType | string, fieldId: string): React.ReactNode {
if (colType === 'date') {
return fieldId === 'nextStepDate'
? <ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground/60" />
: <Calendar className="h-4 w-4 shrink-0 text-muted-foreground/60" />;
}
switch (colType) {
case 'text': return <FileText className="h-4 w-4 shrink-0 text-muted-foreground/60" />;
case 'number': return <DollarSign className="h-4 w-4 shrink-0 text-muted-foreground/60" />;
case 'status-badge': return <Circle className="h-4 w-4 shrink-0 fill-blue-500 text-blue-500" />;
case 'select': return <Circle className="h-4 w-4 shrink-0 text-muted-foreground/60" />;
case 'user': return <User className="h-4 w-4 shrink-0 text-muted-foreground/60" />;
case 'timeline': return <Clock className="h-4 w-4 shrink-0 text-muted-foreground/60" />;
case 'current-action': return <Zap className="h-4 w-4 shrink-0 text-muted-foreground/60" />;
case 'working-memory': return <Layers className="h-4 w-4 shrink-0 text-muted-foreground/60" />;
case 'crm-synced': return <Link className="h-4 w-4 shrink-0 text-muted-foreground/60" />;
case 'aop-type': return <Tag className="h-4 w-4 shrink-0 text-muted-foreground/60" />;
case 'activity-overview': return <BarChart2 className="h-4 w-4 shrink-0 text-muted-foreground/60" />;
default: return <Dot className="h-4 w-4 shrink-0 text-muted-foreground/60" />;
}
}
export const CanvasKanbanCard = memo(function CanvasKanbanCard({
conversation,
cardFields,
cardTitleField,
isSelected,
isDragging = false,
statusOptions,
priorityOptions,
onMouseEnter,
onClick,
onExpandClick,
}: CanvasKanbanCardProps) {
const { attributes, listeners, setNodeRef, isDragging: isActiveDragging } = useDraggable({
id: conversation.data.conversation.id,
});
const conv = conversation.data.conversation;
const hydrated = conversation.data;
const company = conversation.data.company;
const title =
cardTitleField === 'conversation'
? (conv.name || company?.name || 'Untitled')
: (company?.name || conv.name || 'No company');
const logoUrl = company?.logoUrl ?? null;
const logoInitial = (company?.name?.[0] ?? conv.name?.[0] ?? '?').toUpperCase();
const statusOpt = useMemo(
() => statusOptions?.find((o) => o.value === conv.status),
[statusOptions, conv.status],
);
const priorityOpt = useMemo(
() => priorityOptions?.find((o) => o.value === conv.priority),
[priorityOptions, conv.priority],
);
// Separate tasks from grid fields
const showTasks = cardFields.includes('tasks');
const gridFields = cardFields.filter((f) => f !== 'tasks');
// Only show tasks that are overdue or due today — never future tasks
const dueTasks = useMemo(() => {
const endOfToday = new Date();
endOfToday.setHours(23, 59, 59, 999);
return (hydrated.userTasks ?? []).filter(
(t) => t.status === 'todo' && (!t.dueDate || new Date(t.dueDate) <= endOfToday),
) as ConversationUserTask[];
}, [hydrated.userTasks]);
/**
* Renders a single grid-row for a non-tasks field (two cells: icon + value).
* justify-items-start on the parent grid ensures badges size to content.
* Text spans use w-full min-w-0 to still allow truncation.
*/
function renderField(fieldId: string): React.ReactNode {
const colConfig = conversationColumnsConfig.find((c) => c.id === fieldId);
if (!colConfig) return null;
const colType: ColumnType | string = colConfig.type;
const value = getFieldValue(conv, hydrated, fieldId);
const icon = getFieldIcon(colType, fieldId);
if (colType === 'date') {
return (
<React.Fragment key=[redacted]
{icon}
<RelativeDateBadge
date={value as string | Date | null | undefined}
colorType={fieldId === 'nextStepDate' ? 'scheduled' : 'history'}
className="h-5 rounded-sm px-1.5 text-xs"
/>
</React.Fragment>
);
}
if (colType === 'status-badge') {
const label = getEnumDisplayText(conv.status, statusOptions ?? []);
return (
<React.Fragment key=[redacted]
{statusOpt ? <StatusIconDot color={statusOpt.color} iconName={statusOpt.icon} /> : icon}
<span className="w-full min-w-0 truncate text-sm text-muted-foreground">{label || '—'}</span>
</React.Fragment>
);
}
if (colType === 'select' && fieldId === 'priority') {
const label = getEnumDisplayText(conv.priority, priorityOptions ?? []);
return (
<React.Fragment key=[redacted]
{priorityOpt ? <ColorDot color={priorityOpt.color} /> : icon}
<span className="w-full min-w-0 truncate text-sm text-muted-foreground">{label || '—'}</span>
</React.Fragment>
);
}
if (colType === 'number' || colType === 'currency') {
const formatted = value != null
? (fieldId === 'dealValue' ? formatDealValue(value as number) : (value as number).toLocaleString())
: null;
return (
<React.Fragment key=[redacted]
{icon}
{formatted
? <Badge variant="secondary" className="h-5 rounded-sm px-1.5 text-xs font-normal tabular-nums">{formatted}</Badge>
: <span className="text-sm text-muted-foreground">—</span>}
</React.Fragment>
);
}
if (colType === 'text') {
const text = value != null ? String(value) : '';
return (
<React.Fragment key=[redacted]
{icon}
<span className="w-full min-w-0 truncate text-sm text-muted-foreground">{text || '—'}</span>
</React.Fragment>
);
}
if (colType === 'user') {
const user = value as { name: string; image: string | null } | null;
return (
<React.Fragment key=[redacted]
{user ? (
<Avatar className="h-4 w-4 shrink-0 rounded-full">
<AvatarImage src={user.image ?? undefined} className="object-contain" />
<AvatarFallback className="bg-primary/10 text-[9px] font-semibold text-primary">
{user.name?.[0]?.toUpperCase() ?? '?'}
</AvatarFallback>
</Avatar>
) : icon}
<span className="w-full min-w-0 truncate text-sm text-muted-foreground">{user?.name ?? '—'}</span>
</React.Fragment>
);
}
// default fallback
return (
<React.Fragment key=[redacted]
{icon}
<span className="w-full min-w-0 truncate text-sm text-muted-foreground">
{value != null ? String(value) : '—'}
</span>
</React.Fragment>
);
}
return (
<motion.div
ref={setNodeRef}
{...attributes}
{...listeners}
layout
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: isActiveDragging ? 0 : 1, y: 0 }}
transition={{ duration: 0.15 }}
onClick={onClick}
onMouseEnter={() => onMouseEnter?.(conv.id)}
className={cn(
// shrink-0 so a card keeps its height inside the column's flex-col scroll area rather
// than squishing to fit — squishing is what stops the column from scrolling.
'group bg-card relative w-full min-w-0 shrink-0 cursor-pointer overflow-hidden rounded-lg border px-2.5 py-2 shadow-xs',
'transition-colors outline-none select-none',
isSelected ? 'border-action/50 bg-action/10' : 'hover:border-primary/30',
isActiveDragging && 'opacity-0',
isDragging && 'cursor-grabbing shadow-lg',
)}
>
{/* 2-column grid — justify-items-start keeps badges at content width */}
<div className="grid grid-cols-[16px_1fr] items-center justify-items-start gap-x-2 gap-y-1.5">
{/* Title row — avatar + title + expand button */}
<Avatar className="h-4 w-4 shrink-0 rounded-sm">
<AvatarImage src={logoUrl ?? undefined} className="object-contain" />
<AvatarFallback className="rounded-sm bg-primary/10 text-[9px] font-bold text-primary">
{logoInitial}
</AvatarFallback>
</Avatar>
<div className="flex w-full min-w-0 items-center justify-between gap-1">
<span className="min-w-0 truncate text-sm font-semibold">{title}</span>
{onExpandClick && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 shrink-0 opacity-0 group-hover:opacity-100"
onClick={(e) => { e.stopPropagation(); onExpandClick(); }}
>
<Maximize2 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Open conversation</TooltipContent>
</Tooltip>
)}
</div>
{gridFields.map((field) => renderField(field))}
{/* Tasks — icon in first column, card in second */}
{showTasks && (
<React.Fragment>
<ListTodo
className={cn(
'h-4 w-4 shrink-0',
dueTasks.length > 0
? 'text-amber-600 dark:text-amber-400'
: 'text-green-600 dark:text-green-400',
)}
/>
{dueTasks.length > 0 ? (
<div className="w-full rounded border border-dashed border-amber-400 bg-amber-100/70 flex h-5 items-center px-1.5 dark:bg-amber-900/30">
<span className="truncate text-xs font-medium text-amber-800 dark:text-amber-300">
{dueTasks.length === 1 ? '1 task due' : `${dueTasks.length} tasks due`}
</span>
</div>
) : (
<div className="w-full rounded border border-dashed border-green-500/50 bg-green-50 flex h-5 items-center px-1.5 dark:bg-green-950/30">
<span className="truncate text-xs font-medium text-green-700 dark:text-green-400">
You're done
</span>
</div>
)}
</React.Fragment>
)}
</div>
</motion.div>
);
});