KanbanConversationCanvas.tsx22.0 KBView on GitHub /**
* KanbanConversationCanvas
*
* Canvas-scoped kanban board for CRM conversations.
*
* - Columns are determined by a configurable groupByField (default: status).
* - Cards show configurable fields.
* - Drag & drop moves a conversation to a new column (updates the grouped field).
* - Configuration is persisted to canvas.viewConfig via updateCanvasViewConfig + saveCanvasViewConfig.
*/
import {
closestCorners,
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from '@dnd-kit/core';
import { TooltipProvider } from '@/components/ui/tooltip';
import type {
KanbanCardField,
KanbanCardTitleField,
KanbanConversationViewConfig,
KanbanGroupByField,
NextStepDateBucket,
} from '@/modules/canvas/types/canvas-types';
import {
DEFAULT_KANBAN_CARD_FIELDS,
NEXT_STEP_DATE_BUCKET_IDS,
NEXT_STEP_DATE_BUCKET_LABELS,
} from '@/modules/canvas/types/canvas-types';
import type { Canvas } from '@/modules/canvas/types/canvas-types';
import type { ConversationWithMetadata } from '@/modules/conversations/slice/conversationsSlice';
import { useAllConversationEnumOptions } from '@/modules/crm/hooks/use-crm';
import {
getDotColor,
getEnumDisplayText,
getTextColorClass,
STATUS_ICON_MAP,
} from '@/modules/crm/utils';
import { useCrmCanvasRefresh } from '@/modules/crm/hooks/use-crm-canvas-refresh';
import { sortEnumOptions } from '@/modules/crm/field-enums';
import type { CrmFieldEnumOption } from '@/modules/crm/types';
import { CanvasKanbanColumn } from './CanvasKanbanColumn';
import { CanvasKanbanCard } from './CanvasKanbanCard';
import { KanbanConfigPopover } from './KanbanConfigPopover';
import { CanvasFilterSearchSection } from '../conversation-canvas/FilterSortConfigurationRow';
import { useCanvasConversations } from '@/modules/crm/hooks/use-canvas-conversations';
import { useCedarStore } from '@/modules/store';
import { useTRPC } from '@/providers/query-provider';
import { useMutation } from '@tanstack/react-query';
import { useCallback, useMemo, useState } from 'react';
import { Loader2, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { useScrollActive } from '@/hooks/use-scroll-active';
// ── Next Step Date bucketing ─────────────────────────────────────────────────
function startOfDay(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
function getNextStepDateBucket(nextStepDate: Date | string | null | undefined): NextStepDateBucket {
if (!nextStepDate) return 'no_date';
const date = startOfDay(new Date(nextStepDate));
const today = startOfDay(new Date());
const diffMs = date.getTime() - today.getTime();
const diffDays = diffMs / 86_400_000;
if (diffDays === 0) return 'today';
if (diffDays > 0) return 'future';
if (diffDays >= -7) return 'past_week';
return 'overdue';
}
/**
* Returns a representative Date to assign when dragging a conversation into a
* nextStepDate bucket. null = clear the nextStepDate.
*/
function representativeDateForBucket(bucket: NextStepDateBucket): Date | null {
const today = startOfDay(new Date());
switch (bucket) {
case 'today':
return today;
case 'future':
return new Date(today.getTime() + 7 * 86_400_000);
case 'past_week':
return new Date(today.getTime() - 3 * 86_400_000);
case 'overdue':
return new Date(today.getTime() - 14 * 86_400_000);
case 'no_date':
return null;
}
}
// ─────────────────────────────────────────────────────────────────────────────
interface KanbanConversationCanvasProps {
canvas: Canvas;
}
const NEXT_STEP_DATE_DOTS: Record<string, string> = {
overdue: 'bg-red-500',
past_week: 'bg-amber-500',
today: 'bg-blue-500',
future: 'bg-emerald-500',
no_date: 'bg-muted-foreground/40',
};
/**
* The column-header glyph: the status option's own icon, tinted with its colour — the icon-led
* header the task board uses. Returns undefined when the option has no icon (priority, date
* buckets), so the column falls back to its colour dot.
*/
function columnHeaderIcon(icon: string | null, color: string | null): React.ReactNode | undefined {
const Icon = icon ? STATUS_ICON_MAP[icon] : undefined;
if (!Icon) return undefined;
return <Icon className={cn('size-4 shrink-0', getTextColorClass(color ?? 'gray'))} />;
}
export function KanbanConversationCanvas({ canvas }: KanbanConversationCanvasProps) {
const trpc = useTRPC();
const viewConfig = canvas.viewConfig as KanbanConversationViewConfig;
const groupByField: KanbanGroupByField = viewConfig.groupByField ?? 'status';
const cardFields: KanbanCardField[] = viewConfig.cardFields ?? DEFAULT_KANBAN_CARD_FIELDS;
const cardTitleField: KanbanCardTitleField = viewConfig.cardTitleField ?? 'company';
const summaryField = viewConfig.summaryField;
const summaryAggregation = viewConfig.summaryAggregation ?? 'sum';
// Store actions
const updateCanvasViewConfig = useCedarStore((state) => state.updateCanvasViewConfig);
const saveCanvasViewConfig = useCedarStore((state) => state.saveCanvasViewConfig);
const conversations = useCedarStore((state) => state.conversations);
const currentConversationList = useCedarStore((state) => state.currentConversationList);
const activeConversationId = useCedarStore((state) => state.activeConversationId);
const setActiveConversationId = useCedarStore((state) => state.setActiveConversationId);
const setIsConversationOpen = useCedarStore((state) => state.setIsConversationOpen);
// Fetch conversations for this canvas (populates store.conversations).
// Kanban has no infinite scroll, so request a larger first page than the list view.
const [conversationsQuery] = useCanvasConversations(canvas.id, undefined, {
limit: 200,
fetchAllPages: true,
});
const conversationIds = currentConversationList.map((c) => c.id);
const { handleRefresh: handleCrmKanbanRefresh, isPending: isCrmRefreshPending } =
useCrmCanvasRefresh(conversationIds, conversationsQuery.refetch);
// Enum options for status / priority columns
const { data: statusOptionsData } = useAllConversationEnumOptions('status');
const { data: priorityOptionsData } = useAllConversationEnumOptions('priority');
// Active drag state
const [draggingId, setDraggingId] = useState<string | null>(null);
const boardScroll = useScrollActive<HTMLDivElement>();
// DnD sensors – 8px minimum movement before drag activates
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
);
// Column definitions from the chosen groupByField
const columns = useMemo(() => {
if (groupByField === 'nextStepDate') {
return NEXT_STEP_DATE_BUCKET_IDS.map((id) => ({
value: id,
label: NEXT_STEP_DATE_BUCKET_LABELS[id],
dotClass: NEXT_STEP_DATE_DOTS[id] ?? 'bg-muted-foreground/40',
color: null,
icon: null,
}));
}
const optionsData = groupByField === 'status' ? statusOptionsData : priorityOptionsData;
if (!optionsData?.options?.length) return [];
const sorted = sortEnumOptions(optionsData.options);
// `value: null` marks the synthetic "unset" option used by filter UIs; the
// board has no column for it (unset conversations are appended to the first
// column below), and a null id is not a valid dnd-kit droppable identifier.
return sorted
.filter((opt): opt is CrmFieldEnumOption & { value: string } => opt.value !== null)
.map((opt) => ({
value: opt.value,
label: getEnumDisplayText(opt.value, optionsData.options),
dotClass: getDotColor(opt.color).split(' ')[0] ?? 'bg-muted-foreground/40',
color: opt.color,
// Only status options carry an icon; priority falls back to the colour dot.
icon: opt.icon ?? null,
}));
}, [groupByField, statusOptionsData, priorityOptionsData]);
// Group conversations by the active groupByField, preserving sort order from currentConversationList
const conversationsByColumn = useMemo(() => {
const grouped: Record<string, ConversationWithMetadata[]> = {};
for (const col of columns) {
grouped[col.value] = [];
}
// Iterate in sort order (currentConversationList is ordered by the backend/client sort)
const orderedConvs = currentConversationList
.map(({ id }) => conversations[id])
.filter((c): c is ConversationWithMetadata => Boolean(c));
if (groupByField === 'nextStepDate') {
for (const conv of orderedConvs) {
const bucket = getNextStepDateBucket(conv.data.conversation.nextStepDate);
grouped[bucket]?.push(conv);
}
return grouped;
}
const unset: ConversationWithMetadata[] = [];
for (const conv of orderedConvs) {
const val = conv.data.conversation[groupByField] as string | null;
if (val && grouped[val] !== undefined) {
grouped[val].push(conv);
} else {
unset.push(conv);
}
}
// Append unset conversations to the first column
if (unset.length > 0 && columns.length > 0) {
grouped[columns[0].value] = [...grouped[columns[0].value], ...unset];
}
return grouped;
}, [conversations, currentConversationList, columns, groupByField]);
// Mutation to update a conversation field when dragged to a new column
const { mutateAsync: updateConversation } = useMutation(
trpc.crm.updateConversation.mutationOptions(),
);
const handleDragStart = useCallback((event: DragStartEvent) => {
setDraggingId(String(event.active.id));
}, []);
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event;
setDraggingId(null);
if (!over) return;
const conversationId = active.id as string;
const newColumnValue = over.id as string;
const conv = useCedarStore.getState().conversations[conversationId];
if (!conv) return;
// Determine the actual update payload
let updatePayload: Record<string, string | null | Date>;
let optimisticConvUpdate: typeof conv.data.conversation;
if (groupByField === 'nextStepDate') {
const currentBucket = getNextStepDateBucket(conv.data.conversation.nextStepDate);
if (currentBucket === newColumnValue) return;
const newDate = representativeDateForBucket(newColumnValue as NextStepDateBucket);
updatePayload = { nextStepDate: newDate };
optimisticConvUpdate = { ...conv.data.conversation, nextStepDate: newDate };
} else {
const currentValue = conv.data.conversation[groupByField] as string | null;
if (currentValue === newColumnValue) return;
updatePayload = { [groupByField]: newColumnValue };
optimisticConvUpdate = { ...conv.data.conversation, [groupByField]: newColumnValue };
}
// Optimistically update the single conversation in the store
const store = useCedarStore.getState();
store.setConversations({
[conversationId]: { ...conv.data, conversation: optimisticConvUpdate },
});
try {
await updateConversation({ id: conversationId, ...updatePayload });
} catch {
toast.error('Failed to move conversation');
store.setConversations({ [conversationId]: conv.data });
}
},
[groupByField, updateConversation],
);
// Config persistence helpers
const handleGroupByChange = useCallback(
(field: KanbanGroupByField) => {
updateCanvasViewConfig(canvas.id, { ...viewConfig, groupByField: field });
void saveCanvasViewConfig(canvas.id);
},
[canvas.id, viewConfig, updateCanvasViewConfig, saveCanvasViewConfig],
);
const handleCardFieldsChange = useCallback(
(fields: KanbanCardField[]) => {
updateCanvasViewConfig(canvas.id, { ...viewConfig, cardFields: fields });
void saveCanvasViewConfig(canvas.id);
},
[canvas.id, viewConfig, updateCanvasViewConfig, saveCanvasViewConfig],
);
const handleCardTitleFieldChange = useCallback(
(field: KanbanCardTitleField) => {
updateCanvasViewConfig(canvas.id, { ...viewConfig, cardTitleField: field });
void saveCanvasViewConfig(canvas.id);
},
[canvas.id, viewConfig, updateCanvasViewConfig, saveCanvasViewConfig],
);
const handleSummaryFieldChange = useCallback(
(field: string | undefined) => {
updateCanvasViewConfig(canvas.id, { ...viewConfig, summaryField: field });
void saveCanvasViewConfig(canvas.id);
},
[canvas.id, viewConfig, updateCanvasViewConfig, saveCanvasViewConfig],
);
const handleSummaryAggregationChange = useCallback(
(agg: 'sum' | 'average') => {
updateCanvasViewConfig(canvas.id, { ...viewConfig, summaryAggregation: agg });
void saveCanvasViewConfig(canvas.id);
},
[canvas.id, viewConfig, updateCanvasViewConfig, saveCanvasViewConfig],
);
// Enum fields that support column-level hide (nextStepDate uses date ranges — not supported)
const supportsColumnHide = groupByField !== 'nextStepDate';
// Set of column values currently excluded via filterSortConfiguration
const excludedColumnValues = useMemo<Set<string>>(() => {
if (!supportsColumnHide) return new Set();
const filter = viewConfig.filterSortConfiguration?.[groupByField]?.filter;
return new Set<string>((filter?.excluded ?? []).filter((v): v is string => v !== null));
}, [viewConfig.filterSortConfiguration, groupByField, supportsColumnHide]);
// Visible columns — filter out any whose value is in the excluded set
const visibleColumns = useMemo(
() => columns.filter((col) => !excludedColumnValues.has(col.value)),
[columns, excludedColumnValues],
);
// Per-column formatted summary line (e.g. "SUM $1.2M")
const summaryLines = useMemo<Record<string, string>>(() => {
if (!summaryField) return {};
const result: Record<string, string> = {};
for (const col of visibleColumns) {
const items = conversationsByColumn[col.value] ?? [];
const values = items
.map((conv) => {
const v = conv.data.conversation[summaryField as keyof typeof conv.data.conversation];
if (typeof v === 'number') return v;
if (typeof v === 'string') { const n = parseFloat(v); return isNaN(n) ? null : n; }
return null;
})
.filter((v): v is number => v !== null);
const total = values.reduce((a, b) => a + b, 0);
const computed = summaryAggregation === 'average'
? (values.length > 0 ? total / values.length : 0)
: total;
// Format value
let formatted: string;
if (summaryField === 'dealValue') {
formatted = `$${computed.toLocaleString(undefined, { maximumFractionDigits: 0 })}`;
} else {
formatted = computed.toLocaleString();
}
const label = summaryAggregation === 'average' ? 'AVG' : 'SUM';
result[col.value] = `${label} ${formatted}`;
}
return result;
}, [summaryField, summaryAggregation, visibleColumns, conversationsByColumn]);
// Toggle a column's visibility by adding/removing its value from excluded filter
const handleToggleColumnVisibility = useCallback(
(columnValue: string) => {
const currentConfig = viewConfig.filterSortConfiguration ?? {};
const currentFilter = currentConfig[groupByField]?.filter ?? {};
const excluded = (currentFilter.excluded ?? []).filter((v): v is string => v !== null);
const isExcluded = excluded.includes(columnValue);
const newExcluded = isExcluded
? excluded.filter((v) => v !== columnValue)
: [...excluded, columnValue];
const newFilter = newExcluded.length > 0 || (currentFilter.selected?.length ?? 0) > 0
? { ...currentFilter, excluded: newExcluded.length > 0 ? newExcluded : undefined }
: undefined;
const nextConfig: NonNullable<KanbanConversationViewConfig['filterSortConfiguration']> = {
...currentConfig,
};
if (newFilter) {
nextConfig[groupByField] = { ...currentConfig[groupByField], filter: newFilter };
} else {
delete nextConfig[groupByField];
}
updateCanvasViewConfig(canvas.id, {
...viewConfig,
filterSortConfiguration: nextConfig,
});
void saveCanvasViewConfig(canvas.id);
},
[canvas.id, viewConfig, groupByField, updateCanvasViewConfig, saveCanvasViewConfig],
);
const isLoading = conversationsQuery.isLoading;
const draggingConversation = draggingId ? conversations[draggingId] : null;
if (isLoading && Object.keys(conversations).length === 0) {
return (
<div className="flex h-full w-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<TooltipProvider delayDuration={300}>
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={() => setDraggingId(null)}
>
<div className="flex h-full w-full flex-col overflow-hidden">
{/* Filter / search bar */}
<div className="shrink-0 px-3 pt-2">
<CanvasFilterSearchSection canvas={canvas} />
</div>
{/* Toolbar */}
<div className="flex shrink-0 items-center justify-between gap-2 px-3 py-1">
<div className="flex items-center gap-2">
{isLoading && (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
)}
<span className="text-xs text-muted-foreground">
{currentConversationList.length} conversations
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => void handleCrmKanbanRefresh()}
disabled={conversationsQuery.isFetching || isCrmRefreshPending}
>
<RefreshCw
className={cn(
'h-3.5 w-3.5',
(conversationsQuery.isFetching || isCrmRefreshPending) && 'animate-spin',
)}
/>
</Button>
<KanbanConfigPopover
groupByField={groupByField}
cardTitleField={cardTitleField}
cardFields={cardFields}
summaryField={summaryField}
summaryAggregation={summaryAggregation}
onGroupByChange={handleGroupByChange}
onCardTitleFieldChange={handleCardTitleFieldChange}
onCardFieldsChange={handleCardFieldsChange}
onSummaryFieldChange={handleSummaryFieldChange}
onSummaryAggregationChange={handleSummaryAggregationChange}
/>
</div>
</div>
{/* Board */}
{/* The board's horizontal scrollbar was the one that stayed visible at rest — the
columns are already `scrollbar-none`. It only shows while you're actually
scrolling; `scrollbar-gutter: stable` reserves its width either way so the
columns don't shift when it appears. */}
<div
ref={boardScroll.ref}
className={cn(
// `min-h-0` bounds the columns' height so each one scrolls its own cards internally
// rather than the whole board growing — the task board's scroll model.
'flex min-h-0 flex-1 gap-3 overflow-x-auto px-3 pb-3 [scrollbar-gutter:stable]',
boardScroll.scrolling
? '[scrollbar-color:rgba(156,163,175,0.8)_transparent] dark:[scrollbar-color:rgba(255,255,255,0.2)_transparent]'
: '[scrollbar-color:transparent_transparent]',
)}
>
{visibleColumns.map((column) => {
const items = conversationsByColumn[column.value] ?? [];
return (
<CanvasKanbanColumn
key=[redacted]
id={column.value}
label={column.label}
dotClass={column.dotClass}
headerIcon={columnHeaderIcon(column.icon, column.color)}
items={items}
activeItemId={activeConversationId}
getItemId={(conv) => conv.data.conversation.id}
onHide={supportsColumnHide
? () => handleToggleColumnVisibility(column.value)
: undefined}
summaryLine={summaryLines[column.value]}
renderItem={(conv, isSelected) => (
<CanvasKanbanCard
conversation={conv}
cardFields={cardFields}
cardTitleField={cardTitleField}
isSelected={isSelected}
statusOptions={statusOptionsData?.options}
priorityOptions={priorityOptionsData?.options}
onClick={() => setActiveConversationId(conv.data.conversation.id)}
onExpandClick={() => {
setActiveConversationId(conv.data.conversation.id);
setIsConversationOpen(true);
}}
/>
)}
/>
);
})}
</div>
</div>
{/* Drag ghost */}
<DragOverlay dropAnimation={null}>
{draggingConversation && (
<CanvasKanbanCard
conversation={draggingConversation}
cardFields={cardFields}
cardTitleField={cardTitleField}
isSelected={false}
isDragging
statusOptions={statusOptionsData?.options}
priorityOptions={priorityOptionsData?.options}
/>
)}
</DragOverlay>
</DndContext>
</TooltipProvider>
);
}