UIPackage

Kanban Board

block dashboard
Edit on GitHub

Full kanban surface: 4 status columns, drag-and-drop card movement with insertion indicator, board/list view toggle, search + priority + assignee filters, collapsible columns, click-to-open task detail Sheet (comments, subtasks, files), full-form Add Task Dialog with priority/assignee/due-date/tags/subtasks. Mega-block: ships 14 Vue files (KanbanBoard + 6 kanban-* views + 7 small badges/lists). Bind with v-model:columns; consumer owns the columns array so swapping the in-memory store for Drizzle/Postgres is a one-line change. Auto-pulls the use-kanban hook (types + configs + helpers) and kanban-data lib (seed columns).

Also available for Vue ->

Installation

$ npx shadcn@latest add https://react.uipkge.dev/r/react/kanban-board.json

Or with the named registry: npx shadcn@latest add @uipkge-react/kanban-board

Examples

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

FilteredColumn
interface FilteredColumn {
  id: string
  title: string
  color: string
  dotColor: string
  tasks: KanbanTask[]
}
FlatTask
interface FlatTask {
  task: KanbanTask
  columnId: string
  columnTitle: string
  dotColor: string
}
AddTaskForm
interface AddTaskForm {
  title: string
  description: string
  priority: KanbanTask['priority']
  assigneeKey: keyof typeof assignees
  tagKeys: (keyof typeof tagPresets)[]
  subtaskTexts: string[]
}

Theming

CSS custom properties referenced in this item. Override any of them in your :root or per-element to retheme.

--border

Files (1)

  • components/blocks/KanbanBoard.tsx 69.7 kB
    'use client'
    
    import * as React from 'react'
    import {
      Plus,
      MoreHorizontal,
      ChevronsLeft,
      ChevronsRight,
      Search,
      Filter,
      ChevronDown,
      ChevronRight,
      X,
      LayoutGrid,
      List,
      MessageSquare,
      Paperclip,
      ExternalLink,
      Clock,
      Download,
      ArrowUpDown,
      CheckCircle2,
      Calendar as CalendarIcon,
      Send,
    } from 'lucide-react'
    import { cn } from '@/lib/utils'
    import {
      type KanbanColumn as KanbanColumnType,
      type KanbanTask,
      type CommentItem,
      priorityConfig,
      assignees,
      tagPresets,
      fileIconMap,
      getInitials,
      getDueStatus,
      formatDueDate,
      findTaskById,
      getTaskColumn,
    } from '@/lib/use-kanban'
    import { PageHeader, PageHeaderHeading } from '@/components/ui/page'
    import { Button } from '@/components/ui/button'
    import { Badge } from '@/components/ui/badge'
    import { Input } from '@/components/ui/input'
    import { Label } from '@/components/ui/label'
    import { Avatar, AvatarFallback } from '@/components/ui/avatar'
    import {
      Select,
      SelectContent,
      SelectItem,
      SelectTrigger,
      SelectValue,
    } from '@/components/ui/select'
    import { Sheet, SheetContent, SheetTitle, SheetDescription, SheetFooter, SheetClose } from '@/components/ui/sheet'
    import {
      Dialog,
      DialogScrollContent,
      DialogHeader,
      DialogTitle,
      DialogDescription,
      DialogFooter,
    } from '@/components/ui/dialog'
    import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
    import { Calendar } from '@/components/ui/calendar'
    import { RichTextEditor } from '@/components/ui/rich-text-editor'
    import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
    import {
      DropdownMenu,
      DropdownMenuContent,
      DropdownMenuItem,
      DropdownMenuSeparator,
      DropdownMenuTrigger,
    } from '@/components/ui/dropdown-menu'
    import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
    
    /* ------------------------------------------------------------------ */
    /* TagBadge                                                            */
    /* ------------------------------------------------------------------ */
    
    function TagBadge({ label, color, className }: { label: string; color: string; className?: string }) {
      return (
        <span className={cn('rounded-md px-1.5 py-0.5 text-[11px] font-medium ring-1 ring-inset', color, className)}>
          {label}
        </span>
      )
    }
    
    /* ------------------------------------------------------------------ */
    /* PriorityBadge                                                       */
    /* ------------------------------------------------------------------ */
    
    function PriorityBadge({
      priority,
      iconSize = 'size-3.5',
      className,
    }: {
      priority: string
      iconSize?: string
      className?: string
    }) {
      const config = priorityConfig[priority]
      if (!config) return null
      const Icon = config.icon
      return (
        <div className={cn('flex items-center gap-1', className)}>
          <Icon className={cn(iconSize, config.class)} />
          <span className={cn('text-[11px] font-semibold', config.class)}>{config.label}</span>
        </div>
      )
    }
    
    /* ------------------------------------------------------------------ */
    /* DueDateBadge                                                        */
    /* ------------------------------------------------------------------ */
    
    function DueDateBadge({
      dueDate,
      variant,
      className,
    }: {
      dueDate: string
      variant?: 'chip' | 'inline'
      className?: string
    }) {
      const status = getDueStatus(dueDate)
      const formatted = formatDueDate(dueDate)
    
      const chipClasses =
        status === 'overdue'
          ? 'bg-destructive/10 text-destructive'
          : status === 'soon'
            ? 'bg-warning/10 text-warning'
            : 'text-muted-foreground bg-muted'
    
      const inlineClasses =
        status === 'overdue' ? 'text-destructive' : status === 'soon' ? 'text-warning' : 'text-foreground'
    
      if (variant === 'chip') {
        return (
          <div
            className={cn('flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-medium', chipClasses, className)}
          >
            <Clock className="size-3" />
            {formatted}
          </div>
        )
      }
    
      return (
        <p className={cn('flex items-center gap-1 text-[13px] leading-tight font-medium', inlineClasses, className)}>
          <Clock className="size-3" />
          {formatted}
        </p>
      )
    }
    
    /* ------------------------------------------------------------------ */
    /* UserAvatar                                                          */
    /* ------------------------------------------------------------------ */
    
    const userAvatarSizeMap = {
      xs: { avatar: 'size-6', text: 'text-[8px]' },
      sm: { avatar: 'size-7', text: 'text-[9px]' },
      md: { avatar: 'size-8', text: 'text-[11px]' },
    }
    
    function UserAvatar({
      name,
      color,
      size = 'sm',
      className,
    }: {
      name: string
      color: string
      size?: 'xs' | 'sm' | 'md'
      className?: string
    }) {
      return (
        <Avatar className={cn(userAvatarSizeMap[size].avatar, 'shrink-0', className)}>
          <AvatarFallback className={cn(userAvatarSizeMap[size].text, 'font-bold', color)}>
            {getInitials(name)}
          </AvatarFallback>
        </Avatar>
      )
    }
    
    /* ------------------------------------------------------------------ */
    /* SubtaskProgress                                                     */
    /* ------------------------------------------------------------------ */
    
    function SubtaskProgress({ done, total, className }: { done: number; total: number; className?: string }) {
      if (total <= 0) return null
      const percent = total > 0 ? Math.round((done / total) * 100) : 0
      const isComplete = done === total && total > 0
      return (
        <div className={className}>
          <div className="mb-1 flex items-center justify-between">
            <span className="text-muted-foreground text-[11px]">
              {isComplete && <CheckCircle2 className="text-success mr-0.5 inline size-3" />}
              {done}/{total} subtasks
            </span>
            <span className="text-muted-foreground text-[11px] font-medium tabular-nums">{percent}%</span>
          </div>
          <div className="bg-muted h-1.5 overflow-hidden rounded-full">
            <div
              className={cn('h-full rounded-full transition-all duration-500', isComplete ? 'bg-success' : 'bg-primary')}
              style={{ width: `${percent}%` }}
            />
          </div>
        </div>
      )
    }
    
    /* ------------------------------------------------------------------ */
    /* SubtaskList                                                         */
    /* ------------------------------------------------------------------ */
    
    function SubtaskList({
      subtaskIds,
      columns,
      compact = false,
    }: {
      subtaskIds: string[]
      columns: KanbanColumnType[]
      compact?: boolean
    }) {
      const subtasks = subtaskIds
        .map((id) => {
          const task = findTaskById(columns, id)
          const column = getTaskColumn(columns, id)
          return task ? { task, column } : null
        })
        .filter(Boolean) as { task: KanbanTask; column: KanbanColumnType | undefined }[]
    
      const doneCount = subtasks.filter((s) => s.column?.id === 'done').length
      const percent = subtasks.length > 0 ? Math.round((doneCount / subtasks.length) * 100) : 0
    
      if (!subtasks.length) {
        return (
          <p className={compact ? 'text-muted-foreground text-[12px]' : 'text-muted-foreground text-sm'}>
            No subtasks yet.
          </p>
        )
      }
    
      return (
        <div>
          <div className="mb-2 flex items-center justify-between">
            <span className={cn('text-muted-foreground tabular-nums', compact ? 'text-[11px]' : 'text-[13px]')}>
              {doneCount}/{subtasks.length} done
            </span>
            <span className={cn('text-muted-foreground tabular-nums', compact ? 'text-[10px]' : 'text-[11px]')}>
              {percent}%
            </span>
          </div>
          <div className="bg-muted mb-3 h-1.5 overflow-hidden rounded-full">
            <div
              className={cn(
                'h-full rounded-full transition-all duration-500',
                doneCount === subtasks.length ? 'bg-success' : 'bg-primary',
              )}
              style={{ width: `${percent}%` }}
            />
          </div>
    
          <div className={compact ? 'space-y-0.5' : 'space-y-1'}>
            {subtasks.map(({ task, column }) => (
              <a
                key={task.id}
                href={`/dashboard/kanban/${task.id}`}
                className={cn(
                  'group/subtask flex items-center gap-2 rounded-md transition-colors',
                  compact ? 'px-1 py-1' : 'px-1.5 py-1.5',
                  'hover:bg-muted/50',
                )}
              >
                <span className={cn('size-1.5 shrink-0 rounded-full', column?.dotColor ?? 'bg-muted-foreground')} />
                <span
                  className={cn('shrink-0 font-mono', compact ? 'text-[10px]' : 'text-[11px]', 'text-muted-foreground/70')}
                >
                  {task.id}
                </span>
                <span
                  className={cn(
                    'min-w-0 flex-1 truncate',
                    compact ? 'text-[12px]' : 'text-[13px]',
                    column?.id === 'done' ? 'text-muted-foreground line-through' : 'text-foreground',
                  )}
                >
                  {task.title}
                </span>
                <span
                  className={cn(
                    'shrink-0 rounded-md px-1.5 py-0.5 font-medium',
                    compact ? 'text-[9px]' : 'text-[10px]',
                    column?.color ?? 'text-muted-foreground',
                  )}
                >
                  {column?.title ?? 'Unknown'}
                </span>
              </a>
            ))}
          </div>
        </div>
      )
    }
    
    /* ------------------------------------------------------------------ */
    /* CommentList                                                         */
    /* ------------------------------------------------------------------ */
    
    function CommentList({
      comments,
      compact = false,
      onAdd,
    }: {
      comments: CommentItem[]
      compact?: boolean
      onAdd?: (text: string) => void
    }) {
      const [newComment, setNewComment] = React.useState('')
    
      function submit() {
        const stripped = newComment.replace(/<[^>]*>/g, '').trim()
        if (!stripped) return
        onAdd?.(newComment)
        setNewComment('')
      }
    
      return (
        <>
          {comments.length ? (
            <div className={compact ? 'space-y-3' : 'space-y-4'}>
              {comments.map((comment) => (
                <div key={comment.id} className={compact ? 'flex gap-2.5' : 'flex gap-3'}>
                  <UserAvatar name={comment.author} color={comment.authorColor} size={compact ? 'xs' : 'sm'} />
                  <div className="min-w-0 flex-1">
                    <div className="flex items-baseline gap-2">
                      <span className={compact ? 'text-[12px] font-semibold' : 'text-[13px] font-semibold'}>
                        {compact ? comment.author.split(' ')[0] : comment.author}
                      </span>
                      <span
                        className={
                          compact ? 'text-muted-foreground text-[10px]' : 'text-muted-foreground text-[11px]'
                        }
                      >
                        {comment.time}
                      </span>
                    </div>
                    <div
                      className={cn(
                        'rich-text-content prose prose-sm dark:prose-invert mt-0.5 max-w-none',
                        compact
                          ? 'text-muted-foreground text-[12px] leading-relaxed'
                          : 'text-muted-foreground text-[13px] leading-relaxed',
                      )}
                      dangerouslySetInnerHTML={{ __html: comment.text }}
                    />
                  </div>
                </div>
              ))}
            </div>
          ) : (
            <p className={compact ? 'text-muted-foreground text-[12px]' : 'text-muted-foreground text-sm'}>
              No comments yet.
            </p>
          )}
    
          <div className={compact ? 'mt-3 flex gap-2' : 'mt-4 flex gap-3 border-t pt-4'}>
            <div className={compact ? 'mt-1' : 'mt-1.5'}>
              <UserAvatar name="Admin User" color="bg-chart-1/15 text-chart-1" size={compact ? 'xs' : 'sm'} />
            </div>
            <div className="min-w-0 flex-1 space-y-2">
              <RichTextEditor
                value={newComment}
                onValueChange={setNewComment}
                placeholder="Write a comment..."
                minHeight={compact ? '60px' : '80px'}
                className={compact ? 'text-[12px]' : 'text-[13px]'}
              />
              <div className="flex justify-end">
                <Button
                  size="sm"
                  className={compact ? 'h-7 gap-1 text-[11px]' : 'h-8 gap-1.5 text-xs'}
                  disabled={!newComment.replace(/<[^>]*>/g, '').trim()}
                  onClick={submit}
                >
                  <Send className="size-3" />
                  Comment
                </Button>
              </div>
            </div>
          </div>
        </>
      )
    }
    
    /* ------------------------------------------------------------------ */
    /* KanbanCard                                                          */
    /* ------------------------------------------------------------------ */
    
    function KanbanCard({
      task,
      isDone,
      columns,
      onClick,
      onQuickView,
    }: {
      task: KanbanTask
      isDone: boolean
      columns: KanbanColumnType[]
      onClick?: (task: KanbanTask) => void
      onQuickView?: (task: KanbanTask) => void
    }) {
      const subtasksDone = React.useMemo(() => {
        if (!columns || !task.subtaskIds.length) return 0
        return task.subtaskIds.filter((id) => getTaskColumn(columns, id)?.id === 'done').length
      }, [columns, task.subtaskIds])
    
      return (
        <div
          className={cn(
            'kanban-card group/card bg-card relative cursor-grab rounded-lg border p-3 transition-all duration-150',
            'hover:border-border hover:shadow-md active:scale-[0.97] active:cursor-grabbing',
            isDone ? 'opacity-75 hover:opacity-100' : '',
          )}
          onClick={() => onClick?.(task)}
        >
          <div
            className={cn(
              'kanban-accent absolute top-3 bottom-3 left-0 w-[1.5px] rounded-full transition-all duration-150',
              priorityConfig[task.priority].bg,
              task.priority === 'low' ? 'opacity-40' : task.priority === 'medium' ? 'opacity-60' : 'opacity-90',
            )}
          />
    
          <div className="mb-1 flex items-center justify-between pl-2">
            <span className="text-muted-foreground/70 font-mono text-[11px]">{task.id}</span>
            <DropdownMenu>
              <DropdownMenuTrigger asChild>
                <Button
                  variant="ghost"
                  size="icon"
                  className="text-muted-foreground -mr-1 size-6 opacity-0 transition-opacity group-hover/card:opacity-100"
                  onClick={(e) => e.stopPropagation()}
                >
                  <MoreHorizontal className="size-3.5" />
                </Button>
              </DropdownMenuTrigger>
              <DropdownMenuContent align="end" className="w-36">
                <DropdownMenuItem
                  onClick={(e) => {
                    e.stopPropagation()
                    onQuickView?.(task)
                  }}
                >
                  Quick view
                </DropdownMenuItem>
                <DropdownMenuItem asChild>
                  <a href={`/dashboard/kanban/${task.id}`} className="gap-2">
                    <ExternalLink className="size-3.5" />
                    Open detail
                  </a>
                </DropdownMenuItem>
                <DropdownMenuItem>Edit</DropdownMenuItem>
                <DropdownMenuItem>Move to...</DropdownMenuItem>
                <DropdownMenuItem>Assign to...</DropdownMenuItem>
                <DropdownMenuSeparator />
                <DropdownMenuItem className="text-destructive">Delete</DropdownMenuItem>
              </DropdownMenuContent>
            </DropdownMenu>
          </div>
    
          <p
            className={cn(
              'mb-2 pl-2 text-[13px] leading-snug font-medium',
              isDone ? 'decoration-muted-foreground/40 line-through' : '',
            )}
          >
            {task.title}
          </p>
    
          {task.tags.length > 0 && (
            <div className="mb-2 flex flex-wrap gap-1 pl-2">
              {task.tags.map((tag) => (
                <TagBadge key={tag.label} label={tag.label} color={tag.color} />
              ))}
            </div>
          )}
    
          {task.subtaskIds.length > 0 && (
            <div className="mb-2 pl-2">
              <SubtaskProgress done={subtasksDone} total={task.subtaskIds.length} />
            </div>
          )}
    
          <div className="flex items-center gap-2 pl-2">
            {task.dueDate && <DueDateBadge dueDate={task.dueDate} variant="chip" />}
    
            {task.commentItems.length > 0 && (
              <div className="text-muted-foreground/70 flex items-center gap-1 text-[11px]">
                <MessageSquare className="size-3" />
                {task.commentItems.length}
              </div>
            )}
    
            {task.fileItems.length > 0 && (
              <div className="text-muted-foreground/70 flex items-center gap-1 text-[11px]">
                <Paperclip className="size-3" />
                {task.fileItems.length}
              </div>
            )}
    
            <div className="ml-auto">
              <TooltipProvider delayDuration={200}>
                <Tooltip>
                  <TooltipTrigger asChild>
                    <span>
                      <UserAvatar name={task.assignee.name} color={task.assignee.color} size="xs" />
                    </span>
                  </TooltipTrigger>
                  <TooltipContent side="bottom" className="text-xs">
                    {task.assignee.name}
                  </TooltipContent>
                </Tooltip>
              </TooltipProvider>
            </div>
          </div>
        </div>
      )
    }
    
    /* ------------------------------------------------------------------ */
    /* KanbanColumn                                                        */
    /* ------------------------------------------------------------------ */
    
    interface FilteredColumn {
      id: string
      title: string
      color: string
      dotColor: string
      tasks: KanbanTask[]
    }
    
    function KanbanColumn({
      column,
      collapsed,
      draggedTask,
      dragOverColumn,
      dropTargetIndex,
      allColumns,
      onToggleCollapse,
      onAddTask,
      onCardClick,
      onCardQuickView,
      onDragStart,
      onDragEnd,
      onCardDragOver,
      onLaneDragOver,
      onDrop,
    }: {
      column: FilteredColumn
      collapsed: boolean
      draggedTask: string | null
      dragOverColumn: string | null
      dropTargetIndex: number
      allColumns: KanbanColumnType[]
      onToggleCollapse: (columnId: string) => void
      onAddTask: (columnId: string) => void
      onCardClick: (task: KanbanTask) => void
      onCardQuickView: (task: KanbanTask) => void
      onDragStart: (event: React.DragEvent, taskId: string) => void
      onDragEnd: () => void
      onCardDragOver: (event: React.DragEvent, columnId: string, taskIndex: number) => void
      onLaneDragOver: (event: React.DragEvent, columnId: string, taskCount: number) => void
      onDrop: () => void
    }) {
      return (
        <div
          className={cn('group/col flex shrink-0 flex-col transition-all duration-200', collapsed ? 'w-12' : 'w-[300px]')}
          onDragOver={(e) => e.preventDefault()}
          onDrop={() => onDrop()}
        >
          {collapsed ? (
            <button
              className="bg-muted/40 hover:bg-muted/60 flex h-full flex-col items-center gap-2 rounded-xl px-1 pt-3 pb-4 transition-colors"
              onClick={() => onToggleCollapse(column.id)}
            >
              <span className={cn('size-2 shrink-0 rounded-full', column.dotColor)} />
              <span
                className={cn(
                  'text-[11px] font-semibold tracking-tight',
                  column.color,
                  'rotate-180 [writing-mode:vertical-lr]',
                )}
              >
                {column.title}
              </span>
              <Badge variant="secondary" className="mt-1 h-5 min-w-5 justify-center rounded-md px-1 text-[10px]">
                {column.tasks.length}
              </Badge>
              <ChevronsRight className="text-muted-foreground mt-auto size-3.5" />
            </button>
          ) : (
            <>
              <div className="bg-background/95 sticky top-0 z-10 mb-2 flex items-center gap-2 px-2 py-1.5 backdrop-blur-sm">
                <button
                  className="text-muted-foreground/50 hover:text-muted-foreground shrink-0 transition-colors"
                  title="Collapse column"
                  onClick={() => onToggleCollapse(column.id)}
                >
                  <ChevronsLeft className="size-3.5" />
                </button>
                <span className={cn('size-2 shrink-0 rounded-full', column.dotColor)} />
                <h3 className={cn('text-[13px] font-semibold tracking-tight', column.color)}>{column.title}</h3>
                <span className="text-muted-foreground bg-muted rounded-md px-1.5 py-0.5 text-[11px] font-medium tabular-nums">
                  {column.tasks.length}
                </span>
                <div className="ml-auto flex items-center">
                  <Button
                    variant="ghost"
                    size="icon"
                    className="text-muted-foreground size-6 opacity-0 transition-opacity group-hover/col:opacity-100"
                  >
                    <MoreHorizontal className="size-3.5" />
                  </Button>
                  <Button
                    variant="ghost"
                    size="icon"
                    className="text-muted-foreground size-6"
                    onClick={() => onAddTask(column.id)}
                  >
                    <Plus className="size-3.5" />
                  </Button>
                </div>
              </div>
    
              <div
                className={cn(
                  'kanban-lane flex flex-col rounded-xl p-2 transition-all duration-200',
                  dragOverColumn === column.id && draggedTask
                    ? 'bg-primary/[0.06] ring-primary/25 ring-1 ring-inset'
                    : 'bg-muted/40',
                )}
                onDragOver={(e) => {
                  e.preventDefault()
                  onLaneDragOver(e, column.id, column.tasks.length)
                }}
              >
                {column.tasks.map((task, taskIndex) => (
                  <React.Fragment key={task.id}>
                    <div
                      className={cn(
                        'drop-indicator mx-1 transition-all duration-150',
                        dragOverColumn === column.id &&
                          dropTargetIndex === taskIndex &&
                          draggedTask &&
                          draggedTask !== task.id
                          ? 'bg-primary h-0.5 rounded-full'
                          : 'h-0',
                      )}
                    />
                    <div
                      data-task-id={task.id}
                      draggable="true"
                      className={cn(
                        'mt-2 first:mt-0',
                        draggedTask === task.id ? 'scale-95 rotate-1 opacity-30' : 'opacity-100',
                      )}
                      onDragStart={(e) => onDragStart(e, task.id)}
                      onDragEnd={() => onDragEnd()}
                      onDragOver={(e) => {
                        e.stopPropagation()
                        e.preventDefault()
                        onCardDragOver(e, column.id, taskIndex)
                      }}
                    >
                      <KanbanCard
                        task={task}
                        isDone={column.id === 'done'}
                        columns={allColumns}
                        onClick={onCardClick}
                        onQuickView={onCardQuickView}
                      />
                    </div>
                  </React.Fragment>
                ))}
    
                {column.tasks.length > 0 && (
                  <div
                    className={cn(
                      'drop-indicator mx-1 transition-all duration-150',
                      dragOverColumn === column.id && dropTargetIndex === column.tasks.length && draggedTask
                        ? 'bg-primary mt-2 h-0.5 rounded-full'
                        : 'h-0',
                    )}
                  />
                )}
    
                {column.tasks.length === 0 && (
                  <button
                    className="text-muted-foreground/50 hover:text-muted-foreground hover:border-muted-foreground/30 flex flex-1 flex-col items-center justify-center rounded-lg border border-dashed py-10 transition-colors"
                    onClick={() => onAddTask(column.id)}
                  >
                    <Plus className="mb-1 size-4" />
                    <p className="text-xs">No tasks</p>
                  </button>
                )}
              </div>
    
              <button
                className="text-muted-foreground hover:text-foreground hover:bg-muted/60 mt-2 flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed py-2 text-xs transition-colors"
                onClick={() => onAddTask(column.id)}
              >
                <Plus className="size-3.5" />
                Add task
              </button>
            </>
          )}
        </div>
      )
    }
    
    /* ------------------------------------------------------------------ */
    /* KanbanToolbar                                                       */
    /* ------------------------------------------------------------------ */
    
    function KanbanToolbar({
      searchQuery,
      selectedPriority,
      selectedAssignee,
      viewMode,
      onSearchQueryChange,
      onSelectedPriorityChange,
      onSelectedAssigneeChange,
      onViewModeChange,
    }: {
      searchQuery: string
      selectedPriority: string | null
      selectedAssignee: string | null
      viewMode: 'board' | 'list'
      onSearchQueryChange: (value: string) => void
      onSelectedPriorityChange: (value: string | null) => void
      onSelectedAssigneeChange: (value: string | null) => void
      onViewModeChange: (value: 'board' | 'list') => void
    }) {
      return (
        <div className="mb-3 flex shrink-0 items-center gap-2">
          <div className="relative w-56">
            <Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2" />
            <Input
              value={searchQuery}
              placeholder="Search tasks..."
              className="h-8 pl-8 text-sm"
              onChange={(e) => onSearchQueryChange(e.target.value)}
            />
          </div>
    
          <DropdownMenu>
            <DropdownMenuTrigger asChild>
              <Button variant="outline" size="sm" className="h-8 gap-1.5 text-xs">
                <Filter className="size-3" />
                Priority
                {selectedPriority && (
                  <Badge variant="default" className="ml-0.5 h-4 min-w-4 justify-center rounded px-1 text-[10px]">
                    1
                  </Badge>
                )}
                <ChevronDown className="text-muted-foreground size-3" />
              </Button>
            </DropdownMenuTrigger>
            <DropdownMenuContent align="start" className="w-40">
              {Object.entries(priorityConfig).map(([key, config]) => {
                const Icon = config.icon
                return (
                  <DropdownMenuItem
                    key={key}
                    className="gap-2"
                    onClick={() => onSelectedPriorityChange(selectedPriority === key ? null : key)}
                  >
                    <Icon className={cn('size-3.5', config.class)} />
                    {config.label}
                    {selectedPriority === key && <span className="bg-primary ml-auto size-1.5 rounded-full" />}
                  </DropdownMenuItem>
                )
              })}
              {selectedPriority && <DropdownMenuSeparator />}
              {selectedPriority && (
                <DropdownMenuItem onClick={() => onSelectedPriorityChange(null)}>Clear filter</DropdownMenuItem>
              )}
            </DropdownMenuContent>
          </DropdownMenu>
    
          {selectedAssignee && (
            <Badge
              variant="secondary"
              className="h-7 cursor-pointer gap-1 pr-1.5 text-xs"
              onClick={() => onSelectedAssigneeChange(null)}
            >
              {selectedAssignee}
              <X className="size-3 opacity-50" />
            </Badge>
          )}
    
          <ToggleGroup
            type="single"
            size="sm"
            value={viewMode}
            className="bg-muted ml-auto flex items-center gap-0.5 rounded-md p-0.5"
            onValueChange={(v) => v && onViewModeChange(v as 'board' | 'list')}
          >
            <ToggleGroupItem
              value="board"
              className="data-[state=on]:bg-background size-7 rounded-sm p-0 data-[state=on]:shadow-sm"
              title="Board view"
            >
              <LayoutGrid className="size-3.5" />
            </ToggleGroupItem>
            <ToggleGroupItem
              value="list"
              className="data-[state=on]:bg-background size-7 rounded-sm p-0 data-[state=on]:shadow-sm"
              title="List view"
            >
              <List className="size-3.5" />
            </ToggleGroupItem>
          </ToggleGroup>
    
          <div className="flex items-center">
            <TooltipProvider delayDuration={300}>
              {Object.entries(assignees).map(([key, a]) => (
                <Tooltip key={key}>
                  <TooltipTrigger asChild>
                    <button
                      className={cn(
                        // rounded-full so the selected ring + focus ring trace the
                        // Avatar's circular outline instead of the rectangular button.
                        'ring-background focus-visible:ring-ring/50 relative -ml-1.5 rounded-full transition-all outline-none first:ml-0 focus-visible:ring-1',
                        selectedAssignee === a.name
                          ? 'ring-primary z-20 ring-1'
                          : selectedAssignee && selectedAssignee !== a.name
                            ? 'opacity-40 hover:opacity-70'
                            : 'hover:z-10 hover:scale-110',
                      )}
                      onClick={() => onSelectedAssigneeChange(selectedAssignee === a.name ? null : a.name)}
                    >
                      <Avatar className="border-background size-7 border-2">
                        <AvatarFallback className={cn('text-[10px] font-semibold', a.color)}>
                          {getInitials(a.name)}
                        </AvatarFallback>
                      </Avatar>
                    </button>
                  </TooltipTrigger>
                  <TooltipContent side="bottom" className="text-xs">
                    {a.name}
                    {selectedAssignee === a.name && <span className="text-muted-foreground ml-1">(filtered)</span>}
                  </TooltipContent>
                </Tooltip>
              ))}
            </TooltipProvider>
          </div>
        </div>
      )
    }
    
    /* ------------------------------------------------------------------ */
    /* KanbanListView                                                      */
    /* ------------------------------------------------------------------ */
    
    type SortField = 'id' | 'title' | 'priority' | 'assignee' | 'dueDate' | 'status'
    type SortDir = 'asc' | 'desc'
    
    interface FlatTask {
      task: KanbanTask
      columnId: string
      columnTitle: string
      dotColor: string
    }
    
    const priorityOrder: Record<string, number> = { urgent: 0, high: 1, medium: 2, low: 3 }
    
    function KanbanListView({
      columns,
      allColumns,
      onTaskClick,
      onMoveTask,
    }: {
      columns: FilteredColumn[]
      allColumns: KanbanColumnType[]
      onTaskClick: (task: KanbanTask) => void
      onMoveTask: (task: KanbanTask, targetColumnId: string) => void
    }) {
      const [sortField, setSortField] = React.useState<SortField>('status')
      const [sortDir, setSortDir] = React.useState<SortDir>('asc')
      const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(new Set())
    
      function toggleSort(field: SortField) {
        if (sortField === field) {
          setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
        } else {
          setSortField(field)
          setSortDir('asc')
        }
      }
    
      function toggleGroup(columnId: string) {
        setCollapsedGroups((prev) => {
          const next = new Set(prev)
          if (next.has(columnId)) next.delete(columnId)
          else next.add(columnId)
          return next
        })
      }
    
      const flatTasks = React.useMemo<FlatTask[]>(() => {
        const items: FlatTask[] = []
        for (const col of columns) {
          for (const task of col.tasks) {
            items.push({ task, columnId: col.id, columnTitle: col.title, dotColor: col.dotColor })
          }
        }
        return [...items].sort((a, b) => {
          let cmp = 0
          switch (sortField) {
            case 'id': {
              const numA = parseInt(a.task.id.replace(/^[A-Z]+-/, ''), 10)
              const numB = parseInt(b.task.id.replace(/^[A-Z]+-/, ''), 10)
              cmp = numA - numB
              break
            }
            case 'title':
              cmp = a.task.title.localeCompare(b.task.title)
              break
            case 'priority':
              cmp = (priorityOrder[a.task.priority] ?? 99) - (priorityOrder[b.task.priority] ?? 99)
              break
            case 'assignee':
              cmp = a.task.assignee.name.localeCompare(b.task.assignee.name)
              break
            case 'dueDate':
              cmp = (a.task.dueDate ?? '9999').localeCompare(b.task.dueDate ?? '9999')
              break
            case 'status': {
              const colOrder = allColumns.map((c) => c.id)
              cmp = colOrder.indexOf(a.columnId) - colOrder.indexOf(b.columnId)
              break
            }
          }
          return sortDir === 'desc' ? -cmp : cmp
        })
      }, [columns, allColumns, sortField, sortDir])
    
      const groupedTasks = React.useMemo(() => {
        const groups: { column: KanbanColumnType; tasks: FlatTask[] }[] = []
        for (const col of allColumns) {
          const tasks = flatTasks.filter((t) => t.columnId === col.id)
          groups.push({ column: col, tasks })
        }
        return groups
      }, [allColumns, flatTasks])
    
      function subtasksDone(task: KanbanTask): number {
        if (!task.subtaskIds.length) return 0
        return task.subtaskIds.filter((id) => getTaskColumn(allColumns, id)?.id === 'done').length
      }
    
      const headerCols: { field: SortField; label: string }[] = [
        { field: 'id', label: 'ID' },
        { field: 'title', label: 'Task' },
        { field: 'status', label: 'Status' },
        { field: 'priority', label: 'Priority' },
        { field: 'assignee', label: 'Assignee' },
        { field: 'dueDate', label: 'Due' },
      ]
    
      return (
        <div className="kanban-list flex min-h-0 flex-1 flex-col overflow-auto pb-3">
          <div className="bg-muted/50 sticky top-0 z-10 grid grid-cols-[60px_1fr_100px_110px_130px_100px_80px] items-center gap-2 rounded-t-lg border px-3 py-2 text-[11px] font-semibold tracking-wider uppercase">
            {headerCols.map((h) => (
              <button key={h.field} className="flex items-center gap-1 text-left" onClick={() => toggleSort(h.field)}>
                {h.label}
                <ArrowUpDown
                  className={cn('size-3', sortField === h.field ? 'text-foreground' : 'text-muted-foreground/50')}
                />
              </button>
            ))}
            <span className="text-center">Info</span>
          </div>
    
          {groupedTasks.map((group) => (
            <React.Fragment key={group.column.id}>
              <button
                className="bg-muted/30 hover:bg-muted/50 flex items-center gap-2 border-x border-b px-3 py-1.5 text-left transition-colors"
                onClick={() => toggleGroup(group.column.id)}
              >
                {collapsedGroups.has(group.column.id) ? (
                  <ChevronRight className="text-muted-foreground size-3.5" />
                ) : (
                  <ChevronDown className="text-muted-foreground size-3.5" />
                )}
                <span className={cn('size-2 rounded-full', group.column.dotColor)} />
                <span className="text-sm font-medium">{group.column.title}</span>
                <Badge variant="secondary" className="ml-1 h-4 px-1.5 text-[10px] tabular-nums">
                  {group.tasks.length}
                </Badge>
              </button>
    
              {!collapsedGroups.has(group.column.id) &&
                group.tasks.map((item) => (
                  <div
                    key={item.task.id}
                    className="hover:bg-muted/30 grid cursor-pointer grid-cols-[60px_1fr_100px_110px_130px_100px_80px] items-center gap-2 border-x border-b px-3 py-2 transition-colors"
                    onClick={() => onTaskClick(item.task)}
                  >
                    <span className="text-muted-foreground font-mono text-[11px]">{item.task.id}</span>
    
                    <div className="min-w-0">
                      <div className="flex items-center gap-2">
                        <span
                          className={cn(
                            'truncate text-[13px] font-medium',
                            item.columnId === 'done' ? 'text-muted-foreground line-through' : '',
                          )}
                        >
                          {item.task.title}
                        </span>
                        <a
                          href={`/dashboard/kanban/${item.task.id}`}
                          className="text-muted-foreground hover:text-foreground shrink-0 opacity-0 transition-opacity group-hover/row:opacity-100"
                          onClick={(e) => e.stopPropagation()}
                        >
                          <ExternalLink className="size-3" />
                        </a>
                      </div>
                      {(item.task.tags.length > 0 || item.task.subtaskIds.length > 0) && (
                        <div className="mt-0.5 flex items-center gap-1.5">
                          {item.task.tags.map((tag) => (
                            <TagBadge
                              key={tag.label}
                              label={tag.label}
                              color={tag.color}
                              className="!px-1.5 !py-0 !text-[9px]"
                            />
                          ))}
                          {item.task.subtaskIds.length > 0 && (
                            <SubtaskProgress
                              done={subtasksDone(item.task)}
                              total={item.task.subtaskIds.length}
                              className="ml-1"
                            />
                          )}
                        </div>
                      )}
                    </div>
    
                    <div>
                      <Select
                        value={item.columnId}
                        onValueChange={(val) => onMoveTask(item.task, String(val))}
                      >
                        <SelectTrigger
                          className="hover:bg-muted h-6 w-auto gap-1 rounded-md border-none bg-transparent px-1.5 text-[11px] font-medium shadow-none"
                          onClick={(e) => e.stopPropagation()}
                        >
                          <span className="flex items-center gap-1.5">
                            <span className={cn('size-1.5 rounded-full', item.dotColor)} />
                            <SelectValue />
                          </span>
                        </SelectTrigger>
                        <SelectContent>
                          {allColumns.map((col) => (
                            <SelectItem key={col.id} value={col.id}>
                              <span className="flex items-center gap-1.5">
                                <span className={cn('size-1.5 rounded-full', col.dotColor)} />
                                {col.title}
                              </span>
                            </SelectItem>
                          ))}
                        </SelectContent>
                      </Select>
                    </div>
    
                    <PriorityBadge priority={item.task.priority} />
    
                    <div className="flex items-center gap-2">
                      <UserAvatar name={item.task.assignee.name} color={item.task.assignee.color} size="xs" />
                      <span className="truncate text-[12px]">{item.task.assignee.name}</span>
                    </div>
    
                    <div>
                      {item.task.dueDate ? (
                        <DueDateBadge dueDate={item.task.dueDate} variant="chip" />
                      ) : (
                        <span className="text-muted-foreground/50 text-[11px]"></span>
                      )}
                    </div>
    
                    <div className="flex items-center justify-center gap-2">
                      <TooltipProvider delayDuration={200}>
                        {item.task.commentItems.length > 0 && (
                          <Tooltip>
                            <TooltipTrigger asChild>
                              <span className="text-muted-foreground/70 flex items-center gap-0.5 text-[11px]">
                                <MessageSquare className="size-3" />
                                {item.task.commentItems.length}
                              </span>
                            </TooltipTrigger>
                            <TooltipContent className="text-xs">{item.task.commentItems.length} comments</TooltipContent>
                          </Tooltip>
                        )}
                        {item.task.fileItems.length > 0 && (
                          <Tooltip>
                            <TooltipTrigger asChild>
                              <span className="text-muted-foreground/70 flex items-center gap-0.5 text-[11px]">
                                <Paperclip className="size-3" />
                                {item.task.fileItems.length}
                              </span>
                            </TooltipTrigger>
                            <TooltipContent className="text-xs">{item.task.fileItems.length} files</TooltipContent>
                          </Tooltip>
                        )}
                      </TooltipProvider>
                    </div>
                  </div>
                ))}
            </React.Fragment>
          ))}
    
          {flatTasks.length === 0 && (
            <div className="text-muted-foreground flex flex-1 items-center justify-center rounded-b-lg border-x border-b py-12 text-sm">
              No tasks match your filters.
            </div>
          )}
        </div>
      )
    }
    
    /* ------------------------------------------------------------------ */
    /* KanbanTaskSheet                                                     */
    /* ------------------------------------------------------------------ */
    
    function KanbanTaskSheet({
      open,
      task,
      columns,
      onOpenChange,
      onMoveTask,
      onAddComment,
    }: {
      open: boolean
      task: KanbanTask | null
      columns: KanbanColumnType[]
      onOpenChange: (value: boolean) => void
      onMoveTask: (task: KanbanTask, columnId: string) => void
      onAddComment: (task: KanbanTask, text: string) => void
    }) {
      const columnIdForTask = task
        ? (columns.find((c) => c.tasks.some((t) => t.id === task.id))?.id ?? '')
        : ''
    
      return (
        <Sheet open={open} onOpenChange={onOpenChange}>
          <SheetContent className="flex flex-col gap-0 overflow-hidden p-0 sm:max-w-[420px]">
            {task && (
              <>
                <div className={cn('h-1 w-full shrink-0', priorityConfig[task.priority]?.bg)} />
    
                <div className="shrink-0 px-5 pt-4 pb-3">
                  <div className="mb-3 flex items-center gap-2">
                    <span className="text-muted-foreground font-mono text-[11px] tracking-tight">{task.id}</span>
                    <span className="text-muted-foreground/30">·</span>
                    <Select value={columnIdForTask} onValueChange={(val) => onMoveTask(task, String(val))}>
                      <SelectTrigger className="hover:bg-secondary h-5 w-auto gap-1 rounded-md border-none bg-transparent px-1.5 text-[11px] font-medium shadow-none">
                        <SelectValue />
                      </SelectTrigger>
                      <SelectContent>
                        {columns.map((col) => (
                          <SelectItem key={col.id} value={col.id}>
                            <span className="flex items-center gap-1.5">
                              <span className={cn('size-1.5 rounded-full', col.dotColor)} />
                              {col.title}
                            </span>
                          </SelectItem>
                        ))}
                      </SelectContent>
                    </Select>
                    <PriorityBadge priority={task.priority} iconSize="size-3" className="ml-auto" />
                  </div>
    
                  <SheetTitle className="text-[15px] leading-snug font-semibold tracking-tight">{task.title}</SheetTitle>
                  <SheetDescription className="sr-only">Task details</SheetDescription>
                  {task.description ? (
                    <div
                      className="text-muted-foreground rich-text-content prose prose-sm dark:prose-invert mt-1.5 max-w-none text-[13px] leading-relaxed"
                      dangerouslySetInnerHTML={{ __html: task.description }}
                    />
                  ) : (
                    <p className="text-muted-foreground mt-1.5 text-[13px] leading-relaxed">No description provided.</p>
                  )}
    
                  <div className="mt-3 flex flex-wrap gap-1.5">
                    {task.tags.length ? (
                      task.tags.map((tag) => <TagBadge key={tag.label} label={tag.label} color={tag.color} />)
                    ) : (
                      <span className="text-muted-foreground text-[11px]">No tags</span>
                    )}
                  </div>
                </div>
    
                <div className="bg-border mx-5 h-px" />
    
                <div className="flex-1 overflow-y-auto">
                  <div className="space-y-4 px-5 py-3">
                    <div className="flex items-center gap-3">
                      <UserAvatar name={task.assignee.name} color={task.assignee.color} size="md" />
                      <div>
                        <p className="text-[13px] leading-tight font-medium">{task.assignee.name}</p>
                        <p className="text-muted-foreground text-[11px]">Assignee</p>
                      </div>
                      <div className="ml-auto text-right">
                        {task.dueDate ? (
                          <DueDateBadge dueDate={task.dueDate} />
                        ) : (
                          <p className="text-muted-foreground flex items-center gap-1 text-[13px] leading-tight">
                            <Clock className="size-3" />
                            No due date
                          </p>
                        )}
                      </div>
                    </div>
    
                    {task.parentId && (
                      <div className="flex items-center gap-2">
                        <span className="text-muted-foreground text-[11px]">Parent:</span>
                        <a
                          href={`/dashboard/kanban/${task.parentId}`}
                          className="text-primary text-[12px] font-medium hover:underline"
                          onClick={() => onOpenChange(false)}
                        >
                          {task.parentId}
                        </a>
                      </div>
                    )}
    
                    <div>
                      <h4 className="mb-2 text-[13px] font-semibold">
                        Subtasks
                        {task.subtaskIds.length > 0 && (
                          <span className="text-muted-foreground font-normal"> ({task.subtaskIds.length})</span>
                        )}
                      </h4>
                      <SubtaskList subtaskIds={task.subtaskIds} columns={columns} compact />
                    </div>
    
                    <div className="bg-border h-px" />
    
                    <div>
                      <h4 className="mb-2 text-[13px] font-semibold">
                        Comments
                        {task.commentItems.length > 0 && (
                          <span className="text-muted-foreground font-normal"> ({task.commentItems.length})</span>
                        )}
                      </h4>
                      <CommentList comments={task.commentItems} compact onAdd={(text) => onAddComment(task, text)} />
                    </div>
    
                    <div className="bg-border h-px" />
    
                    <div>
                      <h4 className="mb-2 text-[13px] font-semibold">
                        Files
                        {task.fileItems.length > 0 && (
                          <span className="text-muted-foreground font-normal"> ({task.fileItems.length})</span>
                        )}
                      </h4>
                      {task.fileItems.length ? (
                        <div className="space-y-1">
                          {task.fileItems.map((file) => {
                            const FileIcon = fileIconMap[file.type]
                            return (
                              <div
                                key={file.id}
                                className="hover:bg-muted/50 group/file flex items-center gap-2.5 rounded-md px-1.5 py-1.5 transition-colors"
                              >
                                <div className="bg-muted flex size-8 shrink-0 items-center justify-center rounded-md">
                                  <FileIcon className="text-muted-foreground size-4" />
                                </div>
                                <div className="min-w-0 flex-1">
                                  <p className="truncate text-[12px] font-medium">{file.name}</p>
                                  <p className="text-muted-foreground text-[10px]">{file.size}</p>
                                </div>
                                <Button
                                  variant="ghost"
                                  size="icon"
                                  className="size-7 shrink-0 opacity-0 transition-opacity group-hover/file:opacity-100"
                                >
                                  <Download className="size-3.5" />
                                </Button>
                              </div>
                            )
                          })}
                        </div>
                      ) : (
                        <p className="text-muted-foreground text-[12px]">No files attached.</p>
                      )}
                    </div>
                  </div>
                </div>
    
                <SheetFooter className="shrink-0 border-t px-5 py-3">
                  <div className="flex w-full items-center gap-2">
                    <a href={`/dashboard/kanban/${task.id}`} className="flex-1" onClick={() => onOpenChange(false)}>
                      <Button variant="outline" size="sm" className="w-full gap-1.5">
                        <ExternalLink className="size-3.5" />
                        View full detail
                      </Button>
                    </a>
                    <SheetClose asChild>
                      <Button variant="ghost" size="sm">
                        Close
                      </Button>
                    </SheetClose>
                  </div>
                </SheetFooter>
              </>
            )}
          </SheetContent>
        </Sheet>
      )
    }
    
    /* ------------------------------------------------------------------ */
    /* KanbanAddTaskDialog                                                 */
    /* ------------------------------------------------------------------ */
    
    interface AddTaskForm {
      title: string
      description: string
      priority: KanbanTask['priority']
      assigneeKey: keyof typeof assignees
      tagKeys: (keyof typeof tagPresets)[]
      subtaskTexts: string[]
    }
    
    const emptyForm = (): AddTaskForm => ({
      title: '',
      description: '',
      priority: 'medium',
      assigneeKey: 'alice',
      tagKeys: [],
      subtaskTexts: [],
    })
    
    function KanbanAddTaskDialog({
      open,
      columns,
      initialColumnId,
      onOpenChange,
      onCreate,
    }: {
      open: boolean
      columns: KanbanColumnType[]
      initialColumnId: string
      onOpenChange: (value: boolean) => void
      onCreate: (columnId: string, tasks: KanbanTask[]) => void
    }) {
      const [columnId, setColumnId] = React.useState(initialColumnId)
      const [dueDate, setDueDate] = React.useState<Date | undefined>(undefined)
      const [form, setForm] = React.useState<AddTaskForm>(emptyForm)
      const [newSubtaskText, setNewSubtaskText] = React.useState('')
    
      React.useEffect(() => {
        setColumnId(initialColumnId)
      }, [initialColumnId])
    
      React.useEffect(() => {
        if (open) {
          setColumnId(initialColumnId)
          setForm(emptyForm())
          setDueDate(undefined)
          setNewSubtaskText('')
        }
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [open])
    
      function addSubtask() {
        const text = newSubtaskText.trim()
        if (!text) return
        setForm((f) => ({ ...f, subtaskTexts: [...f.subtaskTexts, text] }))
        setNewSubtaskText('')
      }
    
      function removeSubtask(index: number) {
        setForm((f) => ({ ...f, subtaskTexts: f.subtaskTexts.filter((_, i) => i !== index) }))
      }
    
      function toggleTag(key: keyof typeof tagPresets) {
        setForm((f) => {
          const idx = f.tagKeys.indexOf(key)
          const tagKeys = idx >= 0 ? f.tagKeys.filter((k) => k !== key) : [...f.tagKeys, key]
          return { ...f, tagKeys }
        })
      }
    
      function submit() {
        if (!form.title.trim()) return
        const maxId = columns
          .flatMap((c) => c.tasks)
          .reduce((max, t) => {
            const num = parseInt(t.id.replace(/^[A-Z]+-/, ''), 10)
            return num > max ? num : max
          }, 0)
        const idPrefix = columns.flatMap((c) => c.tasks)[0]?.id.split('-')[0] ?? 'TASK'
        const parentId = `${idPrefix}-${maxId + 1}`
        const subtaskTasks: KanbanTask[] = form.subtaskTexts.map((text, i) => ({
          id: `${idPrefix}-${maxId + 2 + i}`,
          title: text,
          priority: 'medium' as const,
          assignee: assignees[form.assigneeKey],
          tags: [],
          parentId,
          subtaskIds: [],
          commentItems: [],
          fileItems: [],
        }))
        const newTask: KanbanTask = {
          id: parentId,
          title: form.title.trim(),
          description: form.description.trim() || undefined,
          priority: form.priority,
          assignee: assignees[form.assigneeKey],
          tags: form.tagKeys.map((k) => tagPresets[k]),
          dueDate: dueDate ? dueDate.toISOString().slice(0, 10) : undefined,
          subtaskIds: subtaskTasks.map((t) => t.id),
          commentItems: [],
          fileItems: [],
        }
        onCreate(columnId, [newTask, ...subtaskTasks])
        onOpenChange(false)
      }
    
      return (
        <Dialog open={open} onOpenChange={onOpenChange}>
          <DialogScrollContent className="sm:max-w-[680px]">
            <DialogHeader>
              <DialogTitle>New Task</DialogTitle>
              <DialogDescription>
                Adding task to {columns.find((c) => c.id === columnId)?.title ?? 'column'}
              </DialogDescription>
            </DialogHeader>
    
            <div className="grid gap-4 py-2">
              <div className="grid gap-1.5">
                <Label htmlFor="task-title">Title</Label>
                <Input
                  id="task-title"
                  value={form.title}
                  placeholder="Enter task title"
                  onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
                />
              </div>
    
              <div className="grid gap-1.5">
                <Label>
                  Description
                  <span className="text-muted-foreground text-xs">(optional)</span>
                </Label>
                <RichTextEditor
                  value={form.description}
                  onValueChange={(val) => setForm((f) => ({ ...f, description: val }))}
                />
              </div>
    
              <div className="grid grid-cols-3 gap-4">
                <div className="grid gap-1.5">
                  <Label htmlFor="task-priority">Priority</Label>
                  <Select
                    value={form.priority}
                    onValueChange={(val) => setForm((f) => ({ ...f, priority: val as KanbanTask['priority'] }))}
                  >
                    <SelectTrigger id="task-priority">
                      <SelectValue placeholder="Priority" />
                    </SelectTrigger>
                    <SelectContent>
                      {Object.entries(priorityConfig).map(([key, config]) => (
                        <SelectItem key={key} value={key}>
                          {config.label}
                        </SelectItem>
                      ))}
                    </SelectContent>
                  </Select>
                </div>
    
                <div className="grid gap-1.5">
                  <Label htmlFor="task-assignee">Assignee</Label>
                  <Select
                    value={form.assigneeKey}
                    onValueChange={(val) => setForm((f) => ({ ...f, assigneeKey: val as keyof typeof assignees }))}
                  >
                    <SelectTrigger id="task-assignee">
                      <SelectValue placeholder="Assignee" />
                    </SelectTrigger>
                    <SelectContent>
                      {Object.entries(assignees).map(([key, person]) => (
                        <SelectItem key={key} value={key}>
                          {person.name}
                        </SelectItem>
                      ))}
                    </SelectContent>
                  </Select>
                </div>
    
                <div className="grid gap-1.5">
                  <Label htmlFor="task-column">Column</Label>
                  <Select value={columnId} onValueChange={setColumnId}>
                    <SelectTrigger id="task-column">
                      <SelectValue placeholder="Column" />
                    </SelectTrigger>
                    <SelectContent>
                      {columns.map((col) => (
                        <SelectItem key={col.id} value={col.id}>
                          {col.title}
                        </SelectItem>
                      ))}
                    </SelectContent>
                  </Select>
                </div>
              </div>
    
              <div className="grid gap-1.5">
                <Label>
                  Due Date
                  <span className="text-muted-foreground text-xs">(optional)</span>
                </Label>
                <Popover>
                  <PopoverTrigger asChild>
                    <Button
                      variant="outline"
                      className={cn('w-full justify-start text-left font-normal', !dueDate && 'text-muted-foreground')}
                    >
                      <CalendarIcon className="mr-2 size-4" />
                      {dueDate ? dueDate.toLocaleDateString('en-US', { dateStyle: 'medium' }) : 'Pick a date'}
                    </Button>
                  </PopoverTrigger>
                  <PopoverContent className="w-auto p-0">
                    <Calendar mode="single" selected={dueDate} onSelect={setDueDate} />
                  </PopoverContent>
                </Popover>
              </div>
    
              <div className="grid gap-1.5">
                <Label>
                  Tags
                  <span className="text-muted-foreground text-xs">(optional)</span>
                </Label>
                <div className="flex flex-wrap gap-2">
                  {Object.entries(tagPresets).map(([key, tag]) => {
                    const active = form.tagKeys.includes(key as keyof typeof tagPresets)
                    return (
                      <button
                        key={key}
                        type="button"
                        className={cn(
                          'inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium transition-colors',
                          active
                            ? 'bg-primary text-primary-foreground border-transparent'
                            : 'border-border bg-background text-foreground hover:bg-muted',
                        )}
                        onClick={() => toggleTag(key as keyof typeof tagPresets)}
                      >
                        {active && <CheckCircle2 className="size-3" />}
                        {tag.label}
                      </button>
                    )
                  })}
                </div>
              </div>
    
              <div className="grid gap-1.5">
                <Label>
                  Subtasks
                  <span className="text-muted-foreground text-xs">(optional)</span>
                </Label>
                {form.subtaskTexts.length > 0 && (
                  <div className="space-y-2">
                    {form.subtaskTexts.map((text, index) => (
                      <div key={index} className="flex items-center gap-2">
                        <span className="flex-1 text-sm">{text}</span>
                        <Button variant="ghost" size="icon" className="size-6" onClick={() => removeSubtask(index)}>
                          <X className="size-3" />
                        </Button>
                      </div>
                    ))}
                  </div>
                )}
                <div className="flex items-center gap-2">
                  <Input
                    value={newSubtaskText}
                    placeholder="Add a subtask"
                    onChange={(e) => setNewSubtaskText(e.target.value)}
                    onKeyUp={(e) => e.key === 'Enter' && addSubtask()}
                  />
                  <Button variant="outline" size="icon" onClick={addSubtask}>
                    <Plus className="size-4" />
                  </Button>
                </div>
              </div>
            </div>
    
            <DialogFooter>
              <Button variant="outline" onClick={() => onOpenChange(false)}>
                Cancel
              </Button>
              <Button disabled={!form.title.trim()} onClick={submit}>
                Create Task
              </Button>
            </DialogFooter>
          </DialogScrollContent>
        </Dialog>
      )
    }
    
    /* ------------------------------------------------------------------ */
    /* KanbanBoard (root)                                                  */
    /* ------------------------------------------------------------------ */
    
    export interface KanbanBoardProps {
      columns: KanbanColumnType[]
      onColumnsChange?: (value: KanbanColumnType[]) => void
      title?: string | null
      description?: string | null
      defaultColumnId?: string
      hideHeader?: boolean
      hideToolbar?: boolean
      lockParentScroll?: boolean
    }
    
    export function KanbanBoard({
      columns,
      onColumnsChange,
      title = 'Kanban Board',
      description = 'Drag tasks across columns to update their status.',
      defaultColumnId = 'backlog',
      hideHeader = false,
      hideToolbar = false,
      lockParentScroll = true,
    }: KanbanBoardProps) {
      const kanbanEl = React.useRef<HTMLDivElement | null>(null)
    
      React.useEffect(() => {
        if (!lockParentScroll) return
        const parentMain = kanbanEl.current?.closest('main[data-slot="sidebar-inset"]') as HTMLElement | null
        if (parentMain) parentMain.style.overflow = 'hidden'
        document.documentElement.style.overflow = 'hidden'
        return () => {
          if (parentMain) parentMain.style.overflow = ''
          document.documentElement.style.overflow = ''
        }
      }, [lockParentScroll])
    
      const [searchQuery, setSearchQuery] = React.useState('')
      const [selectedPriority, setSelectedPriority] = React.useState<string | null>(null)
      const [selectedAssignee, setSelectedAssignee] = React.useState<string | null>(null)
      const [viewMode, setViewMode] = React.useState<'board' | 'list'>('board')
      const [collapsedColumns, setCollapsedColumns] = React.useState<Set<string>>(new Set())
    
      const [detailOpen, setDetailOpen] = React.useState(false)
      const [detailTask, setDetailTask] = React.useState<KanbanTask | null>(null)
    
      const [addTaskOpen, setAddTaskOpen] = React.useState(false)
      const [addTaskColumnId, setAddTaskColumnId] = React.useState(defaultColumnId)
    
      const draggedTaskRef = React.useRef<string | null>(null)
      const dragOverColumnRef = React.useRef<string | null>(null)
      const dropTargetIndexRef = React.useRef<number>(-1)
      const lastDragEndRef = React.useRef(0)
    
      const [draggedTask, setDraggedTask] = React.useState<string | null>(null)
      const [dragOverColumn, setDragOverColumn] = React.useState<string | null>(null)
      const [dropTargetIndex, setDropTargetIndex] = React.useState<number>(-1)
    
      // Mutating helper: the consumer owns `columns`, so we clone, mutate the
      // clone, and emit it back via onColumnsChange — mirroring the Vue v-model.
      function commitColumns(mutator: (cols: KanbanColumnType[]) => void) {
        const next = columns.map((c) => ({ ...c, tasks: [...c.tasks] }))
        mutator(next)
        onColumnsChange?.(next)
      }
    
      const filteredColumns = React.useMemo<FilteredColumn[]>(() => {
        return columns.map((col) => ({
          ...col,
          tasks: col.tasks.filter((task) => {
            const q = searchQuery.toLowerCase()
            const matchesSearch = !q || task.title.toLowerCase().includes(q) || task.id.toLowerCase().includes(q)
            const matchesPriority = !selectedPriority || task.priority === selectedPriority
            const matchesAssignee = !selectedAssignee || task.assignee.name === selectedAssignee
            return matchesSearch && matchesPriority && matchesAssignee
          }),
        }))
      }, [columns, searchQuery, selectedPriority, selectedAssignee])
    
      const totalTasks = React.useMemo(() => columns.reduce((sum, col) => sum + col.tasks.length, 0), [columns])
    
      function toggleCollapse(columnId: string) {
        setCollapsedColumns((prev) => {
          const next = new Set(prev)
          if (next.has(columnId)) next.delete(columnId)
          else next.add(columnId)
          return next
        })
      }
    
      function addComment(task: KanbanTask, text: string) {
        commitColumns((cols) => {
          const target = findTaskById(cols, task.id)
          if (target) {
            target.commentItems = [
              ...target.commentItems,
              {
                id: `c${Date.now()}`,
                author: 'Admin User',
                authorColor: 'bg-chart-1/15 text-chart-1',
                text,
                time: 'Just now',
              },
            ]
          }
        })
      }
    
      function moveTask(task: KanbanTask, targetColumnId: string) {
        commitColumns((cols) => {
          const sourceCol = cols.find((c) => c.tasks.some((t) => t.id === task.id))
          const targetCol = cols.find((c) => c.id === targetColumnId)
          if (!sourceCol || !targetCol || sourceCol.id === targetColumnId) return
          const taskIndex = sourceCol.tasks.findIndex((t) => t.id === task.id)
          if (taskIndex === -1) return
          const removed = sourceCol.tasks.splice(taskIndex, 1)
          if (removed[0]) targetCol.tasks.push(removed[0])
        })
      }
    
      function openTaskDetail(task: KanbanTask) {
        setDetailTask(task)
        setDetailOpen(true)
      }
    
      function onDragStart(event: React.DragEvent, taskId: string) {
        draggedTaskRef.current = taskId
        setDraggedTask(taskId)
        if (event.dataTransfer) {
          event.dataTransfer.effectAllowed = 'move'
          event.dataTransfer.setData('text/plain', taskId)
        }
      }
    
      function resetDrag() {
        draggedTaskRef.current = null
        dragOverColumnRef.current = null
        dropTargetIndexRef.current = -1
        setDraggedTask(null)
        setDragOverColumn(null)
        setDropTargetIndex(-1)
        lastDragEndRef.current = Date.now()
      }
    
      function onDrop() {
        const dragged = draggedTaskRef.current
        const overColumn = dragOverColumnRef.current
        if (!dragged || !overColumn) {
          resetDrag()
          return
        }
    
        commitColumns((cols) => {
          let sourceColIdx = -1
          let taskIdx = -1
          for (let c = 0; c < cols.length; c++) {
            const col = cols[c]
            if (!col) continue
            const tIdx = col.tasks.findIndex((t) => t.id === dragged)
            if (tIdx !== -1) {
              sourceColIdx = c
              taskIdx = tIdx
              break
            }
          }
          const targetColIdx = cols.findIndex((c) => c.id === overColumn)
          if (sourceColIdx === -1 || targetColIdx === -1) return
    
          const sourceCol = cols[sourceColIdx]
          const targetCol = cols[targetColIdx]
          if (!sourceCol || !targetCol) return
    
          const [task] = sourceCol.tasks.splice(taskIdx, 1)
          if (!task) return
    
          let insertAt = dropTargetIndexRef.current
          if (insertAt < 0) insertAt = targetCol.tasks.length
          if (sourceColIdx === targetColIdx && taskIdx < insertAt) insertAt--
    
          targetCol.tasks.splice(insertAt, 0, task)
        })
        resetDrag()
      }
    
      function onCardClick(task: KanbanTask) {
        if (Date.now() - lastDragEndRef.current < 200) return
        openTaskDetail(task)
      }
    
      function onCardDragOver(event: React.DragEvent, columnId: string, taskIndex: number) {
        dragOverColumnRef.current = columnId
        setDragOverColumn(columnId)
        const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
        const midY = rect.top + rect.height / 2
        const idx = event.clientY < midY ? taskIndex : taskIndex + 1
        dropTargetIndexRef.current = idx
        setDropTargetIndex(idx)
      }
    
      function onLaneDragOver(_event: React.DragEvent, columnId: string, taskCount: number) {
        dragOverColumnRef.current = columnId
        setDragOverColumn(columnId)
        dropTargetIndexRef.current = taskCount
        setDropTargetIndex(taskCount)
      }
    
      function openAddTask(columnId: string) {
        setAddTaskColumnId(columnId)
        setAddTaskOpen(true)
      }
    
      function onCreateTask(columnId: string, tasks: KanbanTask[]) {
        commitColumns((cols) => {
          const col = cols.find((c) => c.id === columnId)
          if (col) col.tasks.push(...tasks)
        })
      }
    
      return (
        <div
          ref={kanbanEl}
          data-slot="kanban-board"
          className="kanban-page flex h-[calc(100dvh-3.5rem-3rem)] flex-col overflow-hidden lg:h-[calc(100dvh-3.5rem-3rem)]"
        >
          {!hideHeader && (
            <div className="mb-3 shrink-0">
              <PageHeader>
                <div className="flex items-start justify-between gap-4">
                  <PageHeaderHeading title={title ?? ''} description={description ?? ''} />
                  <div className="flex shrink-0 items-center gap-2">
                    <Badge variant="secondary" className="font-mono text-xs tabular-nums">
                      {totalTasks} tasks
                    </Badge>
                    <Button size="sm" onClick={() => openAddTask(defaultColumnId)}>
                      <Plus className="size-4" />
                      Add Task
                    </Button>
                  </div>
                </div>
              </PageHeader>
            </div>
          )}
    
          {!hideToolbar && (
            <KanbanToolbar
              searchQuery={searchQuery}
              selectedPriority={selectedPriority}
              selectedAssignee={selectedAssignee}
              viewMode={viewMode}
              onSearchQueryChange={setSearchQuery}
              onSelectedPriorityChange={setSelectedPriority}
              onSelectedAssigneeChange={setSelectedAssignee}
              onViewModeChange={setViewMode}
            />
          )}
    
          {viewMode === 'board' ? (
            <div className="kanban-board relative flex min-h-0 flex-1 items-start gap-3 overflow-auto pb-3">
              {filteredColumns.map((column) => (
                <KanbanColumn
                  key={column.id}
                  column={column}
                  collapsed={collapsedColumns.has(column.id)}
                  draggedTask={draggedTask}
                  dragOverColumn={dragOverColumn}
                  dropTargetIndex={dropTargetIndex}
                  allColumns={columns}
                  onToggleCollapse={toggleCollapse}
                  onAddTask={openAddTask}
                  onCardClick={onCardClick}
                  onCardQuickView={openTaskDetail}
                  onDragStart={onDragStart}
                  onDragEnd={resetDrag}
                  onCardDragOver={onCardDragOver}
                  onLaneDragOver={onLaneDragOver}
                  onDrop={onDrop}
                />
              ))}
            </div>
          ) : (
            <KanbanListView
              columns={filteredColumns}
              allColumns={columns}
              onTaskClick={openTaskDetail}
              onMoveTask={moveTask}
            />
          )}
    
          <KanbanTaskSheet
            open={detailOpen}
            task={detailTask}
            columns={columns}
            onOpenChange={setDetailOpen}
            onMoveTask={moveTask}
            onAddComment={addComment}
          />
    
          <KanbanAddTaskDialog
            open={addTaskOpen}
            columns={columns}
            initialColumnId={addTaskColumnId}
            onOpenChange={setAddTaskOpen}
            onCreate={onCreateTask}
          />
    
          <style>{`
            .kanban-board {
              scrollbar-width: thin;
              scrollbar-color: hsl(var(--border)) transparent;
            }
            .kanban-board::-webkit-scrollbar {
              height: 6px;
              width: 6px;
            }
            .kanban-board::-webkit-scrollbar-thumb {
              background-color: hsl(var(--border));
              border-radius: 3px;
            }
            .kanban-board::-webkit-scrollbar-corner {
              background: transparent;
            }
            .kanban-card {
              animation: card-in 0.25s ease-out both;
            }
            @keyframes card-in {
              from {
                opacity: 0;
                transform: translateY(6px);
              }
            }
            .kanban-card:hover .kanban-accent {
              box-shadow: 0 0 3px currentColor;
            }
            .kanban-lane {
              min-height: 60px;
            }
            .kanban-list {
              scrollbar-width: thin;
              scrollbar-color: hsl(var(--border)) transparent;
            }
            .kanban-list::-webkit-scrollbar {
              height: 6px;
              width: 6px;
            }
            .kanban-list::-webkit-scrollbar-thumb {
              background-color: hsl(var(--border));
              border-radius: 3px;
            }
          `}</style>
        </div>
      )
    }

Raw manifest: https://react.uipkge.dev/r/react/kanban-board.json