KanbanConfigPopover.tsx16.1 KBView on GitHub /**
* KanbanConfigPopover
*
* Settings popover for the KanbanConversationCanvas.
*
* Layout:
* 1. Group columns by — pill toggles
* 2. Card title — company / deal name toggle
* 3. Prototypical card preview:
* - Header row: avatar + title (always shown)
* - Draggable field rows (using @dnd-kit/sortable)
* - Click a row to swap it for another field (inline dropdown)
* - Remove button on each row
* - "Add row" button at the bottom
*/
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
arrayMove,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Settings2,
GripVertical,
X,
Plus,
Calendar,
FileText,
DollarSign,
Circle,
User,
Clock,
Zap,
Layers,
Link,
Tag,
BarChart2,
Dot,
} from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
import type { KanbanCardField, KanbanCardTitleField, KanbanGroupByField } from '@/modules/canvas/types/canvas-types';
import {
KANBAN_GROUP_BY_LABELS,
DEFAULT_KANBAN_CARD_FIELDS,
} from '@/modules/canvas/types/canvas-types';
import { conversationColumnsConfig } from '@/modules/crm/config/conversation-columns';
import type { ColumnType } from '@/modules/crm/store/crmSlice';
import { cn } from '@/lib/utils';
const ALL_GROUP_BY_FIELDS = Object.keys(KANBAN_GROUP_BY_LABELS) as KanbanGroupByField[];
// All column IDs from conversationColumnsConfig excluding 'primaryCompany'
const ALL_CARD_FIELDS: KanbanCardField[] = conversationColumnsConfig
.filter((col) => col.id !== 'primaryCompany')
.map((col) => col.id);
function getFieldIcon(colType: ColumnType | string): React.ReactNode {
switch (colType) {
case 'date':
return <Calendar size={11} />;
case 'text':
return <FileText size={11} />;
case 'number':
return <DollarSign size={11} />;
case 'status-badge':
return <Circle size={11} className="fill-blue-500 text-blue-500" />;
case 'select':
return <Circle size={11} />;
case 'user':
return <User size={11} />;
case 'timeline':
return <Clock size={11} />;
case 'current-action':
return <Zap size={11} />;
case 'working-memory':
return <Layers size={11} />;
case 'crm-synced':
return <Link size={11} />;
case 'aop-type':
return <Tag size={11} />;
case 'activity-overview':
return <BarChart2 size={11} />;
default:
return <Dot size={11} />;
}
}
function getColumnMeta(fieldId: string): { icon: React.ReactNode; label: string } {
const col = conversationColumnsConfig.find((c) => c.id === fieldId);
if (col) {
return { icon: getFieldIcon(col.type), label: col.name };
}
return { icon: <Dot size={11} />, label: fieldId };
}
// Numeric columns that can be summed/averaged
const SUMMABLE_FIELDS = conversationColumnsConfig.filter((c) => c.type === 'number');
interface KanbanConfigPopoverProps {
groupByField: KanbanGroupByField;
cardTitleField: KanbanCardTitleField;
cardFields: KanbanCardField[];
summaryField?: string;
summaryAggregation?: 'sum' | 'average';
onGroupByChange: (field: KanbanGroupByField) => void;
onCardTitleFieldChange: (field: KanbanCardTitleField) => void;
onCardFieldsChange: (fields: KanbanCardField[]) => void;
onSummaryFieldChange: (field: string | undefined) => void;
onSummaryAggregationChange: (agg: 'sum' | 'average') => void;
}
// ── Sortable row ─────────────────────────────────────────────────────────────
interface SortableFieldRowProps {
field: KanbanCardField;
availableFields: KanbanCardField[];
onSwap: (newField: KanbanCardField) => void;
onRemove: () => void;
}
function SortableFieldRow({ field, availableFields, onSwap, onRemove }: SortableFieldRowProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: field,
});
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
};
const { icon, label } = getColumnMeta(field);
// Close dropdown on outside click
useEffect(() => {
if (!dropdownOpen) return;
function handleClick(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [dropdownOpen]);
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'group flex items-center gap-1.5 rounded px-1 py-1 relative',
isDragging && 'opacity-50 bg-muted z-50',
!isDragging && 'hover:bg-muted/60',
)}
>
{/* Drag handle */}
<button
{...attributes}
{...listeners}
className="cursor-grab touch-none text-muted-foreground/40 hover:text-muted-foreground active:cursor-grabbing"
tabIndex={-1}
aria-label="Drag to reorder"
>
<GripVertical size={12} />
</button>
{/* Field icon */}
<span className="shrink-0 text-muted-foreground/60">{icon}</span>
{/* Field name — click to open swap dropdown */}
<button
className="flex flex-1 items-center gap-1 text-left text-xs text-foreground hover:text-foreground/70"
onClick={() => setDropdownOpen((v) => !v)}
>
{label}
</button>
{/* Remove button */}
<button
className="ml-auto shrink-0 text-muted-foreground/30 opacity-0 transition-opacity group-hover:opacity-100 hover:text-destructive"
onClick={onRemove}
aria-label={`Remove ${label}`}
>
<X size={11} />
</button>
{/* Swap dropdown */}
{dropdownOpen && availableFields.length > 0 && (
<div
ref={dropdownRef}
className="absolute left-0 top-full z-50 mt-0.5 w-40 rounded-md border bg-popover shadow-md"
>
{availableFields.map((f) => {
const meta = getColumnMeta(f);
return (
<button
key={f}
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-xs hover:bg-muted"
onClick={() => { onSwap(f); setDropdownOpen(false); }}
>
<span className="text-muted-foreground">{meta.icon}</span>
{meta.label}
</button>
);
})}
</div>
)}
</div>
);
}
// ── Main popover ─────────────────────────────────────────────────────────────
export function KanbanConfigPopover({
groupByField,
cardTitleField,
cardFields,
summaryField,
summaryAggregation = 'sum',
onGroupByChange,
onCardTitleFieldChange,
onCardFieldsChange,
onSummaryFieldChange,
onSummaryAggregationChange,
}: KanbanConfigPopoverProps) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
);
const [addOpen, setAddOpen] = useState(false);
const addRef = useRef<HTMLDivElement>(null);
// Fields not yet on the card
const unusedFields = ALL_CARD_FIELDS.filter((f) => !cardFields.includes(f));
// Close "add row" dropdown on outside click
useEffect(() => {
if (!addOpen) return;
function handleClick(e: MouseEvent) {
if (addRef.current && !addRef.current.contains(e.target as Node)) {
setAddOpen(false);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [addOpen]);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = cardFields.indexOf(active.id as KanbanCardField);
const newIndex = cardFields.indexOf(over.id as KanbanCardField);
onCardFieldsChange(arrayMove(cardFields, oldIndex, newIndex));
}
}
function handleSwap(index: number, newField: KanbanCardField) {
const updated = [...cardFields];
updated[index] = newField;
onCardFieldsChange(updated);
}
function handleRemove(index: number) {
onCardFieldsChange(cardFields.filter((_, i) => i !== index));
}
function handleAdd(field: KanbanCardField) {
onCardFieldsChange([...cardFields, field]);
setAddOpen(false);
}
function handleReset() {
onCardFieldsChange([...DEFAULT_KANBAN_CARD_FIELDS]);
}
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1.5 px-2 text-xs">
<Settings2 className="h-3.5 w-3.5" />
Configure
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-64 p-3">
{/* Group by */}
<div className="mb-4 flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">Group by:</span>
<Select value={groupByField} onValueChange={(v) => onGroupByChange(v as KanbanGroupByField)}>
<SelectTrigger size="sm" className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ALL_GROUP_BY_FIELDS.map((field) => (
<SelectItem key=[redacted] value={field} className="text-xs">
{KANBAN_GROUP_BY_LABELS[field]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Card title */}
<div className="mb-3 flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">Title:</span>
<div className="flex items-center gap-1.5">
<span className={cn('text-xs', cardTitleField === 'company' ? 'text-foreground' : 'text-muted-foreground')}>
Company
</span>
<Switch
checked={cardTitleField === 'conversation'}
onCheckedChange={(checked) => onCardTitleFieldChange(checked ? 'conversation' : 'company')}
/>
<span className={cn('text-xs', cardTitleField === 'conversation' ? 'text-foreground' : 'text-muted-foreground')}>
Deal
</span>
</div>
</div>
{/* Summary */}
<div className="mb-4 flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">Summary:</span>
<div className="flex items-center gap-1.5">
<Select
value={summaryField ?? '__none__'}
onValueChange={(v) => onSummaryFieldChange(v === '__none__' ? undefined : v)}
>
<SelectTrigger size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs">None</SelectItem>
{SUMMABLE_FIELDS.map((f) => (
<SelectItem key=[redacted] value={f.id} className="text-xs">{f.name}</SelectItem>
))}
</SelectContent>
</Select>
{summaryField && (
<div className="flex overflow-hidden rounded border text-xs">
<button
className={cn(
'px-1.5 py-0.5',
summaryAggregation !== 'average'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted',
)}
onClick={() => onSummaryAggregationChange('sum')}
>
Σ
</button>
<button
className={cn(
'px-1.5 py-0.5',
summaryAggregation === 'average'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted',
)}
onClick={() => onSummaryAggregationChange('average')}
>
Ø
</button>
</div>
)}
</div>
</div>
{/* Prototypical card preview */}
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Card rows
</p>
{/* Card shell */}
<div className="rounded-md border bg-card shadow-xs">
{/* Header row — always shown, not draggable */}
<div className="flex items-center gap-1.5 border-b px-2 py-1.5">
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm bg-primary/10 text-[9px] font-bold text-primary">
A
</span>
<span className="text-xs font-semibold text-foreground/70">
{cardTitleField === 'company' ? 'Company name' : 'Deal name'}
</span>
</div>
{/* Draggable field rows */}
<div className="px-1 py-1">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={cardFields} strategy={verticalListSortingStrategy}>
{cardFields.map((field, index) => {
// Available for swap: all fields not currently in the list, except this one's slot
const available = ALL_CARD_FIELDS.filter(
(f) => f === field || !cardFields.includes(f),
).filter((f) => f !== field);
return (
<SortableFieldRow
key=[redacted]
field={field}
availableFields={available}
onSwap={(newField) => handleSwap(index, newField)}
onRemove={() => handleRemove(index)}
/>
);
})}
</SortableContext>
</DndContext>
{/* Add row */}
{unusedFields.length > 0 && (
<div ref={addRef} className="relative mt-0.5">
<button
className="flex w-full items-center gap-1.5 rounded px-1 py-1 text-xs text-muted-foreground hover:bg-muted/60 hover:text-foreground"
onClick={() => setAddOpen((v) => !v)}
>
<Plus size={12} />
Add row
</button>
{addOpen && (
<div className="absolute left-0 top-full z-50 mt-0.5 w-40 rounded-md border bg-popover shadow-md">
{unusedFields.map((f) => {
const meta = getColumnMeta(f);
return (
<button
key={f}
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-xs hover:bg-muted"
onClick={() => handleAdd(f)}
>
<span className="text-muted-foreground">{meta.icon}</span>
{meta.label}
</button>
);
})}
</div>
)}
</div>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
className="mt-2 h-7 w-full text-xs text-muted-foreground"
onClick={handleReset}
>
Reset to defaults
</Button>
</div>
</PopoverContent>
</Popover>
);
}