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&apos;re done
                </span>
              </div>
            )}
          </React.Fragment>
        )}
      </div>
    </motion.div>
  );
});