UIPackage

Kanban Board

block dashboard
Edit on GitHub

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

Also available for React ->

Installation

$ npx 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

Live demo coming soon -- source below is exact.

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