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 React ->Installation
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/kanban-board.json$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/kanban-board.json$ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/kanban-board.json$ bunx shadcn-vue@latest add https://uipkge.dev/r/vue/kanban-board.json
Or with the named registry:
npx shadcn-vue@latest add @uipkge/kanban-board
Examples
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
FlatTask interface FlatTask {
task: KanbanTask
columnId: string
columnTitle: string
dotColor: string
} Theming
CSS custom properties referenced in this item. Override any of them in your :root or per-element to retheme.
--border Files (14)
-
app/components/blocks/kanban-board/KanbanBoard.vue 8.8 kB
<script setup lang="ts"> import { computed, onMounted, onUnmounted, ref } from 'vue' import { Plus } from 'lucide-vue-next' import { PageHeader, PageHeaderHeading } from '@/components/ui/page' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import KanbanToolbar from './KanbanToolbar.vue' import KanbanColumn from './KanbanColumn.vue' import KanbanListView from './KanbanListView.vue' import KanbanTaskSheet from './KanbanTaskSheet.vue' import KanbanAddTaskDialog from './KanbanAddTaskDialog.vue' import type { KanbanColumn as KanbanColumnType, KanbanTask } from '@/composables/useKanban' interface Props { columns: KanbanColumnType[] title?: string | null description?: string | null defaultColumnId?: string hideHeader?: boolean hideToolbar?: boolean lockParentScroll?: boolean } const props = withDefaults(defineProps<Props>(), { title: 'Kanban Board', description: 'Drag tasks across columns to update their status.', defaultColumnId: 'backlog', hideHeader: false, hideToolbar: false, lockParentScroll: true, }) const emits = defineEmits<{ 'update:columns': [value: KanbanColumnType[]] }>() const columnsRef = computed({ get: () => props.columns, set: (v) => emits('update:columns', v), }) const kanbanEl = ref<HTMLElement | null>(null) let parentMain: HTMLElement | null = null onMounted(() => { if (!props.lockParentScroll) return parentMain = kanbanEl.value?.closest('main[data-slot="sidebar-inset"]') as HTMLElement | null if (parentMain) parentMain.style.overflow = 'hidden' document.documentElement.style.overflow = 'hidden' }) onUnmounted(() => { if (parentMain) { parentMain.style.overflow = '' parentMain = null } if (props.lockParentScroll) document.documentElement.style.overflow = '' }) const searchQuery = ref('') const selectedPriority = ref<string | null>(null) const selectedAssignee = ref<string | null>(null) const viewMode = ref<'board' | 'list'>('board') const filteredColumns = computed(() => { return columnsRef.value.map((col) => ({ ...col, tasks: col.tasks.filter((task) => { const q = searchQuery.value.toLowerCase() const matchesSearch = !q || task.title.toLowerCase().includes(q) || task.id.toLowerCase().includes(q) const matchesPriority = !selectedPriority.value || task.priority === selectedPriority.value const matchesAssignee = !selectedAssignee.value || task.assignee.name === selectedAssignee.value return matchesSearch && matchesPriority && matchesAssignee }), })) }) const totalTasks = computed(() => columnsRef.value.reduce((sum, col) => sum + col.tasks.length, 0)) const collapsedColumns = ref<Set<string>>(new Set()) function toggleCollapse(columnId: string) { const next = new Set(collapsedColumns.value) if (next.has(columnId)) next.delete(columnId) else next.add(columnId) collapsedColumns.value = next } function addComment(task: KanbanTask, text: string) { task.commentItems.push({ 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) { const sourceCol = columnsRef.value.find((c) => c.tasks.some((t) => t.id === task.id)) const targetCol = columnsRef.value.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]) } const detailOpen = ref(false) const detailTask = ref<KanbanTask | null>(null) function openTaskDetail(task: KanbanTask) { detailTask.value = task detailOpen.value = true } const draggedTask = ref<string | null>(null) const dragOverColumn = ref<string | null>(null) const dropTargetIndex = ref<number>(-1) let lastDragEnd = 0 function onDragStart(event: DragEvent, taskId: string) { draggedTask.value = taskId if (event.dataTransfer) { event.dataTransfer.effectAllowed = 'move' event.dataTransfer.setData('text/plain', taskId) } } function resetDrag() { draggedTask.value = null dragOverColumn.value = null dropTargetIndex.value = -1 lastDragEnd = Date.now() } function onDrop() { if (!draggedTask.value || !dragOverColumn.value) { resetDrag() return } let sourceColIdx = -1 let taskIdx = -1 for (let c = 0; c < columnsRef.value.length; c++) { const col = columnsRef.value[c] if (!col) continue const tIdx = col.tasks.findIndex((t) => t.id === draggedTask.value) if (tIdx !== -1) { sourceColIdx = c taskIdx = tIdx break } } const targetColIdx = columnsRef.value.findIndex((c) => c.id === dragOverColumn.value) if (sourceColIdx === -1 || targetColIdx === -1) { resetDrag() return } const sourceCol = columnsRef.value[sourceColIdx] const targetCol = columnsRef.value[targetColIdx] if (!sourceCol || !targetCol) { resetDrag() return } const [task] = sourceCol.tasks.splice(taskIdx, 1) if (!task) { resetDrag() return } let insertAt = dropTargetIndex.value 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() - lastDragEnd < 200) return openTaskDetail(task) } function onCardDragOver(event: DragEvent, columnId: string, taskIndex: number) { dragOverColumn.value = columnId const rect = (event.currentTarget as HTMLElement).getBoundingClientRect() const midY = rect.top + rect.height / 2 dropTargetIndex.value = event.clientY < midY ? taskIndex : taskIndex + 1 } function onLaneDragOver(_event: DragEvent, columnId: string, taskCount: number) { dragOverColumn.value = columnId dropTargetIndex.value = taskCount } const addTaskOpen = ref(false) const addTaskColumnId = ref(props.defaultColumnId) function openAddTask(columnId: string) { addTaskColumnId.value = columnId addTaskOpen.value = true } function onCreateTask(columnId: string, tasks: KanbanTask[]) { const col = columnsRef.value.find((c) => c.id === columnId) if (col) col.tasks.push(...tasks) } </script> <template> <div ref="kanbanEl" data-slot="kanban-board" class="kanban-page flex h-[calc(100dvh-3.5rem-3rem)] flex-col overflow-hidden lg:h-[calc(100dvh-3.5rem-3rem)]" > <div v-if="!hideHeader" class="mb-3 shrink-0"> <PageHeader> <div class="flex items-start justify-between gap-4"> <PageHeaderHeading :title="title ?? ''" :description="description ?? ''" /> <div class="flex shrink-0 items-center gap-2"> <Badge variant="secondary" class="font-mono text-xs tabular-nums">{{ totalTasks }} tasks</Badge> <Button size="sm" @click="openAddTask(defaultColumnId)"> <Plus class="size-4" /> Add Task </Button> </div> </div> </PageHeader> </div> <KanbanToolbar v-if="!hideToolbar" v-model:search-query="searchQuery" v-model:selected-priority="selectedPriority" v-model:selected-assignee="selectedAssignee" v-model:view-mode="viewMode" /> <div v-if="viewMode === 'board'" class="kanban-board relative flex min-h-0 flex-1 items-start gap-3 overflow-auto pb-3" > <KanbanColumn v-for="column in filteredColumns" :key="column.id" :column="column" :collapsed="collapsedColumns.has(column.id)" :dragged-task="draggedTask" :drag-over-column="dragOverColumn" :drop-target-index="dropTargetIndex" @toggle-collapse="toggleCollapse" @add-task="openAddTask" @card-click="onCardClick" @card-quick-view="openTaskDetail" @drag-start="onDragStart" @drag-end="resetDrag" @card-drag-over="onCardDragOver" @lane-drag-over="onLaneDragOver" @drop="onDrop" /> </div> <KanbanListView v-else :columns="filteredColumns" :all-columns="columnsRef" @task-click="openTaskDetail" @move-task="moveTask" /> <KanbanTaskSheet v-model:open="detailOpen" :task="detailTask" :columns="columnsRef" @move-task="moveTask" @add-comment="addComment" /> <KanbanAddTaskDialog v-model:open="addTaskOpen" :columns="columnsRef" :initial-column-id="addTaskColumnId" @create="onCreateTask" /> </div> </template> <style scoped> .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; } </style> -
app/components/blocks/kanban-board/KanbanCard.vue 4.7 kB
<script setup lang="ts"> import { computed, defineEmits } from 'vue' import { type KanbanTask, type KanbanColumn, priorityConfig, getTaskColumn } from '@/composables/useKanban' import TagBadge from './TagBadge.vue' import SubtaskProgress from './SubtaskProgress.vue' import DueDateBadge from './DueDateBadge.vue' import UserAvatar from './UserAvatar.vue' 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 { MoreHorizontal, MessageSquare, Paperclip, ExternalLink } from 'lucide-vue-next' const props = defineProps<{ task: KanbanTask isDone: boolean }>() const columns = useState<KanbanColumn[]>('kanban-columns') const subtasksDone = computed(() => { if (!columns.value || !props.task.subtaskIds.length) return 0 return props.task.subtaskIds.filter((id) => getTaskColumn(columns.value, id)?.id === 'done').length }) defineEmits<{ click: [task: KanbanTask] 'quick-view': [task: KanbanTask] }>() </script> <template> <div :class="[ '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' : '', ]" @click="$emit('click', task)" > <div :class="[ '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 class="mb-1 flex items-center justify-between pl-2"> <span class="text-muted-foreground/70 font-mono text-[11px]">{{ task.id }}</span> <DropdownMenu> <DropdownMenuTrigger as-child> <Button variant="ghost" size="icon" class="text-muted-foreground -mr-1 size-6 opacity-0 transition-opacity group-hover/card:opacity-100" @click.stop > <MoreHorizontal class="size-3.5" /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" class="w-36"> <DropdownMenuItem @click.stop="$emit('quick-view', task)">Quick view</DropdownMenuItem> <DropdownMenuItem as-child> <NuxtLink :to="`/dashboard/kanban/${task.id}`" class="gap-2"> <ExternalLink class="size-3.5" /> Open detail </NuxtLink> </DropdownMenuItem> <DropdownMenuItem>Edit</DropdownMenuItem> <DropdownMenuItem>Move to...</DropdownMenuItem> <DropdownMenuItem>Assign to...</DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem class="text-destructive">Delete</DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </div> <p :class="[ 'mb-2 pl-2 text-[13px] leading-snug font-medium', isDone ? 'decoration-muted-foreground/40 line-through' : '', ]" > {{ task.title }} </p> <div v-if="task.tags.length" class="mb-2 flex flex-wrap gap-1 pl-2"> <TagBadge v-for="tag in task.tags" :key="tag.label" :label="tag.label" :color="tag.color" /> </div> <div v-if="task.subtaskIds.length" class="mb-2 pl-2"> <SubtaskProgress :done="subtasksDone" :total="task.subtaskIds.length" /> </div> <div class="flex items-center gap-2 pl-2"> <DueDateBadge v-if="task.dueDate" :due-date="task.dueDate" variant="chip" /> <div v-if="task.commentItems.length" class="text-muted-foreground/70 flex items-center gap-1 text-[11px]"> <MessageSquare class="size-3" /> {{ task.commentItems.length }} </div> <div v-if="task.fileItems.length" class="text-muted-foreground/70 flex items-center gap-1 text-[11px]"> <Paperclip class="size-3" /> {{ task.fileItems.length }} </div> <div class="ml-auto"> <TooltipProvider :delay-duration="200"> <Tooltip> <TooltipTrigger as-child> <UserAvatar :name="task.assignee.name" :color="task.assignee.color" size="xs" /> </TooltipTrigger> <TooltipContent side="bottom" class="text-xs">{{ task.assignee.name }}</TooltipContent> </Tooltip> </TooltipProvider> </div> </div> </div> </template> <style scoped> .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; } </style> -
app/components/blocks/kanban-board/KanbanColumn.vue 5.7 kB
<script setup lang="ts"> import { Plus, MoreHorizontal, ChevronsLeft, ChevronsRight } from 'lucide-vue-next' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import KanbanCard from './KanbanCard.vue' import type { KanbanTask } from '@/composables/useKanban' defineProps<{ column: { id: string; title: string; color: string; dotColor: string; tasks: KanbanTask[] } collapsed: boolean draggedTask: string | null dragOverColumn: string | null dropTargetIndex: number }>() defineEmits<{ 'toggle-collapse': [columnId: string] 'add-task': [columnId: string] 'card-click': [task: KanbanTask] 'card-quick-view': [task: KanbanTask] 'drag-start': [event: DragEvent, taskId: string] 'drag-end': [] 'card-drag-over': [event: DragEvent, columnId: string, taskIndex: number] 'lane-drag-over': [event: DragEvent, columnId: string, taskCount: number] drop: [] }>() </script> <template> <div :class="['group/col flex shrink-0 flex-col transition-all duration-200', collapsed ? 'w-12' : 'w-[300px]']" @dragover.prevent @drop="$emit('drop')" > <button v-if="collapsed" class="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" @click="$emit('toggle-collapse', column.id)" > <span :class="['size-2 shrink-0 rounded-full', column.dotColor]" /> <span :class="['text-[11px] font-semibold tracking-tight', column.color, 'rotate-180 [writing-mode:vertical-lr]']" > {{ column.title }} </span> <Badge variant="secondary" class="mt-1 h-5 min-w-5 justify-center rounded-md px-1 text-[10px]"> {{ column.tasks.length }} </Badge> <ChevronsRight class="text-muted-foreground mt-auto size-3.5" /> </button> <template v-else> <div class="bg-background/95 sticky top-0 z-10 mb-2 flex items-center gap-2 px-2 py-1.5 backdrop-blur-sm"> <button class="text-muted-foreground/50 hover:text-muted-foreground shrink-0 transition-colors" title="Collapse column" @click="$emit('toggle-collapse', column.id)" > <ChevronsLeft class="size-3.5" /> </button> <span :class="['size-2 shrink-0 rounded-full', column.dotColor]" /> <h3 :class="['text-[13px] font-semibold tracking-tight', column.color]">{{ column.title }}</h3> <span class="text-muted-foreground bg-muted rounded-md px-1.5 py-0.5 text-[11px] font-medium tabular-nums"> {{ column.tasks.length }} </span> <div class="ml-auto flex items-center"> <Button variant="ghost" size="icon" class="text-muted-foreground size-6 opacity-0 transition-opacity group-hover/col:opacity-100" > <MoreHorizontal class="size-3.5" /> </Button> <Button variant="ghost" size="icon" class="text-muted-foreground size-6" @click="$emit('add-task', column.id)" > <Plus class="size-3.5" /> </Button> </div> </div> <div :class="[ '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', ]" @dragover.prevent="$emit('lane-drag-over', $event, column.id, column.tasks.length)" > <template v-for="(task, taskIndex) in column.tasks" :key="task.id"> <div :class="[ '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" :class="['mt-2 first:mt-0', draggedTask === task.id ? 'scale-95 rotate-1 opacity-30' : 'opacity-100']" @dragstart="$emit('drag-start', $event, task.id)" @dragend="$emit('drag-end')" @dragover.stop.prevent="$emit('card-drag-over', $event, column.id, taskIndex)" > <KanbanCard :task="task" :is-done="column.id === 'done'" @click="$emit('card-click', $event)" @quick-view="$emit('card-quick-view', $event)" /> </div> </template> <div v-if="column.tasks.length > 0" :class="[ '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', ]" /> <button v-if="column.tasks.length === 0" class="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" @click="$emit('add-task', column.id)" > <Plus class="mb-1 size-4" /> <p class="text-xs">No tasks</p> </button> </div> <button class="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" @click="$emit('add-task', column.id)" > <Plus class="size-3.5" /> Add task </button> </template> </div> </template> <style scoped> .kanban-lane { min-height: 60px; } </style> -
app/components/blocks/kanban-board/KanbanListView.vue 11.3 kB
<script setup lang="ts"> import { ref, computed } from 'vue' import type { KanbanTask, KanbanColumn } from '@/composables/useKanban' import { getTaskColumn } from '@/composables/useKanban' import PriorityBadge from './PriorityBadge.vue' import TagBadge from './TagBadge.vue' import DueDateBadge from './DueDateBadge.vue' import UserAvatar from './UserAvatar.vue' import SubtaskProgress from './SubtaskProgress.vue' 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 { MessageSquare, Paperclip, ExternalLink, ArrowUpDown, ChevronDown, ChevronRight } from 'lucide-vue-next' const props = defineProps<{ columns: KanbanColumn[] allColumns: KanbanColumn[] }>() defineEmits<{ 'task-click': [task: KanbanTask] 'move-task': [task: KanbanTask, targetColumnId: string] }>() type SortField = 'id' | 'title' | 'priority' | 'assignee' | 'dueDate' | 'status' type SortDir = 'asc' | 'desc' const sortField = ref<SortField>('status') const sortDir = ref<SortDir>('asc') const priorityOrder: Record<string, number> = { urgent: 0, high: 1, medium: 2, low: 3 } function toggleSort(field: SortField) { if (sortField.value === field) { sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc' } else { sortField.value = field sortDir.value = 'asc' } } const groupByStatus = ref(true) const collapsedGroups = ref<Set<string>>(new Set()) function toggleGroup(columnId: string) { const next = new Set(collapsedGroups.value) if (next.has(columnId)) next.delete(columnId) else next.add(columnId) collapsedGroups.value = next } interface FlatTask { task: KanbanTask columnId: string columnTitle: string dotColor: string } function sortTasks(tasks: FlatTask[]): FlatTask[] { return [...tasks].sort((a, b) => { let cmp = 0 switch (sortField.value) { 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 = props.allColumns.map((c) => c.id) cmp = colOrder.indexOf(a.columnId) - colOrder.indexOf(b.columnId) break } } return sortDir.value === 'desc' ? -cmp : cmp }) } const flatTasks = computed<FlatTask[]>(() => { const items: FlatTask[] = [] for (const col of props.columns) { for (const task of col.tasks) { items.push({ task, columnId: col.id, columnTitle: col.title, dotColor: col.dotColor, }) } } return sortTasks(items) }) const groupedTasks = computed(() => { if (!groupByStatus.value) return null const groups: { column: KanbanColumn; tasks: FlatTask[] }[] = [] for (const col of props.allColumns) { const tasks = flatTasks.value.filter((t) => t.columnId === col.id) groups.push({ column: col, tasks }) } return groups }) function subtasksDone(task: KanbanTask): number { if (!task.subtaskIds.length) return 0 return task.subtaskIds.filter((id) => getTaskColumn(props.allColumns, id)?.id === 'done').length } </script> <template> <div class="kanban-list flex min-h-0 flex-1 flex-col overflow-auto pb-3"> <div class="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" > <button class="flex items-center gap-1 text-left" @click="toggleSort('id')"> ID <ArrowUpDown :class="['size-3', sortField === 'id' ? 'text-foreground' : 'text-muted-foreground/50']" /> </button> <button class="flex items-center gap-1 text-left" @click="toggleSort('title')"> Task <ArrowUpDown :class="['size-3', sortField === 'title' ? 'text-foreground' : 'text-muted-foreground/50']" /> </button> <button class="flex items-center gap-1 text-left" @click="toggleSort('status')"> Status <ArrowUpDown :class="['size-3', sortField === 'status' ? 'text-foreground' : 'text-muted-foreground/50']" /> </button> <button class="flex items-center gap-1 text-left" @click="toggleSort('priority')"> Priority <ArrowUpDown :class="['size-3', sortField === 'priority' ? 'text-foreground' : 'text-muted-foreground/50']" /> </button> <button class="flex items-center gap-1 text-left" @click="toggleSort('assignee')"> Assignee <ArrowUpDown :class="['size-3', sortField === 'assignee' ? 'text-foreground' : 'text-muted-foreground/50']" /> </button> <button class="flex items-center gap-1 text-left" @click="toggleSort('dueDate')"> Due <ArrowUpDown :class="['size-3', sortField === 'dueDate' ? 'text-foreground' : 'text-muted-foreground/50']" /> </button> <span class="text-center">Info</span> </div> <template v-if="groupedTasks"> <template v-for="group in groupedTasks" :key="group.column.id"> <button class="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" @click="toggleGroup(group.column.id)" > <component :is="collapsedGroups.has(group.column.id) ? ChevronRight : ChevronDown" class="text-muted-foreground size-3.5" /> <span :class="['size-2 rounded-full', group.column.dotColor]" /> <span class="text-sm font-medium">{{ group.column.title }}</span> <Badge variant="secondary" class="ml-1 h-4 px-1.5 text-[10px] tabular-nums"> {{ group.tasks.length }} </Badge> </button> <template v-if="!collapsedGroups.has(group.column.id)"> <div v-for="item in group.tasks" :key="item.task.id" class="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" @click="$emit('task-click', item.task)" > <span class="text-muted-foreground font-mono text-[11px]">{{ item.task.id }}</span> <div class="min-w-0"> <div class="flex items-center gap-2"> <span :class="[ 'truncate text-[13px] font-medium', item.columnId === 'done' ? 'text-muted-foreground line-through' : '', ]" > {{ item.task.title }} </span> <NuxtLink :to="`/dashboard/kanban/${item.task.id}`" class="text-muted-foreground hover:text-foreground shrink-0 opacity-0 transition-opacity group-hover/row:opacity-100" @click.stop > <ExternalLink class="size-3" /> </NuxtLink> </div> <div v-if="item.task.tags.length || item.task.subtaskIds.length" class="mt-0.5 flex items-center gap-1.5"> <TagBadge v-for="tag in item.task.tags" :key="tag.label" :label="tag.label" :color="tag.color" class="!px-1.5 !py-0 !text-[9px]" /> <SubtaskProgress v-if="item.task.subtaskIds.length" :done="subtasksDone(item.task)" :total="item.task.subtaskIds.length" class="ml-1" /> </div> </div> <div> <Select :model-value="item.columnId" @update:model-value="(val) => $emit('move-task', item.task, String(val))" > <SelectTrigger class="hover:bg-muted h-6 w-auto gap-1 rounded-md border-none bg-transparent px-1.5 text-[11px] font-medium shadow-none" @click.stop > <span class="flex items-center gap-1.5"> <span :class="['size-1.5 rounded-full', item.dotColor]" /> <SelectValue /> </span> </SelectTrigger> <SelectContent> <SelectItem v-for="col in allColumns" :key="col.id" :value="col.id"> <span class="flex items-center gap-1.5"> <span :class="['size-1.5 rounded-full', col.dotColor]" /> {{ col.title }} </span> </SelectItem> </SelectContent> </Select> </div> <PriorityBadge :priority="item.task.priority" /> <div class="flex items-center gap-2"> <UserAvatar :name="item.task.assignee.name" :color="item.task.assignee.color" size="xs" /> <span class="truncate text-[12px]">{{ item.task.assignee.name }}</span> </div> <div> <DueDateBadge v-if="item.task.dueDate" :due-date="item.task.dueDate" variant="chip" /> <span v-else class="text-muted-foreground/50 text-[11px]">—</span> </div> <div class="flex items-center justify-center gap-2"> <TooltipProvider :delay-duration="200"> <Tooltip v-if="item.task.commentItems.length"> <TooltipTrigger as-child> <span class="text-muted-foreground/70 flex items-center gap-0.5 text-[11px]"> <MessageSquare class="size-3" /> {{ item.task.commentItems.length }} </span> </TooltipTrigger> <TooltipContent class="text-xs">{{ item.task.commentItems.length }} comments</TooltipContent> </Tooltip> <Tooltip v-if="item.task.fileItems.length"> <TooltipTrigger as-child> <span class="text-muted-foreground/70 flex items-center gap-0.5 text-[11px]"> <Paperclip class="size-3" /> {{ item.task.fileItems.length }} </span> </TooltipTrigger> <TooltipContent class="text-xs">{{ item.task.fileItems.length }} files</TooltipContent> </Tooltip> </TooltipProvider> </div> </div> </template> </template> </template> <div v-if="flatTasks.length === 0" class="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> </template> <style scoped> .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> -
app/components/blocks/kanban-board/KanbanTaskSheet.vue 7.9 kB
<script setup lang="ts"> import { computed } from 'vue' import { type KanbanTask, type KanbanColumn, priorityConfig, fileIconMap } from '@/composables/useKanban' import PriorityBadge from './PriorityBadge.vue' import TagBadge from './TagBadge.vue' import DueDateBadge from './DueDateBadge.vue' import UserAvatar from './UserAvatar.vue' import CommentList from './CommentList.vue' import SubtaskList from './SubtaskList.vue' import { Sheet, SheetContent, SheetTitle, SheetDescription, SheetFooter, SheetClose } from '@/components/ui/sheet' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Button } from '@/components/ui/button' import { Clock, Download, ExternalLink } from 'lucide-vue-next' const props = defineProps<{ open: boolean task: KanbanTask | null columns: KanbanColumn[] }>() defineEmits<{ 'update:open': [value: boolean] 'move-task': [task: KanbanTask, columnId: string] 'add-comment': [task: KanbanTask, text: string] }>() const columnIdForTask = computed(() => { if (!props.task) return '' return props.columns.find((c) => c.tasks.some((t) => t.id === props.task!.id))?.id ?? '' }) </script> <template> <Sheet :open="open" @update:open="$emit('update:open', $event)"> <SheetContent class="flex flex-col gap-0 overflow-hidden p-0 sm:max-w-[420px]"> <template v-if="task"> <div :class="['h-1 w-full shrink-0', priorityConfig[task.priority]?.bg]" /> <div class="shrink-0 px-5 pt-4 pb-3"> <div class="mb-3 flex items-center gap-2"> <span class="text-muted-foreground font-mono text-[11px] tracking-tight">{{ task.id }}</span> <span class="text-muted-foreground/30">·</span> <Select :model-value="columnIdForTask" @update:model-value="(val) => task && $emit('move-task', task, String(val))" > <SelectTrigger class="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> <SelectItem v-for="col in columns" :key="col.id" :value="col.id"> <span class="flex items-center gap-1.5"> <span :class="['size-1.5 rounded-full', col.dotColor]" /> {{ col.title }} </span> </SelectItem> </SelectContent> </Select> <PriorityBadge :priority="task.priority" icon-size="size-3" class="ml-auto" /> </div> <SheetTitle class="text-[15px] leading-snug font-semibold tracking-tight"> {{ task.title }} </SheetTitle> <SheetDescription class="sr-only">Task details</SheetDescription> <div v-if="task.description" class="text-muted-foreground rich-text-content prose prose-sm dark:prose-invert mt-1.5 max-w-none text-[13px] leading-relaxed" v-html="task.description" /> <p v-else class="text-muted-foreground mt-1.5 text-[13px] leading-relaxed">No description provided.</p> <div class="mt-3 flex flex-wrap gap-1.5"> <template v-if="task.tags.length"> <TagBadge v-for="tag in task.tags" :key="tag.label" :label="tag.label" :color="tag.color" /> </template> <span v-else class="text-muted-foreground text-[11px]">No tags</span> </div> </div> <div class="bg-border mx-5 h-px" /> <div class="flex-1 overflow-y-auto"> <div class="space-y-4 px-5 py-3"> <div class="flex items-center gap-3"> <UserAvatar :name="task.assignee.name" :color="task.assignee.color" size="md" /> <div> <p class="text-[13px] leading-tight font-medium">{{ task.assignee.name }}</p> <p class="text-muted-foreground text-[11px]">Assignee</p> </div> <div class="ml-auto text-right"> <DueDateBadge v-if="task.dueDate" :due-date="task.dueDate" /> <p v-else class="text-muted-foreground flex items-center gap-1 text-[13px] leading-tight"> <Clock class="size-3" /> No due date </p> </div> </div> <div v-if="task.parentId" class="flex items-center gap-2"> <span class="text-muted-foreground text-[11px]">Parent:</span> <NuxtLink :to="`/dashboard/kanban/${task.parentId}`" class="text-primary text-[12px] font-medium hover:underline" @click="$emit('update:open', false)" > {{ task.parentId }} </NuxtLink> </div> <div> <h4 class="mb-2 text-[13px] font-semibold"> Subtasks <span v-if="task.subtaskIds.length" class="text-muted-foreground font-normal"> ({{ task.subtaskIds.length }}) </span> </h4> <SubtaskList :subtask-ids="task.subtaskIds" :columns="columns" compact /> </div> <div class="bg-border h-px" /> <div> <h4 class="mb-2 text-[13px] font-semibold"> Comments <span v-if="task.commentItems.length" class="text-muted-foreground font-normal"> ({{ task.commentItems.length }}) </span> </h4> <CommentList :comments="task.commentItems" compact @add="(text) => task && $emit('add-comment', task, text)" /> </div> <div class="bg-border h-px" /> <div> <h4 class="mb-2 text-[13px] font-semibold"> Files <span v-if="task.fileItems.length" class="text-muted-foreground font-normal"> ({{ task.fileItems.length }}) </span> </h4> <div v-if="task.fileItems.length" class="space-y-1"> <div v-for="file in task.fileItems" :key="file.id" class="hover:bg-muted/50 group/file flex items-center gap-2.5 rounded-md px-1.5 py-1.5 transition-colors" > <div class="bg-muted flex size-8 shrink-0 items-center justify-center rounded-md"> <component :is="fileIconMap[file.type]" class="text-muted-foreground size-4" /> </div> <div class="min-w-0 flex-1"> <p class="truncate text-[12px] font-medium">{{ file.name }}</p> <p class="text-muted-foreground text-[10px]">{{ file.size }}</p> </div> <Button variant="ghost" size="icon" class="size-7 shrink-0 opacity-0 transition-opacity group-hover/file:opacity-100" > <Download class="size-3.5" /> </Button> </div> </div> <p v-else class="text-muted-foreground text-[12px]">No files attached.</p> </div> </div> </div> <SheetFooter class="shrink-0 border-t px-5 py-3"> <div class="flex w-full items-center gap-2"> <NuxtLink :to="`/dashboard/kanban/${task.id}`" class="flex-1" @click="$emit('update:open', false)"> <Button variant="outline" size="sm" class="w-full gap-1.5"> <ExternalLink class="size-3.5" /> View full detail </Button> </NuxtLink> <SheetClose as-child> <Button variant="ghost" size="sm">Close</Button> </SheetClose> </div> </SheetFooter> </template> </SheetContent> </Sheet> </template> -
app/components/blocks/kanban-board/KanbanAddTaskDialog.vue 8.9 kB
<script setup lang="ts"> import { ref, watch } from 'vue' import type { KanbanTask, KanbanColumn } from '@/composables/useKanban' import { priorityConfig, assignees, tagPresets } from '@/composables/useKanban' import { Dialog, DialogScrollContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from '@/components/ui/dialog' 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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Calendar } from '@/components/ui/calendar' import { RichTextEditor } from '@/components/ui/rich-text-editor' import { Plus, X, CheckCircle2, CalendarIcon } from 'lucide-vue-next' import { getLocalTimeZone, DateFormatter, type DateValue } from '@internationalized/date' const props = defineProps<{ open: boolean columns: KanbanColumn[] initialColumnId: string }>() const emit = defineEmits<{ 'update:open': [value: boolean] create: [columnId: string, tasks: KanbanTask[]] }>() const df = new DateFormatter('en-US', { dateStyle: 'medium' }) const columnId = ref(props.initialColumnId) const dueDate = ref<DateValue | undefined>(undefined) const form = ref({ title: '', description: '', priority: 'medium' as KanbanTask['priority'], assigneeKey: 'alice' as keyof typeof assignees, tagKeys: [] as (keyof typeof tagPresets)[], subtaskTexts: [] as string[], }) const newSubtaskText = ref('') watch( () => props.initialColumnId, (val) => { columnId.value = val }, ) watch( () => props.open, (val) => { if (val) { columnId.value = props.initialColumnId form.value = { title: '', description: '', priority: 'medium', assigneeKey: 'alice', tagKeys: [], subtaskTexts: [], } dueDate.value = undefined newSubtaskText.value = '' } }, ) function addSubtask() { const text = newSubtaskText.value.trim() if (!text) return form.value.subtaskTexts.push(text) newSubtaskText.value = '' } function removeSubtask(index: number) { form.value.subtaskTexts.splice(index, 1) } function toggleTag(key: keyof typeof tagPresets) { const idx = form.value.tagKeys.indexOf(key) if (idx >= 0) form.value.tagKeys.splice(idx, 1) else form.value.tagKeys.push(key) } function submit() { if (!form.value.title.trim()) return const maxId = props.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 = props.columns.flatMap((c) => c.tasks)[0]?.id.split('-')[0] ?? 'TASK' const parentId = `${idPrefix}-${maxId + 1}` const subtaskTasks: KanbanTask[] = form.value.subtaskTexts.map((text, i) => ({ id: `${idPrefix}-${maxId + 2 + i}`, title: text, priority: 'medium' as const, assignee: assignees[form.value.assigneeKey], tags: [], parentId, subtaskIds: [], commentItems: [], fileItems: [], })) const newTask: KanbanTask = { id: parentId, title: form.value.title.trim(), description: form.value.description.trim() || undefined, priority: form.value.priority, assignee: assignees[form.value.assigneeKey], tags: form.value.tagKeys.map((k) => tagPresets[k]), dueDate: dueDate.value ? dueDate.value.toString() : undefined, subtaskIds: subtaskTasks.map((t) => t.id), commentItems: [], fileItems: [], } emit('create', columnId.value, [newTask, ...subtaskTasks]) emit('update:open', false) } </script> <template> <Dialog :open="open" @update:open="$emit('update:open', $event)"> <DialogScrollContent class="sm:max-w-[680px]"> <DialogHeader> <DialogTitle>New Task</DialogTitle> <DialogDescription> Adding task to {{ columns.find((c) => c.id === columnId)?.title ?? 'column' }} </DialogDescription> </DialogHeader> <div class="grid gap-4 py-2"> <div class="grid gap-1.5"> <Label for="task-title">Title</Label> <Input id="task-title" v-model="form.title" placeholder="Enter task title" /> </div> <div class="grid gap-1.5"> <Label> Description <span class="text-muted-foreground text-xs">(optional)</span> </Label> <RichTextEditor v-model="form.description" /> </div> <div class="grid grid-cols-3 gap-4"> <div class="grid gap-1.5"> <Label for="task-priority">Priority</Label> <Select v-model="form.priority"> <SelectTrigger id="task-priority"> <SelectValue placeholder="Priority" /> </SelectTrigger> <SelectContent> <SelectItem v-for="(config, key) in priorityConfig" :key="key" :value="key"> {{ config.label }} </SelectItem> </SelectContent> </Select> </div> <div class="grid gap-1.5"> <Label for="task-assignee">Assignee</Label> <Select v-model="form.assigneeKey"> <SelectTrigger id="task-assignee"> <SelectValue placeholder="Assignee" /> </SelectTrigger> <SelectContent> <SelectItem v-for="(person, key) in assignees" :key="key" :value="key"> {{ person.name }} </SelectItem> </SelectContent> </Select> </div> <div class="grid gap-1.5"> <Label for="task-column">Column</Label> <Select v-model="columnId"> <SelectTrigger id="task-column"> <SelectValue placeholder="Column" /> </SelectTrigger> <SelectContent> <SelectItem v-for="col in columns" :key="col.id" :value="col.id"> {{ col.title }} </SelectItem> </SelectContent> </Select> </div> </div> <div class="grid gap-1.5"> <Label> Due Date <span class="text-muted-foreground text-xs">(optional)</span> </Label> <Popover> <PopoverTrigger as-child> <Button variant="outline" :class="['w-full justify-start text-left font-normal', !dueDate && 'text-muted-foreground']" > <CalendarIcon class="mr-2 size-4" /> {{ dueDate ? df.format(dueDate.toDate(getLocalTimeZone())) : 'Pick a date' }} </Button> </PopoverTrigger> <PopoverContent class="w-auto p-0"> <Calendar v-model="dueDate" initial-focus /> </PopoverContent> </Popover> </div> <div class="grid gap-1.5"> <Label> Tags <span class="text-muted-foreground text-xs">(optional)</span> </Label> <div class="flex flex-wrap gap-2"> <button v-for="(tag, key) in tagPresets" :key="key" type="button" :class="[ 'inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium transition-colors', form.tagKeys.includes(key as keyof typeof tagPresets) ? 'bg-primary text-primary-foreground border-transparent' : 'border-border bg-background text-foreground hover:bg-muted', ]" @click="toggleTag(key as keyof typeof tagPresets)" > <CheckCircle2 v-if="form.tagKeys.includes(key as keyof typeof tagPresets)" class="size-3" /> {{ tag.label }} </button> </div> </div> <div class="grid gap-1.5"> <Label> Subtasks <span class="text-muted-foreground text-xs">(optional)</span> </Label> <div v-if="form.subtaskTexts.length" class="space-y-2"> <div v-for="(text, index) in form.subtaskTexts" :key="index" class="flex items-center gap-2"> <span class="flex-1 text-sm">{{ text }}</span> <Button variant="ghost" size="icon" class="size-6" @click="removeSubtask(index)"> <X class="size-3" /> </Button> </div> </div> <div class="flex items-center gap-2"> <Input v-model="newSubtaskText" placeholder="Add a subtask" @keyup.enter="addSubtask" /> <Button variant="outline" size="icon" @click="addSubtask"> <Plus class="size-4" /> </Button> </div> </div> </div> <DialogFooter> <Button variant="outline" @click="$emit('update:open', false)">Cancel</Button> <Button :disabled="!form.title.trim()" @click="submit">Create Task</Button> </DialogFooter> </DialogScrollContent> </Dialog> </template> -
app/components/blocks/kanban-board/KanbanToolbar.vue 5.2 kB
<script setup lang="ts"> import { Search, Filter, ChevronDown, X, LayoutGrid, List } from 'lucide-vue-next' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' 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' import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { priorityConfig, assignees, getInitials } from '@/composables/useKanban' defineProps<{ searchQuery: string selectedPriority: string | null selectedAssignee: string | null viewMode: 'board' | 'list' }>() defineEmits<{ 'update:searchQuery': [value: string] 'update:selectedPriority': [value: string | null] 'update:selectedAssignee': [value: string | null] 'update:viewMode': [value: 'board' | 'list'] }>() </script> <template> <div class="mb-3 flex shrink-0 items-center gap-2"> <div class="relative w-56"> <Search class="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2" /> <Input :model-value="searchQuery" placeholder="Search tasks..." class="h-8 pl-8 text-sm" @update:model-value="$emit('update:searchQuery', $event)" /> </div> <DropdownMenu> <DropdownMenuTrigger as-child> <Button variant="outline" size="sm" class="h-8 gap-1.5 text-xs"> <Filter class="size-3" /> Priority <Badge v-if="selectedPriority" variant="default" class="ml-0.5 h-4 min-w-4 justify-center rounded px-1 text-[10px]" > 1 </Badge> <ChevronDown class="text-muted-foreground size-3" /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="start" class="w-40"> <DropdownMenuItem v-for="(config, key) in priorityConfig" :key="key" class="gap-2" @click="$emit('update:selectedPriority', selectedPriority === key ? null : (key as string))" > <component :is="config.icon" :class="['size-3.5', config.class]" /> {{ config.label }} <span v-if="selectedPriority === key" class="bg-primary ml-auto size-1.5 rounded-full" /> </DropdownMenuItem> <DropdownMenuSeparator v-if="selectedPriority" /> <DropdownMenuItem v-if="selectedPriority" @click="$emit('update:selectedPriority', null)"> Clear filter </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> <Badge v-if="selectedAssignee" variant="secondary" class="h-7 cursor-pointer gap-1 pr-1.5 text-xs" @click="$emit('update:selectedAssignee', null)" > {{ selectedAssignee }} <X class="size-3 opacity-50" /> </Badge> <ToggleGroup type="single" size="sm" :model-value="viewMode" class="bg-muted ml-auto flex items-center gap-0.5 rounded-md p-0.5" @update:model-value="(v) => v && $emit('update:viewMode', v as 'board' | 'list')" > <ToggleGroupItem value="board" class="data-[state=on]:bg-background size-7 rounded-sm p-0 data-[state=on]:shadow-sm" title="Board view" > <LayoutGrid class="size-3.5" /> </ToggleGroupItem> <ToggleGroupItem value="list" class="data-[state=on]:bg-background size-7 rounded-sm p-0 data-[state=on]:shadow-sm" title="List view" > <List class="size-3.5" /> </ToggleGroupItem> </ToggleGroup> <div class="flex items-center"> <TooltipProvider :delay-duration="300"> <Tooltip v-for="(a, key) in assignees" :key="key"> <TooltipTrigger as-child> <button :class="[ // 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', ]" @click="$emit('update:selectedAssignee', selectedAssignee === a.name ? null : a.name)" > <Avatar class="border-background size-7 border-2"> <AvatarFallback :class="['text-[10px] font-semibold', a.color]"> {{ getInitials(a.name) }} </AvatarFallback> </Avatar> </button> </TooltipTrigger> <TooltipContent side="bottom" class="text-xs"> {{ a.name }} <span v-if="selectedAssignee === a.name" class="text-muted-foreground ml-1">(filtered)</span> </TooltipContent> </Tooltip> </TooltipProvider> </div> </div> </template> -
app/components/blocks/kanban-board/PriorityBadge.vue 0.6 kB
<script setup lang="ts"> import { computed } from 'vue' import { priorityConfig } from '@/composables/useKanban' import { cn } from '@/lib/utils' const props = withDefaults( defineProps<{ priority: string iconSize?: string class?: string }>(), { iconSize: 'size-3.5', }, ) const config = computed(() => priorityConfig[props.priority]) </script> <template> <div v-if="config" :class="cn('flex items-center gap-1', props.class)"> <component :is="config.icon" :class="[iconSize, config.class]" /> <span :class="['text-[11px] font-semibold', config.class]">{{ config.label }}</span> </div> </template> -
app/components/blocks/kanban-board/DueDateBadge.vue 1.3 kB
<script setup lang="ts"> import { computed } from 'vue' import { Clock } from 'lucide-vue-next' import { getDueStatus, formatDueDate } from '@/composables/useKanban' import { cn } from '@/lib/utils' const props = defineProps<{ dueDate: string variant?: 'chip' | 'inline' class?: string }>() const status = computed(() => getDueStatus(props.dueDate)) const formatted = computed(() => formatDueDate(props.dueDate)) const chipClasses = computed(() => { switch (status.value) { case 'overdue': return 'bg-destructive/10 text-destructive' case 'soon': return 'bg-warning/10 text-warning' default: return 'text-muted-foreground bg-muted' } }) const inlineClasses = computed(() => { switch (status.value) { case 'overdue': return 'text-destructive' case 'soon': return 'text-warning' default: return 'text-foreground' } }) </script> <template> <div v-if="variant === 'chip'" :class="cn('flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-medium', chipClasses, props.class)" > <Clock class="size-3" /> {{ formatted }} </div> <p v-else :class="cn('flex items-center gap-1 text-[13px] leading-tight font-medium', inlineClasses, props.class)"> <Clock class="size-3" /> {{ formatted }} </p> </template> -
app/components/blocks/kanban-board/TagBadge.vue 0.3 kB
<script setup lang="ts"> import { cn } from '@/lib/utils' defineProps<{ label: string color: string class?: string }>() </script> <template> <span :class="cn('rounded-md px-1.5 py-0.5 text-[11px] font-medium ring-1 ring-inset', color, $props.class)"> {{ label }} </span> </template> -
app/components/blocks/kanban-board/UserAvatar.vue 0.7 kB
<script setup lang="ts"> import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { getInitials } from '@/composables/useKanban' import { cn } from '@/lib/utils' defineProps<{ name: string color: string size?: 'xs' | 'sm' | 'md' class?: string }>() const sizeMap = { xs: { avatar: 'size-6', text: 'text-[8px]' }, sm: { avatar: 'size-7', text: 'text-[9px]' }, md: { avatar: 'size-8', text: 'text-[11px]' }, } </script> <template> <Avatar :class="cn(sizeMap[size ?? 'sm'].avatar, 'shrink-0', $props.class)"> <AvatarFallback :class="[sizeMap[size ?? 'sm'].text, 'font-bold', color]"> {{ getInitials(name) }} </AvatarFallback> </Avatar> </template> -
app/components/blocks/kanban-board/SubtaskProgress.vue 1 kB
<script setup lang="ts"> import { computed } from 'vue' import { CheckCircle2 } from 'lucide-vue-next' const props = defineProps<{ done: number total: number barHeight?: string }>() const percent = computed(() => (props.total > 0 ? Math.round((props.done / props.total) * 100) : 0)) const isComplete = computed(() => props.done === props.total && props.total > 0) </script> <template> <div v-if="total > 0"> <div class="mb-1 flex items-center justify-between"> <span class="text-muted-foreground text-[11px]"> <CheckCircle2 v-if="isComplete" class="text-success mr-0.5 inline size-3" /> {{ done }}/{{ total }} subtasks </span> <span class="text-muted-foreground text-[11px] font-medium tabular-nums">{{ percent }}%</span> </div> <div :class="['bg-muted overflow-hidden rounded-full', barHeight ?? 'h-1.5']"> <div :class="['h-full rounded-full transition-all duration-500', isComplete ? 'bg-success' : 'bg-primary']" :style="{ width: `${percent}%` }" /> </div> </div> </template> -
app/components/blocks/kanban-board/SubtaskList.vue 2.9 kB
<script setup lang="ts"> import { computed } from 'vue' import type { KanbanColumn } from '@/composables/useKanban' import { findTaskById, getTaskColumn } from '@/composables/useKanban' const props = defineProps<{ subtaskIds: string[] columns: KanbanColumn[] compact?: boolean }>() const subtasks = computed(() => { return props.subtaskIds .map((id) => { const task = findTaskById(props.columns, id) const column = getTaskColumn(props.columns, id) return task ? { task, column } : null }) .filter(Boolean) as { task: NonNullable<ReturnType<typeof findTaskById>> column: ReturnType<typeof getTaskColumn> }[] }) const doneCount = computed(() => subtasks.value.filter((s) => s.column?.id === 'done').length) </script> <template> <div v-if="subtasks.length"> <div class="mb-2 flex items-center justify-between"> <span :class="compact ? 'text-[11px]' : 'text-[13px]'" class="text-muted-foreground tabular-nums"> {{ doneCount }}/{{ subtasks.length }} done </span> <span :class="compact ? 'text-[10px]' : 'text-[11px]'" class="text-muted-foreground tabular-nums"> {{ subtasks.length > 0 ? Math.round((doneCount / subtasks.length) * 100) : 0 }}% </span> </div> <div class="bg-muted mb-3 h-1.5 overflow-hidden rounded-full"> <div :class="[ 'h-full rounded-full transition-all duration-500', doneCount === subtasks.length ? 'bg-success' : 'bg-primary', ]" :style="{ width: `${subtasks.length > 0 ? Math.round((doneCount / subtasks.length) * 100) : 0}%` }" /> </div> <div :class="compact ? 'space-y-0.5' : 'space-y-1'"> <NuxtLink v-for="{ task, column } in subtasks" :key="task.id" :to="`/dashboard/kanban/${task.id}`" :class="[ '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 :class="['size-1.5 shrink-0 rounded-full', column?.dotColor ?? 'bg-muted-foreground']" /> <span :class="['shrink-0 font-mono', compact ? 'text-[10px]' : 'text-[11px]', 'text-muted-foreground/70']"> {{ task.id }} </span> <span :class="[ '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 :class="[ '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> </NuxtLink> </div> </div> <p v-else :class="compact ? 'text-muted-foreground text-[12px]' : 'text-muted-foreground text-sm'"> No subtasks yet. </p> </template> -
app/components/blocks/kanban-board/CommentList.vue 2.7 kB
<script setup lang="ts"> import { ref } from 'vue' import { Send } from 'lucide-vue-next' import { Button } from '@/components/ui/button' import { RichTextEditor } from '@/components/ui/rich-text-editor' import UserAvatar from './UserAvatar.vue' import type { CommentItem } from '@/composables/useKanban' withDefaults( defineProps<{ comments: CommentItem[] compact?: boolean }>(), { compact: false, }, ) const emit = defineEmits<{ add: [text: string] }>() const newComment = ref('') function submit() { const stripped = newComment.value.replace(/<[^>]*>/g, '').trim() if (!stripped) return emit('add', newComment.value) newComment.value = '' } </script> <template> <div v-if="comments.length" :class="compact ? 'space-y-3' : 'space-y-4'"> <div v-for="comment in comments" :key="comment.id" :class="compact ? 'flex gap-2.5' : 'flex gap-3'"> <UserAvatar :name="comment.author" :color="comment.authorColor" :size="compact ? 'xs' : 'sm'" /> <div class="min-w-0 flex-1"> <div class="flex items-baseline gap-2"> <span :class="compact ? 'text-[12px] font-semibold' : 'text-[13px] font-semibold'"> {{ compact ? comment.author.split(' ')[0] : comment.author }} </span> <span :class="compact ? 'text-muted-foreground text-[10px]' : 'text-muted-foreground text-[11px]'"> {{ comment.time }} </span> </div> <div :class="[ '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', ]" v-html="comment.text" /> </div> </div> </div> <p v-else :class="compact ? 'text-muted-foreground text-[12px]' : 'text-muted-foreground text-sm'"> No comments yet. </p> <div :class="compact ? 'mt-3 flex gap-2' : 'mt-4 flex gap-3 border-t pt-4'"> <div :class="compact ? 'mt-1' : 'mt-1.5'"> <UserAvatar name="Admin User" color="bg-chart-1/15 text-chart-1" :size="compact ? 'xs' : 'sm'" /> </div> <div class="min-w-0 flex-1 space-y-2"> <RichTextEditor v-model:model-value="newComment" placeholder="Write a comment..." :min-height="compact ? '60px' : '80px'" :class="compact ? 'text-[12px]' : 'text-[13px]'" /> <div class="flex justify-end"> <Button size="sm" :class="compact ? 'h-7 gap-1 text-[11px]' : 'h-8 gap-1.5 text-xs'" :disabled="!newComment.replace(/<[^>]*>/g, '').trim()" @click="submit" > <Send class="size-3" /> Comment </Button> </div> </div> </div> </template>
Raw manifest: https://uipkge.dev/r/vue/kanban-board.json