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';