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>
  );
}