UIPackage
Menu

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 React files (KanbanBoard + 6 kanban-* views + 7 small badges/lists). Bind with columns/onColumnsChange; 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://uipkge.dev/r/react/kanban-board.json
Named registry: npx shadcn@latest add @uipkge-react/kanban-board Installs to: components/blocks/kanban-board/

Examples

Props

Name Type / Values Default Required
columns KanbanColumnType[] required
onColumnsChange (value: KanbanColumnType[]) => void optional
title string | null optional
description string | null optional
defaultColumnId string optional
hideHeader boolean optional
hideToolbar boolean optional
lockParentScroll boolean optional

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 installed (14)

  • components/blocks/kanban-board/KanbanBoard.tsx 11.8 kB
    'use client'
    
    import * as React from 'react'
    import { Plus } from 'lucide-react'
    import { type KanbanColumn as KanbanColumnType, type KanbanTask, findTaskById } from '@/lib/use-kanban'
    import { PageHeader, PageHeaderHeading } from '@/components/ui/page'
    import { Button } from '@/components/ui/button'
    import { Badge } from '@/components/ui/badge'
    import { KanbanToolbar } from './KanbanToolbar'
    import { KanbanColumn, type FilteredColumn } from './KanbanColumn'
    import { KanbanListView } from './KanbanListView'
    import { KanbanTaskSheet } from './KanbanTaskSheet'
    import { KanbanAddTaskDialog } from './KanbanAddTaskDialog'
    
    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>
      )
    }
  • components/blocks/kanban-board/KanbanCard.tsx 5.1 kB
    'use client'
    
    import * as React from 'react'
    import { MoreHorizontal, MessageSquare, Paperclip, ExternalLink } from 'lucide-react'
    import { cn } from '@/lib/utils'
    import { type KanbanColumn as KanbanColumnType, type KanbanTask, priorityConfig, getTaskColumn } from '@/lib/use-kanban'
    import { Button } from '@/components/ui/button'
    import {
      DropdownMenu,
      DropdownMenuContent,
      DropdownMenuItem,
      DropdownMenuSeparator,
      DropdownMenuTrigger,
    } from '@/components/ui/dropdown-menu'
    import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
    import { TagBadge } from './TagBadge'
    import { SubtaskProgress } from './SubtaskProgress'
    import { DueDateBadge } from './DueDateBadge'
    import { UserAvatar } from './UserAvatar'
    
    export 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>
      )
    }
  • components/blocks/kanban-board/KanbanColumn.tsx 7.1 kB
    'use client'
    
    import * as React from 'react'
    import { Plus, MoreHorizontal, ChevronsLeft, ChevronsRight } from 'lucide-react'
    import { cn } from '@/lib/utils'
    import { type KanbanColumn as KanbanColumnType, type KanbanTask } from '@/lib/use-kanban'
    import { Button } from '@/components/ui/button'
    import { Badge } from '@/components/ui/badge'
    import { KanbanCard } from './KanbanCard'
    
    export interface FilteredColumn {
      id: string
      title: string
      color: string
      dotColor: string
      tasks: KanbanTask[]
    }
    
    export 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>
      )
    }
  • components/blocks/kanban-board/KanbanListView.tsx 11.4 kB
    'use client'
    
    import * as React from 'react'
    import { ChevronDown, ChevronRight, ExternalLink, MessageSquare, Paperclip, ArrowUpDown } from 'lucide-react'
    import { cn } from '@/lib/utils'
    import { type KanbanColumn as KanbanColumnType, type KanbanTask, getTaskColumn } from '@/lib/use-kanban'
    import { Badge } from '@/components/ui/badge'
    import {
      Select,
      SelectContent,
      SelectItem,
      SelectTrigger,
      SelectValue,
    } from '@/components/ui/select'
    import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
    import { type FilteredColumn } from './KanbanColumn'
    import { TagBadge } from './TagBadge'
    import { SubtaskProgress } from './SubtaskProgress'
    import { PriorityBadge } from './PriorityBadge'
    import { DueDateBadge } from './DueDateBadge'
    import { UserAvatar } from './UserAvatar'
    
    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 }
    
    export 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>
      )
    }
  • components/blocks/kanban-board/KanbanTaskSheet.tsx 8.9 kB
    'use client'
    
    import { Clock, ExternalLink, Download } from 'lucide-react'
    import { cn } from '@/lib/utils'
    import { type KanbanColumn as KanbanColumnType, type KanbanTask, priorityConfig, fileIconMap } from '@/lib/use-kanban'
    import { Button } from '@/components/ui/button'
    import {
      Select,
      SelectContent,
      SelectItem,
      SelectTrigger,
      SelectValue,
    } from '@/components/ui/select'
    import { Sheet, SheetContent, SheetTitle, SheetDescription, SheetFooter, SheetClose } from '@/components/ui/sheet'
    import { TagBadge } from './TagBadge'
    import { PriorityBadge } from './PriorityBadge'
    import { DueDateBadge } from './DueDateBadge'
    import { UserAvatar } from './UserAvatar'
    import { SubtaskList } from './SubtaskList'
    import { CommentList } from './CommentList'
    
    export 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>
      )
    }
  • components/blocks/kanban-board/KanbanAddTaskDialog.tsx 10.6 kB
    'use client'
    
    import * as React from 'react'
    import { Plus, X, CheckCircle2, Calendar as CalendarIcon } from 'lucide-react'
    import { cn } from '@/lib/utils'
    import {
      type KanbanColumn as KanbanColumnType,
      type KanbanTask,
      priorityConfig,
      assignees,
      tagPresets,
    } from '@/lib/use-kanban'
    import { Button } from '@/components/ui/button'
    import { Input } from '@/components/ui/input'
    import { Label } from '@/components/ui/label'
    import {
      Select,
      SelectContent,
      SelectItem,
      SelectTrigger,
      SelectValue,
    } from '@/components/ui/select'
    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'
    
    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: [],
    })
    
    export 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>
      )
    }
  • components/blocks/kanban-board/KanbanToolbar.tsx 5.8 kB
    'use client'
    
    import { Search, Filter, ChevronDown, X, LayoutGrid, List } from 'lucide-react'
    import { cn } from '@/lib/utils'
    import { priorityConfig, assignees, getInitials } from '@/lib/use-kanban'
    import { Button } from '@/components/ui/button'
    import { Badge } from '@/components/ui/badge'
    import { Input } from '@/components/ui/input'
    import { Avatar, AvatarFallback } from '@/components/ui/avatar'
    import {
      DropdownMenu,
      DropdownMenuContent,
      DropdownMenuItem,
      DropdownMenuSeparator,
      DropdownMenuTrigger,
    } from '@/components/ui/dropdown-menu'
    import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
    import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
    
    export 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>
      )
    }
  • components/blocks/kanban-board/PriorityBadge.tsx 0.6 kB
    'use client'
    
    import { cn } from '@/lib/utils'
    import { priorityConfig } from '@/lib/use-kanban'
    
    export 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>
      )
    }
  • components/blocks/kanban-board/DueDateBadge.tsx 1.1 kB
    'use client'
    
    import { Clock } from 'lucide-react'
    import { cn } from '@/lib/utils'
    import { getDueStatus, formatDueDate } from '@/lib/use-kanban'
    
    export 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>
      )
    }
  • components/blocks/kanban-board/TagBadge.tsx 0.3 kB
    'use client'
    
    import { cn } from '@/lib/utils'
    
    export 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>
      )
    }
  • components/blocks/kanban-board/UserAvatar.tsx 0.7 kB
    'use client'
    
    import { cn } from '@/lib/utils'
    import { getInitials } from '@/lib/use-kanban'
    import { Avatar, AvatarFallback } from '@/components/ui/avatar'
    
    const userAvatarSizeMap = {
      xs: { avatar: 'size-6', text: 'text-[8px]' },
      sm: { avatar: 'size-7', text: 'text-[9px]' },
      md: { avatar: 'size-8', text: 'text-[11px]' },
    }
    
    export 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>
      )
    }
  • components/blocks/kanban-board/SubtaskProgress.tsx 1 kB
    'use client'
    
    import { CheckCircle2 } from 'lucide-react'
    import { cn } from '@/lib/utils'
    
    export 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>
      )
    }
  • components/blocks/kanban-board/SubtaskList.tsx 3.1 kB
    'use client'
    
    import { cn } from '@/lib/utils'
    import {
      type KanbanColumn as KanbanColumnType,
      type KanbanTask,
      findTaskById,
      getTaskColumn,
    } from '@/lib/use-kanban'
    
    export 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>
      )
    }
  • components/blocks/kanban-board/CommentList.tsx 3.3 kB
    'use client'
    
    import * as React from 'react'
    import { Send } from 'lucide-react'
    import { cn } from '@/lib/utils'
    import { type CommentItem } from '@/lib/use-kanban'
    import { Button } from '@/components/ui/button'
    import { RichTextEditor } from '@/components/ui/rich-text-editor'
    import { UserAvatar } from './UserAvatar'
    
    export 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>
        </>
      )
    }

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