Kanban Board
block dashboardFull 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
$ pnpm dlx shadcn@latest add https://react.uipkge.dev/r/react/kanban-board.json$ npx shadcn@latest add https://react.uipkge.dev/r/react/kanban-board.json$ yarn dlx shadcn@latest add https://react.uipkge.dev/r/react/kanban-board.json$ bunx 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