crm-cell.tsx41.0 KBView on GitHub

Introduced 1 production defect in 180 days, median 1 day to fix.

/**
 * CRM Cell Component
 * Renders editable cells based on column type
 * Uses atomic components from ConversationCellComponents for consistent editing
 */

import {
  StatusBadgeEditor,
  DateBadgeEditor,
  TextEditor,
  SelectEditor,
  NumberEditor,
  CurrentActionDisplay,
  FutureActionDisplay,
} from './ConversationCellComponents';
import {
  AlertCircle,
  Bot,
  Brain,
  CalendarIcon,
  Check,
  CheckCircle2,
  Clock,
  Link2,
  Link2Off,
  ListTodo,
  Mail,
  MessageSquare,
} from 'lucide-react';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { animate, AnimatePresence, motion, useMotionValue, useTransform } from 'motion/react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { memo, useCallback, useEffect, useState } from 'react';
import { DatePicker } from '@/components/ui/date-picker';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { Calendar } from '@/components/ui/calendar';
import { ListFieldChips } from './ListFieldChips';
import { Button } from '@/components/ui/button';
import { useCedarStore } from '@/modules/store';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { SignalDot } from './SignalDot';
import { format } from 'date-fns';
import { cn } from '@/lib/utils';

import type {
  CompanyCombined,
  CRMColumn,
  CrmSyncedValue,
  CurrentActionData,
  FutureActionData,
  NextStep,
  ScheduledAction,
  UserTask,
  WorkingMemoryField,
} from '../store/crmSlice';
import {
  getEnumDisplayText,
  getNextStepColor,
  getTypeColor,
  getAopColorStyles,
} from '@/modules/crm/utils';
import { taskOutputLabel } from '@/modules/userTasks/utils/task-output';
import { ConversationActivityOverview } from '../../conversations/components/timeline/ConversationActivityOverview';
import { useOptimisticConversationActions } from '../hooks/use-optimistic-conversation-actions';
import { formatRelativeDate, getScheduledTextColor } from '@/modules/crm/utils/time';
import { CRMCellTimeline } from '../../conversations/components/CRMCellTimeline';
import type { ConversationEvent, FieldSignal } from '../types';
import { useAOPs } from '@/modules/aop/hooks/use-aops';
import { parseFraction } from '../types';

interface CRMCellProps {
  rowId: string;
  column: CRMColumn;
  value:
    | string
    | boolean
    | number
    | ConversationEvent[]
    | NextStep
    | CompanyCombined
    | WorkingMemoryField[]
    | ScheduledAction
    | UserTask[]
    | CurrentActionData
    | FutureActionData
    | null
    | undefined;
  onBlur: (
    rowId: string,
    columnId: string,
    value:
      | string
      | boolean
      | number
      | ConversationEvent[]
      | NextStep
      | CompanyCombined
      | ScheduledAction
      | UserTask[]
      | WorkingMemoryField[]
      | CurrentActionData
      | FutureActionData
      | null
      | undefined,
  ) => void;
  reasoning?: string | null;
  conversationId?: string | null;
  isRowHovered?: boolean;
  isRowSelected?: boolean;
}

// AOP Type Cell - shows AOP name, allows changing AOP
const AopTypeCell = memo(({ conversationId }: { conversationId?: string | null }) => {
  const { data: aopsData } = useAOPs();
  const conversations = useCedarStore((state) => state.conversations);
  const conversationData = conversationId ? conversations[conversationId] : null;
  const { optimisticUpdateConversation } = useOptimisticConversationActions();

  const aopId = conversationData?.data.conversation.aopId;
  const aop = aopsData?.aops?.find((a) => a.id === aopId);
  const aopName = aop?.name;
  const aopColorStyles = getAopColorStyles(aop?.color);

  if (!aopId || !aopName) {
    return (
      <Badge
        variant="secondary"
        className="w-full cursor-pointer justify-center rounded-full bg-sunken px-2 py-0.5 font-normal text-muted-foreground"
      >
        <AlertCircle className="mr-1.5 h-3 w-3 shrink-0" />
        <span className="truncate">No AOP</span>
      </Badge>
    );
  }

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Badge
          variant="secondary"
          className={cn(
            'w-full cursor-pointer justify-center rounded-sm px-2 py-0.5',
            !aopColorStyles && getTypeColor(aopName),
          )}
          style={aopColorStyles || undefined}
        >
          {getEnumDisplayText(aopName)}
        </Badge>
      </DropdownMenuTrigger>
      <DropdownMenuContent>
        {aopsData?.aops?.map((aopOption) => {
          const optionColorStyles = getAopColorStyles(aopOption.color);
          return (
            <DropdownMenuItem
              key=[redacted]
              onClick={async () => {
                if (!conversationId) return;
                await optimisticUpdateConversation(conversationId, {
                  aopId: aopOption.id,
                });
              }}
            >
              <div className="flex items-center justify-between w-full">
                <Badge
                  variant="secondary"
                  className={cn('rounded-sm', !optionColorStyles && getTypeColor(aopOption.name))}
                  style={optionColorStyles || undefined}
                >
                  {getEnumDisplayText(aopOption.name)}
                </Badge>
                {aopId === aopOption.id && <Check className="h-4 w-4 text-green-600" />}
              </div>
            </DropdownMenuItem>
          );
        })}
      </DropdownMenuContent>
    </DropdownMenu>
  );
});

AopTypeCell.displayName = 'AopTypeCell';

// NextStepDateCell — clicking the date badge opens a popover with both date picker and next steps text
const NextStepDateCell = memo(
  ({
    conversationId,
    dateValue,
    onUpdate,
  }: {
    conversationId?: string | null;
    dateValue?: string;
    onUpdate: (columnId: string, value: string | undefined) => void;
    rowId: string;
  }) => {
    const conversations = useCedarStore((state) => state.conversations);
    const nextSteps = conversationId
      ? (conversations[conversationId]?.data.conversation.nextSteps ?? '')
      : '';
    const [open, setOpen] = useState(false);
    const [localNextSteps, setLocalNextSteps] = useState(nextSteps);

    useEffect(() => {
      setLocalNextSteps(nextSteps);
    }, [nextSteps]);

    const date = dateValue ? new Date(dateValue) : undefined;

    const handleOpenChange = (isOpen: boolean) => {
      if (!isOpen && localNextSteps !== nextSteps) {
        onUpdate('nextSteps', localNextSteps);
      }
      setOpen(isOpen);
    };

    return (
      <Popover open={open} onOpenChange={handleOpenChange}>
        <PopoverTrigger asChild>
          <Button
            variant="ghost"
            size="sm"
            className={cn(
              'h-8 w-full justify-start border-0 bg-transparent text-left font-normal focus-visible:ring-0',
              !date && 'text-muted-foreground',
            )}
          >
            <CalendarIcon className="mr-2 h-4 w-4" />
            {date ? format(date, 'PPP') : <span>Pick a date</span>}
          </Button>
        </PopoverTrigger>
        <PopoverContent className="w-72 p-0" align="start">
          <Calendar
            mode="single"
            selected={date}
            onSelect={(newDate) => {
              if (newDate) {
                const formatted = format(newDate, 'yyyy-MM-dd');
                onUpdate('nextStepDate', formatted);
              } else {
                onUpdate('nextStepDate', undefined);
              }
            }}
          />
          <div className="border-t px-3 py-2">
            <label className="text-muted-foreground mb-1 block text-xs font-medium">
              Next Steps
            </label>
            <Textarea
              value={localNextSteps}
              onChange={(e) => setLocalNextSteps(e.target.value)}
              placeholder="Enter next steps..."
              className="min-h-[60px] resize-none text-sm"
            />
          </div>
        </PopoverContent>
      </Popover>
    );
  },
);

NextStepDateCell.displayName = 'NextStepDateCell';

// Separate CompanyCell component with combined company + name
const CompanyCell = memo(
  ({
    localValue,
    conversationId,
    isRowHovered,
    isRowSelected,
    onBlur,
    rowId,
  }: {
    localValue:
      | string
      | boolean
      | number
      | ConversationEvent[]
      | NextStep
      | CompanyCombined
      | WorkingMemoryField[]
      | ScheduledAction
      | UserTask[]
      | CurrentActionData
      | FutureActionData
      | null
      | undefined;
    conversationId?: string | null;
    isRowHovered?: boolean;
    isRowSelected?: boolean;
    onBlur: (rowId: string, columnId: string, value: CompanyCombined | null | undefined) => void;
    rowId: string;
  }) => {
    const companyCombined = localValue as CompanyCombined | null;
    const companyName = companyCombined?.primaryCompany || 'No company';
    const conversationName = companyCombined?.name || '';

    const [isEditingName, setIsEditingName] = useState(false);
    const [editedName, setEditedName] = useState(conversationName);

    // Get full conversation data from store to access company logoUrl
    const conversations = useCedarStore((state) => state.conversations);
    const conversationData = conversationId ? conversations[conversationId] : null;
    const companyLogoUrl = conversationData?.data?.company?.logoUrl;

    // Get bulk selection state from store
    const conversationSelection = useCedarStore((state) => state.conversationSelection);
    const toggleConversationSelection = useCedarStore((state) => state.toggleConversationSelection);
    const isBulkSelected = conversationId ? conversationSelection.includes(conversationId) : false;

    // Handle checkbox click to toggle bulk selection
    const handleCheckboxClick = useCallback(
      (e: React.MouseEvent) => {
        e.stopPropagation();
        if (!conversationId) return;
        toggleConversationSelection(conversationId);
      },
      [conversationId, toggleConversationSelection],
    );

    // Get company initial (first letter of name)
    const companyInitial = companyName?.[0]?.toUpperCase() || '?';

    useEffect(() => {
      setEditedName(conversationName);
    }, [conversationName]);

    const handleNameBlur = () => {
      setIsEditingName(false);
      const updated: CompanyCombined = {
        primaryCompany: companyCombined?.primaryCompany || null,
        name: editedName,
      };
      onBlur(rowId, 'primaryCompany', updated);
    };

    const setActiveConversationId = useCedarStore((state) => state.setActiveConversationId);
    const setIsConversationOpen = useCedarStore((state) => state.setIsConversationOpen);

    // Motion values for distortion effect
    const deform = useMotionValue(0);
    const rotateX = useTransform(() => deform.get() * -3);
    const skewY = useTransform(() => deform.get() * -1);
    const scaleY = useTransform(() => 1 + deform.get() * 0.05);
    const scaleX = useTransform(() => 1 - deform.get() * 0.03);

    // Motion values for gradient overlay
    const breathe = useMotionValue(0);

    // Trigger distortion animation on hover
    useEffect(() => {
      if (isRowHovered && conversationId) {
        // Start distortion animation
        animate([
          [deform, 1, { duration: 0.3, ease: [0.65, 0, 0.35, 1] }],
          [deform, 0, { duration: 0.8, ease: [0.22, 1, 0.36, 1] }],
        ]);

        // Start breathing animation for gradient
        animate(breathe, 1, {
          duration: 0.4,
          ease: [0, 0.55, 0.45, 1],
        });
      } else {
        // Reset animations
        animate(deform, 0, { duration: 0.2 });
        animate(breathe, 0, { duration: 0.2 });
      }
    }, [isRowHovered, conversationId, deform, breathe]);

    const handleOpenConversation = (e: React.MouseEvent) => {
      e.stopPropagation();
      if (conversationId) {
        setActiveConversationId(conversationId);
        setIsConversationOpen(true);
      }
    };

    return (
      <div
        className="relative flex h-full min-h-24 w-full flex-col items-center justify-center gap-1 overflow-visible px-2 py-2"
        onClick={handleOpenConversation}
      >
        {/* Gradient overlay - only visible on hover */}
        <AnimatePresence>
          {isRowHovered && conversationId && (
            <>
              {/* Top gradient with breathing effect */}
              <motion.div
                className="pointer-events-none absolute inset-0 z-10 rounded-md"
                initial={{ opacity: 0 }}
                animate={{ opacity: 0.4 }}
                exit={{ opacity: 0 }}
                transition={{ duration: 0.3 }}
                style={{
                  scale: breathe,
                  background:
                    'radial-gradient(ellipse 120% 80% at 50% 20%, rgba(59, 130, 246, 0.3), transparent 70%)',
                  filter: 'blur(8px)',
                }}
              />
              {/* Bottom gradient */}
              <motion.div
                className="pointer-events-none absolute inset-0 z-10 rounded-md"
                initial={{ opacity: 0 }}
                animate={{ opacity: 0.3 }}
                exit={{ opacity: 0 }}
                transition={{ duration: 0.3, delay: 0.05 }}
                style={{
                  scale: breathe,
                  background:
                    'radial-gradient(ellipse 100% 60% at 50% 80%, rgba(147, 197, 253, 0.25), transparent 60%)',
                  filter: 'blur(10px)',
                }}
              />
            </>
          )}
        </AnimatePresence>

        {/* Company with logo and name */}
        <motion.div
          className="relative z-10 flex w-full items-center justify-center gap-2"
          style={{
            rotateX,
            skewY,
            scaleY,
            scaleX,
            originX: 0.5,
            originY: 0.5,
            transformPerspective: 500,
          }}
        >
          {/* Avatar or Checkbox - show checkbox on hover or when bulk selected */}
          <div
            className="group/checkbox relative flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center"
            onClick={handleCheckboxClick}
          >
            {isRowHovered || isBulkSelected ? (
              <>
                {/* Background circle for extra hitbox - uses group-hover to trigger on parent hover */}
                <div className="group-hover/checkbox:bg-primary/20 absolute left-1/2 top-1/2 z-10 h-8 w-8 -translate-x-1/2 -translate-y-1/2 rounded-full transition-colors" />
                {/* Checkbox on top */}
                <Checkbox checked={isBulkSelected} className="relative z-20 h-4 w-4" />
              </>
            ) : (
              <Avatar className="h-5 w-5 rounded-md">
                <AvatarImage src={companyLogoUrl || undefined} className="object-contain" />
                <AvatarFallback className="rounded-md bg-primary/10 text-xs font-semibold text-primary">
                  {companyInitial}
                </AvatarFallback>
              </Avatar>
            )}
          </div>
          <span className="truncate text-sm font-semibold">{companyName}</span>
        </motion.div>

        {/* "Open" text fading in from top */}
        <AnimatePresence>
          {isRowHovered && conversationId && (
            <motion.div
              initial={{ opacity: 0, y: -10 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: -10 }}
              transition={{ duration: 0.2, ease: 'easeOut' }}
              className="text-muted-foreground absolute top-1 z-20 w-full text-center text-xs"
            >
              Open
            </motion.div>
          )}
        </AnimatePresence>

        {/* Editable Name */}
        <div className="relative z-10 w-full">
          {isEditingName ? (
            <Input
              type="text"
              value={editedName}
              onChange={(e) => setEditedName(e.target.value)}
              onBlur={handleNameBlur}
              className="h-7 border-0 bg-transparent px-2 text-center text-sm focus-visible:ring-0"
              placeholder="Enter name..."
              autoFocus
              onClick={(e) => e.stopPropagation()}
            />
          ) : (
            <div
              onClick={(e) => {
                e.stopPropagation();
                setIsEditingName(true);
              }}
              className={cn(
                'w-full cursor-text px-2 text-center text-sm text-foreground',
                isRowSelected ? 'line-clamp-2' : 'line-clamp-1',
              )}
            >
              {conversationName || (
                <span className="text-muted-foreground opacity-50">Click to add name...</span>
              )}
            </div>
          )}
        </div>
      </div>
    );
  },
);

CompanyCell.displayName = 'CompanyCell';

/**
 * Simple key/value editor for a `json` field. Renders each top-level key of the stored
 * JSON object with an editable value input. On blur, rebuilds the object (preserving all
 * keys, incl. nested/rep-owned ones untouched) and emits the stringified JSON — the write
 * path treats it as a partial patch and deep-merges it, so untouched keys are safe.
 */
const JsonFieldCell = memo(
  ({
    value,
    onCommit,
  }: {
    value: string;
    onCommit: (next: string) => void;
  }) => {
    let parsed: Record<string, unknown> | null = null;
    try {
      const candidate = value ? JSON.parse(value) : {};
      if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
        parsed = candidate as Record<string, unknown>;
      }
    } catch {
      parsed = null;
    }

    // Not a JSON object (malformed / array / scalar) — show the raw text, read-only.
    if (!parsed) {
      return (
        <div className="w-full px-3 py-2">
          <span className="line-clamp-3 text-center text-xs text-muted-foreground" title={value}>
            {value || 'Empty'}
          </span>
        </div>
      );
    }

    const entries = Object.entries(parsed);
    if (entries.length === 0) {
      return (
        <div className="w-full px-3 py-2">
          <span className="text-center text-xs text-muted-foreground">Empty</span>
        </div>
      );
    }

    const commitKey=[redacted] string, raw: string) => {
      const original = (parsed as Record<string, unknown>)[key];
      // Don't flatten nested objects/arrays from a single-line input — leave as-is.
      if (original !== null && typeof original === 'object') return;
      // No semantic edit → do not rewrite the value. A null (or undefined) renders as an empty
      // input, so a plain blur must leave it null rather than coerce it to "". null/undefined
      // display as '' to match the Input's defaultValue below.
      const originalDisplay = original === null || original === undefined ? '' : String(original);
      if (raw === originalDisplay) return;
      // Preserve the original value's type where trivially possible; otherwise store as string.
      let nextValue: unknown = raw;
      if (typeof original === 'number') {
        const n = Number(raw);
        nextValue = Number.isNaN(n) ? raw : n;
      } else if (typeof original === 'boolean') {
        nextValue = raw === 'true';
      }
      const next = { ...(parsed as Record<string, unknown>), [key]: nextValue };
      onCommit(JSON.stringify(next));
    };

    return (
      <div className="flex w-full flex-col gap-1 px-3 py-2">
        {entries.map(([key, val]) => {
          const isComplex = val !== null && typeof val === 'object';
          return (
            <div key=[redacted] className="flex items-center gap-2">
              <span className="min-w-0 flex-shrink-0 truncate text-xs text-muted-foreground" title={key}>
                {key}
              </span>
              {isComplex ? (
                <span
                  className="min-w-0 flex-1 truncate text-right text-xs text-muted-foreground"
                  title={JSON.stringify(val)}
                >
                  {Array.isArray(val) ? `[${val.length}]` : '{…}'}
                </span>
              ) : (
                <Input
                  defaultValue={val === null ? '' : String(val)}
                  onBlur={(e) => commitKey(key, e.target.value)}
                  className="h-6 flex-1 border-0 bg-transparent text-right text-xs focus-visible:ring-0"
                />
              )}
            </div>
          );
        })}
      </div>
    );
  },
);

JsonFieldCell.displayName = 'JsonFieldCell';

export const CRMCell = memo(
  ({
    rowId,
    column,
    value,
    reasoning,
    onBlur,
    conversationId,
    isRowHovered,
    isRowSelected,
  }: CRMCellProps) => {
    const [localValue, setLocalValue] = useState(value);

    useEffect(() => {
      setLocalValue(value);
    }, [value]);

    const handleUpdate = useCallback(
      (
        newValue:
          | string
          | boolean
          | number
          | ConversationEvent[]
          | NextStep
          | CompanyCombined
          | ScheduledAction
          | UserTask[]
          | WorkingMemoryField[]
          | null
          | undefined,
      ) => {
        setLocalValue(newValue);
      },
      [],
    );

    const handleBlur = useCallback(
      (
        newValue:
          | string
          | boolean
          | number
          | NextStep
          | CompanyCombined
          | ScheduledAction
          | UserTask[]
          | WorkingMemoryField[]
          | null
          | undefined,
      ) => {
        // No conversion needed - null values pass through directly
        onBlur(rowId, column.id, newValue);
      },
      [rowId, column.id, onBlur],
    );

    switch (column.type) {
      case 'next-step':
        const nextStep = localValue as NextStep | undefined;
        if (nextStep && nextStep.action) {
          return (
            <div className="break-words">
              <Badge
                variant="secondary"
                className={cn(
                  'whitespace-normal break-words rounded-sm',
                  getNextStepColor(nextStep.action),
                )}
              >
                <span className="break-words">{nextStep.action}</span>
                {nextStep.date && (
                  <span className="ml-1 whitespace-nowrap opacity-70">
                    @ {formatRelativeDate(nextStep.date)}
                  </span>
                )}
              </Badge>
            </div>
          );
        }
        return <span className="text-muted-foreground text-sm">No next step</span>;

      case 'status-badge': {
        const status = (localValue as string) || null;
        return (
          <div className="flex w-full items-center justify-center px-2 py-1">
            <StatusBadgeEditor
              value={status}
              options={column.enumOptions || []}
              onSelect={(newStatus) => {
                handleUpdate(newStatus);
                handleBlur(newStatus);
              }}
              columnId="status"
              placeholder="Select..."
            />
          </div>
        );
      }

      case 'working-memory':
        const memoryFields = (localValue as WorkingMemoryField[]) || [];
        const hasMemory = memoryFields.length > 0;

        return (
          <Popover>
            <PopoverTrigger asChild>
              <Button
                variant="ghost"
                size="sm"
                className={cn(
                  'h-8 w-full justify-start gap-2 px-2 font-normal',
                  !hasMemory && 'text-muted-foreground opacity-50',
                )}
              >
                <Brain className="h-3.5 w-3.5 shrink-0" />
                <span className="truncate">
                  {hasMemory
                    ? `${memoryFields.length} field${memoryFields.length === 1 ? '' : 's'}`
                    : 'No custom fields'}
                </span>
              </Button>
            </PopoverTrigger>
            <PopoverContent className="w-80 p-0" align="start">
              <div className="p-3">
                <div className="mb-2 flex items-center gap-2">
                  <Brain className="text-muted-foreground h-4 w-4" />
                  <h4 className="text-sm font-medium">Custom Custom Fields</h4>
                </div>

                {hasMemory ? (
                  <div className="space-y-3">
                    {memoryFields.map((field, index) => (
                      <div key=[redacted] className="space-y-1">
                        <div className="flex items-center justify-between">
                          <span className="text-muted-foreground text-xs font-medium">
                            {field.name}
                          </span>
                        </div>
                        <div className="bg-muted/50 rounded-md p-2 text-xs">
                          {field.value || (
                            <span className="text-muted-foreground italic">Empty</span>
                          )}
                        </div>
                      </div>
                    ))}
                  </div>
                ) : (
                  <div className="text-muted-foreground py-4 text-center text-sm">
                    No custom custom fields
                  </div>
                )}
              </div>
            </PopoverContent>
          </Popover>
        );

      case 'user-tasks':
        const tasks = (localValue as UserTask[]) || [];
        const todoTasks = tasks.filter((t) => t.status === 'todo');
        const hasTasks = todoTasks.length > 0;

        if (!hasTasks) {
          return (
            <div className="text-muted-foreground flex w-full items-center justify-center text-xs">
              No tasks
            </div>
          );
        }

        return (
          <div className="w-full space-y-1.5 py-1">
            {todoTasks.map((task) => {
              // The output axis decides what this task produces; payload presence on it decides
              // whether the artifact already exists.
              const output = task.taskOutput;

              // Determine icon and description based on the task's output kind
              let taskIcon;
              let metaDescription;

              if (output?.kind === 'email') {
                taskIcon = <Mail className="h-3 w-3" />;
                metaDescription = output.draftId ? 'Email draft ready to send' : 'Reply needed';
              } else if (output?.kind === 'slack') {
                taskIcon = <MessageSquare className="h-3 w-3" />;
                metaDescription = `Reply in ${output.channelName || 'Slack'}`;
              } else {
                taskIcon = <ListTodo className="h-3 w-3" />;
                metaDescription = task.description || taskOutputLabel(task);
              }

              return (
                <Badge
                  key=[redacted]
                  variant="secondary"
                  className="w-full justify-center gap-1.5 rounded-full bg-sunken px-2 py-0.5 font-normal text-yellow-600 dark:text-yellow-400"
                >
                  {taskIcon}
                  <span className="truncate">{metaDescription}</span>
                </Badge>
              );
            })}
          </div>
        );

      case 'timeline':
        return (
          <div className="w-full">
            <CRMCellTimeline events={(localValue as ConversationEvent[]) || []} />
          </div>
        );

      case 'activity-overview':
        return (
          <div className="w-full min-w-[250px] px-2 py-1">
            <ConversationActivityOverview events={(localValue as ConversationEvent[]) || []} />
          </div>
        );

      case 'current-action':
        return (
          <CurrentActionDisplay
            value={localValue as CurrentActionData}
            isSelected={isRowSelected}
          />
        );

      case 'future-action':
        return (
          <FutureActionDisplay value={localValue as FutureActionData} isSelected={isRowSelected} />
        );

      case 'scheduled-action':
        const scheduledAction = localValue as ScheduledAction | null;

        if (!scheduledAction) {
          return (
            <div className="text-muted-foreground flex items-center gap-1.5 text-sm">
              <Bot className="h-3.5 w-3.5 opacity-50" />
              <span className="opacity-50">No action</span>
            </div>
          );
        }

        const getStatusConfig = (status: ScheduledAction['status']) => {
          switch (status) {
            case 'pending':
              return {
                icon: <Clock className="h-3 w-3" />,
                color: 'bg-sunken text-blue-600 dark:text-blue-400',
                label: 'Scheduled',
              };
            case 'final':
              return {
                icon: <CheckCircle2 className="h-3 w-3" />,
                color: 'bg-sunken text-muted-foreground',
                label: 'No scheduled actions',
              };
            case 'completed':
              return {
                icon: <CheckCircle2 className="h-3 w-3" />,
                color: 'bg-sunken text-green-600 dark:text-green-400',
                label: 'Completed',
              };
            case 'canceled':
              return {
                icon: <AlertCircle className="h-3 w-3" />,
                color: 'bg-sunken text-muted-foreground',
                label: 'Canceled',
              };
            case 'failed':
              return {
                icon: <AlertCircle className="h-3 w-3" />,
                color: 'bg-sunken text-red-600 dark:text-red-400',
                label: 'Failed',
              };
            default:
              return {
                icon: <Bot className="h-3 w-3" />,
                color: 'bg-sunken text-muted-foreground',
                label: status,
              };
          }
        };

        const statusConfig = getStatusConfig(scheduledAction.status);
        const promptText = scheduledAction.prompt || 'No description';

        return (
          <div className="h-auto w-full justify-start gap-2 px-2 py-2 text-center font-normal">
            {/* Single badge with icon, "Scheduled:", and date for pending actions */}
            {scheduledAction.status === 'pending' && scheduledAction.scheduledFor ? (
              <Badge
                variant="secondary"
                className={cn(
                  'justify-center gap-1 rounded-full bg-sunken px-2 py-0.5 font-normal',
                  getScheduledTextColor(scheduledAction.scheduledFor),
                )}
              >
                <Clock className="h-3 w-3" />
                Scheduled: {formatRelativeDate(scheduledAction.scheduledFor)}
              </Badge>
            ) : (
              <Badge
                variant="secondary"
                className={cn(
                  'justify-center gap-1 rounded-full px-2 py-0.5 font-normal',
                  statusConfig.color,
                )}
              >
                {statusConfig.icon}
                {statusConfig.label}
              </Badge>
            )}
            <span className="text-muted-foreground line-clamp-2 w-full text-sm">{promptText}</span>
          </div>
        );

      case 'crm-synced': {
        const syncData = localValue as CrmSyncedValue | null;
        const isSynced = syncData?.isSynced ?? false;
        const provider = syncData?.provider || '';

        if (isSynced) {
          return (
            <Badge
              variant="secondary"
              className="w-full justify-center gap-1.5 rounded-full bg-sunken px-2 py-0.5 font-normal text-green-600 dark:text-green-400"
            >
              <Link2 className="h-3 w-3" />
              <span className="truncate">
                {provider ? provider.charAt(0).toUpperCase() + provider.slice(1) : 'Synced'}
              </span>
            </Badge>
          );
        }

        return (
          <Badge
            variant="secondary"
            className="w-full justify-center gap-1.5 rounded-full bg-sunken px-2 py-0.5 font-normal text-muted-foreground"
          >
            <Link2Off className="h-3 w-3" />
            <span>Not Synced</span>
          </Badge>
        );
      }

      case 'checkbox':
        return (
          <Checkbox
            checked={typeof localValue === 'boolean' ? localValue : false}
            onCheckedChange={(checked) => {
              handleUpdate(checked === true);
              handleBlur(checked === true);
            }}
          />
        );

      case 'aop-type':
        // AOP Type column - derived from AOP name, shows AOP selection
        // This is handled in its own case, not in 'select'
        return <AopTypeCell conversationId={conversationId} />;

      case 'select':
        if (column.id === 'status' || column.id === 'priority') {
          return (
            <StatusBadgeEditor
              value={localValue as string}
              options={column.enumOptions || []}
              onSelect={(value) => {
                handleUpdate(value);
                handleBlur(value);
              }}
              columnId={column.id as 'status' | 'priority'}
              className="w-full justify-center"
            />
          );
        }

        // Default select for other columns (including custom fields and dealStatus)
        return (
          <SelectEditor
            value={localValue as string}
            options={column.enumOptions || []}
            onSelect={(value) => {
              handleUpdate(value);
              handleBlur(value);
            }}
            className="w-full justify-center"
          />
        );

      // A list column with options is a multi-select holding several values at once. Both
      // shapes stay read-only: the options belong to the CRM sync, and an optionless list
      // column holds agent-written markdown that a one-line cell editor would clobber.
      case 'list': {
        const listValue = typeof localValue === 'string' ? localValue : null;

        if (!column.enumOptions?.length) {
          return (
            <div className="w-full px-3 py-2">
              <span className="line-clamp-3 text-center text-sm" title={listValue ?? undefined}>
                {listValue || ''}
              </span>
            </div>
          );
        }

        return (
          <div className="flex w-full min-w-0 items-center justify-center overflow-hidden px-2">
            <ListFieldChips
              value={listValue}
              options={column.enumOptions}
              className="flex-nowrap"
            />
          </div>
        );
      }

      case 'date': {
        const dateValue = typeof localValue === 'string' ? localValue : undefined;

        if (column.id === 'nextStepDate') {
          return (
            <NextStepDateCell
              conversationId={conversationId}
              dateValue={dateValue}
              onUpdate={(columnId, value) => {
                handleUpdate(value);
                onBlur(rowId, columnId, value);
              }}
              rowId={rowId}
            />
          );
        }

        if (column.id === 'lastContactedAt') {
          return (
            <DateBadgeEditor
              value={dateValue}
              onChange={(date) => {
                if (date) {
                  const formatted = format(date, 'yyyy-MM-dd');
                  handleUpdate(formatted);
                  handleBlur(formatted);
                } else {
                  handleUpdate(undefined);
                  handleBlur(undefined);
                }
              }}
              className="w-full"
            />
          );
        }

        return (
          <DatePicker
            value={dateValue}
            onChange={(date) => {
              handleUpdate(date);
              handleBlur(date);
            }}
            placeholder="Select date"
          />
        );
      }

      case 'number':
        return (
          <div className="relative flex w-full items-center justify-center px-2">
            <NumberEditor
              value={localValue as string | number}
              onBlur={(value) => {
                handleUpdate(value);
                handleBlur(value);
              }}
              type="number"
            />
          </div>
        );

      case 'company-combined':
        return (
          <CompanyCell
            localValue={localValue}
            conversationId={conversationId}
            isRowHovered={isRowHovered}
            isRowSelected={isRowSelected}
            onBlur={(rowId, columnId, value) => onBlur(rowId, columnId, value)}
            rowId={rowId}
          />
        );

      case 'currency':
        return (
          <div className="relative flex w-full items-center justify-center px-2">
            <NumberEditor
              value={localValue as string | number}
              onBlur={(value) => {
                handleUpdate(value);
                handleBlur(value);
              }}
              type="currency"
            />
          </div>
        );

      case 'boolean': {
        const boolValue =
          typeof localValue === 'boolean'
            ? localValue
            : typeof localValue === 'string'
              ? localValue === 'true'
              : false;

        return (
          <div className="flex w-full items-center justify-center">
            <Checkbox
              checked={boolValue}
              onCheckedChange={(checked) => {
                const value = checked === true ? 'true' : 'false';
                handleUpdate(value);
                handleBlur(value);
              }}
            />
          </div>
        );
      }

      case 'url': {
        const urlValue = typeof localValue === 'string' ? localValue : '';

        return (
          <Input
            type="url"
            value={urlValue}
            onChange={(e) => handleUpdate(e.target.value)}
            onBlur={(e) => handleBlur(e.target.value)}
            className="h-8 border-0 bg-transparent text-center text-xs focus-visible:ring-0"
            placeholder="https://..."
          />
        );
      }

      case 'phone': {
        const phoneValue = typeof localValue === 'string' ? localValue : '';

        return (
          <Input
            type="tel"
            value={phoneValue}
            onChange={(e) => handleUpdate(e.target.value)}
            onBlur={(e) => handleBlur(e.target.value)}
            className="h-8 border-0 bg-transparent text-center focus-visible:ring-0"
            placeholder="+1 (555) 123-4567"
          />
        );
      }

      case 'fraction': {
        const raw = typeof localValue === 'string' ? localValue : '';
        const parsed = parseFraction(raw);
        const ratio =
          parsed && parsed.denominator !== 0
            ? Math.max(0, Math.min(1, parsed.numerator / parsed.denominator))
            : 0;
        return (
          <div className="flex w-full items-center justify-center gap-2 px-2">
            <Input
              type="text"
              value={raw}
              onChange={(e) => handleUpdate(e.target.value)}
              onBlur={(e) => handleBlur(e.target.value)}
              className="h-8 w-16 border-0 bg-transparent text-center text-xs tabular-nums focus-visible:ring-0"
              placeholder="n/d"
            />
            <div className="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
              <div
                className={cn('h-full rounded-full', parsed ? 'bg-primary' : 'bg-muted')}
                style={{ width: `${Math.round(ratio * 100)}%` }}
              />
            </div>
          </div>
        );
      }

      case 'text': {
        const shouldUseTextarea =
          column.id === 'statusOverview' ||
          column.id === 'nextSteps' ||
          (column.id.startsWith('wm_') && column.id !== 'wm_custom' && column.type === 'text');

        return (
          <div className="w-full px-3 py-2">
            <TextEditor
              value={localValue as string}
              onBlur={(value) => {
                handleUpdate(value);
                handleBlur(value);
              }}
              multiline={shouldUseTextarea}
              placeholder={`Enter ${column.name?.toLowerCase() || 'value'}...`}
              className={cn(
                'text-center',
                shouldUseTextarea && (isRowSelected ? 'line-clamp-5' : 'line-clamp-3'),
              )}
              inputClassName="text-center"
            />
          </div>
        );
      }

      case 'json': {
        const jsonValue = typeof localValue === 'string' ? localValue : '';
        return (
          <JsonFieldCell
            value={jsonValue}
            onCommit={(next) => {
              handleUpdate(next);
              handleBlur(next);
            }}
          />
        );
      }

      case 'score': {
        const signal =
          localValue === 'red' || localValue === 'yellow' || localValue === 'green'
            ? (localValue as FieldSignal)
            : null;
        if (signal == null) return null;
        return (
          <div className="flex items-center justify-center w-full">
            <SignalDot signal={signal} reasoning={reasoning ?? null} />
          </div>
        );
      }

      default:
        return <span className="text-muted-foreground text-sm">Unknown column type</span>;
    }
  },
);

CRMCell.displayName = 'CRMCell';