'use client' import * as React from 'react' import { DragDropContext, Droppable, Draggable, type DropResult, } from '@hello-pangea/dnd' import { cn } from '../../lib/utils' export interface KanbanColumnConfig { id: string label: string color?: string } /** A resolved card move derived from a drag, in board terms (not DnD terms). */ export interface KanbanMove { cardId: string fromColumnId: string toColumnId: string toIndex: number } /** * Pure resolver: turn a @hello-pangea/dnd DropResult into a KanbanMove, or null * when the drag is a no-op (dropped outside any column, or back onto the same slot). * Exported so consumers and tests can reason about a move without a DOM. */ export function resolveKanbanMove(result: DropResult): KanbanMove | null { const { draggableId, source, destination } = result if (!destination) return null if ( source.droppableId === destination.droppableId && source.index === destination.index ) { return null } return { cardId: draggableId, fromColumnId: source.droppableId, toColumnId: destination.droppableId, toIndex: destination.index, } } export interface KanbanBoardProps { columns: KanbanColumnConfig[] items: Record renderCard: (item: T) => React.ReactNode renderColumnHeader?: (column: KanbanColumnConfig, items: T[]) => React.ReactNode renderColumnFooter?: (column: KanbanColumnConfig) => React.ReactNode emptyColumnMessage?: string columnWidth?: number columnRef?: (columnId: string) => React.Ref | undefined isColumnOver?: (columnId: string) => boolean className?: string /** * DnD (opt-in). Provide BOTH `getCardId` and `onCardMove` to make cards draggable * between columns via @hello-pangea/dnd — all drag mechanics live here in * @startsimpli/ui; consumers stay dnd-agnostic and just persist the move in * `onCardMove`. Omit either and the board renders exactly as before (no DnD; * `columnRef`/`isColumnOver` honored for a parent's own manual wiring). */ getCardId?: (item: T) => string onCardMove?: (move: KanbanMove) => void } export function KanbanBoard({ columns, items, renderCard, renderColumnHeader, renderColumnFooter, emptyColumnMessage = 'No items', columnWidth = 320, columnRef, isColumnOver, className, getCardId, onCardMove, }: KanbanBoardProps) { const dndEnabled = !!(getCardId && onCardMove) const handleDragEnd = React.useCallback( (result: DropResult) => { const move = resolveKanbanMove(result) if (move) onCardMove?.(move) }, [onCardMove], ) const columnHeader = (column: KanbanColumnConfig, columnItems: T[]) => renderColumnHeader ? ( renderColumnHeader(column, columnItems) ) : (
{column.color && (
{columnItems.length}
) const emptyState = (

{emptyColumnMessage}

) const renderColumn = (column: KanbanColumnConfig) => { const columnItems = items[column.id] ?? [] const footer = renderColumnFooter && (
{renderColumnFooter(column)}
) if (dndEnabled) { return (
{columnHeader(column, columnItems)} {(provided, snapshot) => (
{columnItems.length === 0 && !snapshot.isDraggingOver ? emptyState : columnItems.map((item, index) => { const id = getCardId!(item) return ( {(dp, ds) => (
{renderCard(item)}
)}
) })} {provided.placeholder}
)}
{footer}
) } // Non-DnD (backward compatible): honor columnRef/isColumnOver for a parent's own // manual drag wiring, render cards keyed by index as before. const ref = columnRef?.(column.id) const isOver = isColumnOver?.(column.id) ?? false return (
{columnHeader(column, columnItems)}
{columnItems.length === 0 ? emptyState : columnItems.map((item, index) => ( {renderCard(item)} ))}
{footer}
) } const board = (
{columns.map(renderColumn)}
) if (dndEnabled) { return {board} } return board }