UIPackage
Menu

Framework

Change language

Boilerplate repo

Gantt

gantt ui

Interactive Gantt chart primitive with multi-scale timeline (day/week/month/year), collapsible task tree, progress fill, milestone markers, dependency lines, and right-click GanttContextMenu.

Also available for React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/gantt.json
Named registry: npx shadcn-vue@latest add @uipkge/gantt Installs to: app/components/ui/gantt/

Examples

Loading interactive previews…

Props

Name Type / Values Default Required
tasks GanttTask[] required
scale GanttScale 'day' optional
startDate string optional
endDate string optional
rowHeight number 40 optional
headerHeight number 48 optional
treeWidth number 280 optional
class HTMLAttributes['class'] optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

GanttDependency
interface GanttDependency {
  fromId: string
  toId: string
  type?: 'finish-to-start' | 'start-to-start' | 'finish-to-finish'
}
GanttAssignee
interface GanttAssignee {
  name: string
  avatar?: string
  initials?: string
  role?: string
}
GanttTask
interface GanttTask {
  id: string
  name: string
  startDate: string // YYYY-MM-DD
  endDate: string // YYYY-MM-DD
  progress?: number // 0 to 100
  color?: string
  status?: GanttTaskStatus
  priority?: GanttTaskPriority
  assignee?: GanttAssignee
  isMilestone?: boolean
  isGroup?: boolean
  parentId?: string | null
  isExpanded?: boolean
  dependencies?: string[] // task IDs
  children?: GanttTask[]
}
GanttColumn
interface GanttColumn {
  key: string
  label: string
  width?: string
}

npm dependencies

Used by

Files installed (9)

  • app/components/ui/gantt/Gantt.vue 2.6 kB
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue'
    import { computed, provide, ref, toRef } from 'vue'
    import { cn } from '@/lib/utils'
    import type { GanttScale, GanttTask } from './types'
    
    interface Props {
      tasks: GanttTask[]
      scale?: GanttScale
      startDate?: string
      endDate?: string
      rowHeight?: number
      headerHeight?: number
      treeWidth?: number
      class?: HTMLAttributes['class']
    }
    
    const props = withDefaults(defineProps<Props>(), {
      scale: 'day',
      rowHeight: 40,
      headerHeight: 48,
      treeWidth: 280,
    })
    
    const emits = defineEmits<{
      (e: 'update:scale', scale: GanttScale): void
      (e: 'task-click', task: GanttTask): void
      (e: 'task-change', task: GanttTask): void
    }>()
    
    const currentScale = ref<GanttScale>(props.scale)
    
    // Compute start and end dates from tasks if not explicitly provided
    const resolvedStartDate = computed(() => {
      if (props.startDate) return new Date(props.startDate)
      if (props.tasks.length === 0) return new Date()
      const dates = props.tasks.map((t) => new Date(t.startDate).getTime())
      const min = Math.min(...dates)
      const d = new Date(min)
      d.setDate(d.getDate() - 3) // padding
      return d
    })
    
    const resolvedEndDate = computed(() => {
      if (props.endDate) return new Date(props.endDate)
      if (props.tasks.length === 0) {
        const d = new Date()
        d.setDate(d.getDate() + 30)
        return d
      }
      const dates = props.tasks.map((t) => new Date(t.endDate).getTime())
      const max = Math.max(...dates)
      const d = new Date(max)
      d.setDate(d.getDate() + 7) // padding
      return d
    })
    
    const totalDays = computed(() => {
      const diff = resolvedEndDate.value.getTime() - resolvedStartDate.value.getTime()
      return Math.max(1, Math.ceil(diff / (1000 * 60 * 60 * 24)))
    })
    
    const columnWidth = computed(() => {
      switch (currentScale.value) {
        case 'day':
          return 44
        case 'week':
          return 120
        case 'month':
          return 180
        case 'year':
          return 240
        default:
          return 44
      }
    })
    
    provide('ganttContext', {
      scale: currentScale,
      startDate: resolvedStartDate,
      endDate: resolvedEndDate,
      totalDays,
      columnWidth,
      rowHeight: toRef(props, 'rowHeight'),
      headerHeight: toRef(props, 'headerHeight'),
      treeWidth: toRef(props, 'treeWidth'),
      tasks: toRef(props, 'tasks'),
      onTaskClick: (task: GanttTask) => emits('task-click', task),
      onTaskChange: (task: GanttTask) => emits('task-change', task),
    })
    </script>
    
    <template>
      <div
        data-uipkge
        data-slot="gantt"
        :class="
          cn(
            'border-border bg-card text-card-foreground relative flex w-full flex-col overflow-hidden rounded-xl border shadow-xs',
            props.class,
          )
        "
      >
        <slot />
      </div>
    </template>
  • app/components/ui/gantt/GanttHeader.vue 1.8 kB
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue'
    import { inject } from 'vue'
    import { Calendar, ChevronLeft, ChevronRight } from 'lucide-vue-next'
    import { cn } from '@/lib/utils'
    import { Button, ButtonGroup } from '@/components/ui/button'
    import type { GanttScale } from './types'
    
    interface Props {
      title?: string
      showScaleSwitcher?: boolean
      showNavigation?: boolean
      class?: HTMLAttributes['class']
    }
    
    const props = withDefaults(defineProps<Props>(), {
      title: 'Project Timeline',
      showScaleSwitcher: true,
      showNavigation: false,
    })
    
    const context = inject<any>('ganttContext')
    
    function setScale(scale: GanttScale) {
      if (context?.scale) {
        context.scale.value = scale
      }
    }
    </script>
    
    <template>
      <div
        data-uipkge
        data-slot="gantt-header"
        :class="cn('border-border bg-muted/30 flex items-center justify-between border-b px-4 py-2.5', props.class)"
      >
        <div class="flex items-center gap-2">
          <Calendar class="text-primary size-4" />
          <span class="text-foreground text-sm font-semibold">{{ title }}</span>
        </div>
    
        <div class="flex items-center gap-3">
          <slot name="actions" />
    
          <ButtonGroup v-if="showScaleSwitcher && context">
            <Button size="xs" :variant="context.scale.value === 'day' ? 'default' : 'outline'" @click="setScale('day')">
              Day
            </Button>
            <Button size="xs" :variant="context.scale.value === 'week' ? 'default' : 'outline'" @click="setScale('week')">
              Week
            </Button>
            <Button size="xs" :variant="context.scale.value === 'month' ? 'default' : 'outline'" @click="setScale('month')">
              Month
            </Button>
            <Button size="xs" :variant="context.scale.value === 'year' ? 'default' : 'outline'" @click="setScale('year')">
              Year
            </Button>
          </ButtonGroup>
        </div>
      </div>
    </template>
  • app/components/ui/gantt/GanttTree.vue 4 kB
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue'
    import { inject, ref } from 'vue'
    import { ChevronRight, ChevronDown, Flag, User } from 'lucide-vue-next'
    import { cn } from '@/lib/utils'
    import type { GanttTask } from './types'
    
    interface Props {
      showAssignee?: boolean
      showPriority?: boolean
      class?: HTMLAttributes['class']
    }
    
    const props = withDefaults(defineProps<Props>(), {
      showAssignee: true,
      showPriority: true,
    })
    
    const context = inject<any>('ganttContext')
    
    const priorityColors: Record<string, string> = {
      urgent: 'text-destructive',
      high: 'text-amber-500',
      medium: 'text-primary',
      low: 'text-muted-foreground/60',
    }
    
    function calculateDays(startDate: string, endDate: string) {
      const diff = new Date(endDate).getTime() - new Date(startDate).getTime()
      const days = Math.max(1, Math.ceil(diff / (1000 * 60 * 60 * 24)))
      return `${days}d`
    }
    </script>
    
    <template>
      <div
        data-uipkge
        data-slot="gantt-tree"
        :style="{ width: `${context?.treeWidth.value ?? 300}px` }"
        :class="cn('border-border bg-card flex shrink-0 flex-col border-r transition-[width] select-none', props.class)"
      >
        <!-- Column Headers -->
        <div
          :style="{ height: `${context?.headerHeight.value ?? 48}px` }"
          class="border-border bg-muted/20 text-muted-foreground flex items-center justify-between border-b px-3 text-[11px] font-semibold tracking-wider uppercase"
        >
          <span class="flex-1 truncate">Deliverable</span>
          <span v-if="showPriority" class="w-12 shrink-0 text-center">Pri</span>
          <span class="w-16 shrink-0 text-right">Duration</span>
        </div>
    
        <!-- Rows List -->
        <div class="divide-border/40 flex-1 divide-y overflow-y-auto">
          <div
            v-for="task in context?.tasks.value ?? []"
            :key="task.id"
            :style="{ height: `${context?.rowHeight.value ?? 40}px` }"
            :class="
              cn(
                'group/row text-foreground hover:bg-muted/40 flex cursor-pointer items-center justify-between px-3 text-xs transition-colors',
                task.isGroup && 'bg-muted/10 font-semibold',
              )
            "
            @click="context?.onTaskClick(task)"
          >
            <!-- Task Name & Chevron / Indent -->
            <div class="flex min-w-0 flex-1 items-center gap-1.5 pr-2">
              <button
                v-if="task.isGroup || (task.children && task.children.length > 0)"
                type="button"
                class="text-muted-foreground hover:text-foreground flex size-4 shrink-0 items-center justify-center rounded-xs p-0.5"
                @click.stop="context?.toggleExpand?.(task.id)"
              >
                <ChevronDown v-if="task.isExpanded !== false" class="size-3" />
                <ChevronRight v-else class="size-3" />
              </button>
              <span v-else-if="task.parentId" class="w-4 shrink-0" />
    
              <!-- Status Indicator Dot -->
              <span
                v-if="task.status"
                :class="[
                  'size-2 shrink-0 rounded-full',
                  task.status === 'done' && 'bg-emerald-500 ring-2 ring-emerald-500/20',
                  task.status === 'in-progress' && 'bg-primary ring-primary/20 ring-2',
                  task.status === 'at-risk' && 'bg-amber-500 ring-2 ring-amber-500/20',
                  task.status === 'todo' && 'bg-muted-foreground/40',
                  task.status === 'blocked' && 'bg-destructive ring-destructive/20 ring-2',
                ]"
              />
    
              <span class="truncate font-medium">{{ task.name }}</span>
            </div>
    
            <!-- Priority Flag -->
            <div v-if="showPriority" class="flex w-12 shrink-0 items-center justify-center">
              <Flag v-if="task.priority" :class="cn('size-3', priorityColors[task.priority])" />
            </div>
    
            <!-- Duration / Due Date Tag -->
            <div class="text-muted-foreground w-16 shrink-0 text-right font-mono text-[11px]">
              <span v-if="task.isMilestone" class="text-[10px] font-semibold text-amber-500"> Milestone </span>
              <span v-else>
                {{ calculateDays(task.startDate, task.endDate) }}
              </span>
            </div>
          </div>
        </div>
      </div>
    </template>
  • app/components/ui/gantt/GanttTimeline.vue 7.4 kB
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue'
    import { computed, inject, ref } from 'vue'
    import { cn } from '@/lib/utils'
    import GanttBar from './GanttBar.vue'
    import GanttMilestone from './GanttMilestone.vue'
    import type { GanttTask } from './types'
    
    interface Props {
      showTodayLine?: boolean
      showDependencies?: boolean
      class?: HTMLAttributes['class']
    }
    
    const props = withDefaults(defineProps<Props>(), {
      showTodayLine: true,
      showDependencies: true,
    })
    
    const context = inject<any>('ganttContext')
    
    // Generate header date columns
    const columns = computed(() => {
      if (!context) return []
      const list: { date: Date; label: string; subLabel: string; isWeekend: boolean }[] = []
      const start = new Date(context.startDate.value)
      const total = context.totalDays.value
    
      for (let i = 0; i < total; i++) {
        const d = new Date(start)
        d.setDate(d.getDate() + i)
        const dayOfWeek = d.getDay()
        const isWeekend = dayOfWeek === 0 || dayOfWeek === 6
    
        list.push({
          date: d,
          label: d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
          subLabel: d.toLocaleDateString(undefined, { weekday: 'narrow' }),
          isWeekend,
        })
      }
      return list
    })
    
    const timelineWidth = computed(() => {
      if (!context) return 800
      return columns.value.length * context.columnWidth.value
    })
    
    function getTaskCoordinates(task: GanttTask, index: number) {
      if (!context) return { left: 0, width: 100, top: 0, height: 28 }
      const start = new Date(context.startDate.value).getTime()
      const taskStart = new Date(task.startDate).getTime()
      const taskEnd = new Date(task.endDate).getTime()
      const oneDay = 1000 * 60 * 60 * 24
    
      const startDiffDays = Math.max(0, (taskStart - start) / oneDay)
      const durationDays = Math.max(1, (taskEnd - taskStart) / oneDay)
    
      const left = startDiffDays * context.columnWidth.value
      const width = durationDays * context.columnWidth.value
      const rowHeight = context.rowHeight.value
      const top = index * rowHeight + (rowHeight - 28) / 2
    
      return { left, width, top, height: 28 }
    }
    
    const todayPosition = computed(() => {
      if (!context) return null
      const start = new Date(context.startDate.value).getTime()
      const today = new Date().setHours(0, 0, 0, 0)
      const oneDay = 1000 * 60 * 60 * 24
      const diffDays = (today - start) / oneDay
    
      if (diffDays < 0 || diffDays > context.totalDays.value) return null
      return diffDays * context.columnWidth.value + context.columnWidth.value / 2
    })
    
    // Compute SVG Dependency curves between tasks
    const dependencyPaths = computed(() => {
      if (!context || !props.showDependencies) return []
      const tasksList: GanttTask[] = context.tasks.value ?? []
      const taskMap = new Map<string, { task: GanttTask; index: number }>()
      tasksList.forEach((t, i) => taskMap.set(t.id, { task: t, index: i }))
    
      const paths: { d: string; fromId: string; toId: string }[] = []
    
      tasksList.forEach((toTask, toIdx) => {
        if (!toTask.dependencies || toTask.dependencies.length === 0) return
        toTask.dependencies.forEach((fromId) => {
          const fromEntry = taskMap.get(fromId)
          if (!fromEntry) return
    
          const fromCoords = getTaskCoordinates(fromEntry.task, fromEntry.index)
          const toCoords = getTaskCoordinates(toTask, toIdx)
    
          const startX = fromEntry.task.isMilestone ? fromCoords.left : fromCoords.left + fromCoords.width
          const startY = fromCoords.top + 14
    
          const endX = toCoords.left
          const endY = toCoords.top + 14
    
          const deltaX = Math.max(16, (endX - startX) / 2)
          const d = `M ${startX} ${startY} C ${startX + deltaX} ${startY}, ${endX - deltaX} ${endY}, ${endX} ${endY}`
          paths.push({ d, fromId, toId: toTask.id })
        })
      })
    
      return paths
    })
    </script>
    
    <template>
      <div
        data-uipkge
        data-slot="gantt-timeline"
        :class="cn('bg-background relative flex-1 overflow-x-auto overflow-y-hidden select-none', props.class)"
      >
        <div :style="{ width: `${timelineWidth}px` }" class="relative">
          <!-- Header Dates -->
          <div
            :style="{ height: `${context?.headerHeight.value ?? 48}px` }"
            class="border-border bg-muted/10 sticky top-0 z-20 flex border-b"
          >
            <div
              v-for="(col, i) in columns"
              :key="i"
              :style="{ width: `${context?.columnWidth.value ?? 44}px` }"
              :class="
                cn(
                  'border-border/50 text-muted-foreground flex flex-col items-center justify-center border-r text-[10px]',
                  col.isWeekend && 'bg-muted/20 text-muted-foreground/60',
                )
              "
            >
              <span class="text-foreground font-medium">{{ col.label }}</span>
              <span class="text-[9px]">{{ col.subLabel }}</span>
            </div>
          </div>
    
          <!-- Grid Background & Task Bars -->
          <div class="relative">
            <!-- Column grid lines -->
            <div class="pointer-events-none absolute inset-0 flex">
              <div
                v-for="(col, i) in columns"
                :key="i"
                :style="{ width: `${context?.columnWidth.value ?? 44}px` }"
                :class="cn('border-border/30 h-full border-r', col.isWeekend && 'bg-muted/15')"
              />
            </div>
    
            <!-- Today Marker Line -->
            <div
              v-if="showTodayLine && todayPosition != null"
              :style="{ left: `${todayPosition}px` }"
              class="pointer-events-none absolute inset-y-0 z-30 flex flex-col items-center"
            >
              <div
                class="bg-destructive text-destructive-foreground rounded-full px-1.5 py-0.5 text-[9px] font-bold shadow-xs"
              >
                Today
              </div>
              <div class="bg-destructive/60 h-full w-[1.5px] border-r border-dashed" />
            </div>
    
            <!-- SVG Dependencies Overlay -->
            <svg
              v-if="dependencyPaths.length > 0"
              :width="timelineWidth"
              :height="(context?.tasks.value.length ?? 0) * (context?.rowHeight.value ?? 40)"
              class="pointer-events-none absolute inset-0 z-10"
            >
              <defs>
                <marker id="gantt-arrow" viewBox="0 0 6 6" refX="5" refY="3" markerWidth="6" markerHeight="6" orient="auto">
                  <path d="M 0 0 L 6 3 L 0 6 z" class="fill-primary/60" />
                </marker>
              </defs>
              <path
                v-for="(p, i) in dependencyPaths"
                :key="i"
                :d="p.d"
                fill="none"
                class="stroke-primary/50"
                stroke-width="1.5"
                stroke-dasharray="3,3"
                marker-end="url(#gantt-arrow)"
              />
            </svg>
    
            <!-- Task Rows & Bars -->
            <div
              v-for="(task, idx) in context?.tasks.value ?? []"
              :key="task.id"
              :style="{ height: `${context?.rowHeight.value ?? 40}px` }"
              class="border-border/40 hover:bg-muted/10 relative border-b transition-colors"
            >
              <template v-if="task.isMilestone">
                <GanttMilestone
                  :task="task"
                  :left="getTaskCoordinates(task, idx).left"
                  :top="(context?.rowHeight.value ?? 40) / 2"
                  @click="context?.onTaskClick(task)"
                />
              </template>
              <template v-else>
                <GanttBar
                  :task="task"
                  :left="getTaskCoordinates(task, idx).left"
                  :width="getTaskCoordinates(task, idx).width"
                  :top="(getTaskCoordinates(task, idx).height - 28) / 2 + 6"
                  :height="28"
                  @click="context?.onTaskClick(task)"
                />
              </template>
            </div>
          </div>
        </div>
      </div>
    </template>
  • app/components/ui/gantt/GanttBar.vue 3.3 kB
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue'
    import { computed } from 'vue'
    import { cn } from '@/lib/utils'
    import type { GanttTask } from './types'
    
    interface Props {
      task: GanttTask
      left: number
      width: number
      top: number
      height: number
      class?: HTMLAttributes['class']
    }
    
    const props = defineProps<Props>()
    
    const emits = defineEmits<{
      (e: 'click', task: GanttTask): void
    }>()
    
    const statusColors: Record<string, string> = {
      done: 'bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 border-emerald-500/40',
      'in-progress': 'bg-primary/20 text-primary border-primary/40',
      'at-risk': 'bg-amber-500/20 text-amber-700 dark:text-amber-300 border-amber-500/40',
      todo: 'bg-muted/80 text-muted-foreground border-border',
      blocked: 'bg-destructive/20 text-destructive border-destructive/40',
    }
    
    const progressColors: Record<string, string> = {
      done: 'bg-emerald-500/40',
      'in-progress': 'bg-primary/40',
      'at-risk': 'bg-amber-500/40',
      todo: 'bg-muted-foreground/20',
      blocked: 'bg-destructive/40',
    }
    </script>
    
    <template>
      <!-- Group Parent Task Bracket Bar -->
      <div
        v-if="task.isGroup"
        data-uipkge
        data-slot="gantt-group-bar"
        :style="{
          left: `${left}px`,
          width: `${Math.max(24, width)}px`,
          top: `${top + 4}px`,
          height: `${height - 8}px`,
        }"
        :class="
          cn(
            'group/bar bg-foreground/80 text-background hover:bg-foreground absolute z-10 flex cursor-pointer items-center justify-between rounded-xs px-2 text-xs font-semibold shadow-xs select-none',
            props.class,
          )
        "
        @click="$emit('click', task)"
      >
        <span class="truncate">{{ task.name }}</span>
        <span v-if="task.progress != null" class="font-mono text-[10px] opacity-80"> {{ task.progress }}% </span>
      </div>
    
      <!-- Standard Deliverable Bar -->
      <div
        v-else
        data-uipkge
        data-slot="gantt-bar"
        :style="{
          left: `${left}px`,
          width: `${Math.max(24, width)}px`,
          top: `${top}px`,
          height: `${height}px`,
        }"
        :class="
          cn(
            'group/bar absolute z-10 flex cursor-pointer items-center overflow-hidden rounded-md border text-xs font-medium shadow-xs transition-[box-shadow,transform] select-none hover:scale-[1.01] hover:shadow-md',
            task.color ? task.color : statusColors[task.status ?? 'in-progress'],
            props.class,
          )
        "
        @click="$emit('click', task)"
      >
        <!-- Progress fill -->
        <div
          v-if="task.progress != null && task.progress > 0"
          :style="{ width: `${task.progress}%` }"
          :class="cn('absolute inset-y-0 left-0 transition-all', progressColors[task.status ?? 'in-progress'])"
        />
    
        <!-- Content -->
        <div class="relative z-10 flex w-full min-w-0 items-center justify-between px-2">
          <span class="truncate font-medium">{{ task.name }}</span>
          <span v-if="task.progress != null" class="ml-1 shrink-0 font-mono text-[10px] opacity-80">
            {{ task.progress }}%
          </span>
        </div>
    
        <!-- Left / Right Resize Handles -->
        <div
          aria-hidden="true"
          class="bg-foreground/20 absolute inset-y-0 left-0 w-1.5 cursor-ew-resize opacity-0 transition-opacity group-hover/bar:opacity-100"
        />
        <div
          aria-hidden="true"
          class="bg-foreground/20 absolute inset-y-0 right-0 w-1.5 cursor-ew-resize opacity-0 transition-opacity group-hover/bar:opacity-100"
        />
      </div>
    </template>
  • app/components/ui/gantt/GanttMilestone.vue 0.9 kB
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue'
    import { cn } from '@/lib/utils'
    import type { GanttTask } from './types'
    
    interface Props {
      task: GanttTask
      left: number
      top: number
      size?: number
      class?: HTMLAttributes['class']
    }
    
    const props = withDefaults(defineProps<Props>(), {
      size: 16,
    })
    
    const emits = defineEmits<{
      (e: 'click', task: GanttTask): void
    }>()
    </script>
    
    <template>
      <div
        data-uipkge
        data-slot="gantt-milestone"
        :style="{
          left: `${left - size / 2}px`,
          top: `${top - size / 2}px`,
          width: `${size}px`,
          height: `${size}px`,
        }"
        :class="
          cn(
            'border-primary bg-primary absolute z-20 rotate-45 cursor-pointer rounded-xs border-2 shadow-sm transition-transform hover:scale-125',
            props.class,
          )
        "
        :title="`${task.name} (${task.startDate})`"
        @click="$emit('click', task)"
      />
    </template>
  • app/components/ui/gantt/GanttContextMenu.vue 5.4 kB
    <script setup lang="ts">
    import {
      ContextMenu,
      ContextMenuContent,
      ContextMenuItem,
      ContextMenuLabel,
      ContextMenuRadioGroup,
      ContextMenuRadioItem,
      ContextMenuSeparator,
      ContextMenuShortcut,
      ContextMenuSub,
      ContextMenuSubContent,
      ContextMenuSubTrigger,
      ContextMenuTrigger,
    } from '@/components/ui/context-menu'
    import { Edit2, Copy, Trash2, Clock, CheckCircle2, AlertCircle, Flag, Calendar, Layers } from 'lucide-vue-next'
    import type { GanttTask, GanttTaskStatus, GanttTaskPriority } from './types'
    
    const props = defineProps<{
      task: GanttTask
    }>()
    
    const emits = defineEmits<{
      (e: 'edit', task: GanttTask): void
      (e: 'status-change', task: GanttTask, status: GanttTaskStatus): void
      (e: 'priority-change', task: GanttTask, priority: GanttTaskPriority): void
      (e: 'duplicate', task: GanttTask): void
      (e: 'delete', task: GanttTask): void
    }>()
    
    function copyTaskId() {
      if (typeof navigator !== 'undefined' && navigator.clipboard) {
        navigator.clipboard.writeText(props.task.id)
      }
    }
    </script>
    
    <template>
      <ContextMenu>
        <ContextMenuTrigger as-child>
          <slot />
        </ContextMenuTrigger>
        <ContextMenuContent class="w-56">
          <ContextMenuLabel class="flex items-center justify-between text-xs">
            <span class="truncate font-semibold">{{ task.name }}</span>
            <span class="text-muted-foreground font-mono text-[10px]">{{ task.id }}</span>
          </ContextMenuLabel>
          <ContextMenuSeparator />
    
          <!-- Edit / Inspect -->
          <ContextMenuItem @select="$emit('edit', task)">
            <Edit2 class="mr-2 size-3.5" />
            <span>View Details</span>
            <ContextMenuShortcut></ContextMenuShortcut>
          </ContextMenuItem>
    
          <!-- Status Submenu -->
          <ContextMenuSub>
            <ContextMenuSubTrigger>
              <Clock class="text-primary mr-2 size-3.5" />
              <span>Change Status</span>
            </ContextMenuSubTrigger>
            <ContextMenuSubContent class="w-44">
              <ContextMenuRadioGroup :model-value="task.status ?? 'todo'">
                <ContextMenuRadioItem value="done" @select="$emit('status-change', task, 'done')">
                  <span class="mr-2 size-2 rounded-full bg-emerald-500" />
                  <span>Completed</span>
                </ContextMenuRadioItem>
                <ContextMenuRadioItem value="in-progress" @select="$emit('status-change', task, 'in-progress')">
                  <span class="bg-primary mr-2 size-2 rounded-full" />
                  <span>In Progress</span>
                </ContextMenuRadioItem>
                <ContextMenuRadioItem value="at-risk" @select="$emit('status-change', task, 'at-risk')">
                  <span class="mr-2 size-2 rounded-full bg-amber-500" />
                  <span>At Risk</span>
                </ContextMenuRadioItem>
                <ContextMenuRadioItem value="blocked" @select="$emit('status-change', task, 'blocked')">
                  <span class="bg-destructive mr-2 size-2 rounded-full" />
                  <span>Blocked</span>
                </ContextMenuRadioItem>
                <ContextMenuRadioItem value="todo" @select="$emit('status-change', task, 'todo')">
                  <span class="bg-muted-foreground/40 mr-2 size-2 rounded-full" />
                  <span>To Do</span>
                </ContextMenuRadioItem>
              </ContextMenuRadioGroup>
            </ContextMenuSubContent>
          </ContextMenuSub>
    
          <!-- Priority Submenu -->
          <ContextMenuSub>
            <ContextMenuSubTrigger>
              <Flag class="mr-2 size-3.5 text-amber-500" />
              <span>Set Priority</span>
            </ContextMenuSubTrigger>
            <ContextMenuSubContent class="w-40">
              <ContextMenuRadioGroup :model-value="task.priority ?? 'medium'">
                <ContextMenuRadioItem value="urgent" @select="$emit('priority-change', task, 'urgent')">
                  <Flag class="text-destructive mr-2 size-3" />
                  <span>Urgent</span>
                </ContextMenuRadioItem>
                <ContextMenuRadioItem value="high" @select="$emit('priority-change', task, 'high')">
                  <Flag class="mr-2 size-3 text-amber-500" />
                  <span>High</span>
                </ContextMenuRadioItem>
                <ContextMenuRadioItem value="medium" @select="$emit('priority-change', task, 'medium')">
                  <Flag class="text-primary mr-2 size-3" />
                  <span>Medium</span>
                </ContextMenuRadioItem>
                <ContextMenuRadioItem value="low" @select="$emit('priority-change', task, 'low')">
                  <Flag class="text-muted-foreground mr-2 size-3" />
                  <span>Low</span>
                </ContextMenuRadioItem>
              </ContextMenuRadioGroup>
            </ContextMenuSubContent>
          </ContextMenuSub>
    
          <ContextMenuSeparator />
    
          <!-- Copy ID -->
          <ContextMenuItem @select="copyTaskId">
            <Copy class="mr-2 size-3.5" />
            <span>Copy Task ID</span>
            <ContextMenuShortcut>⌘C</ContextMenuShortcut>
          </ContextMenuItem>
    
          <!-- Duplicate -->
          <ContextMenuItem @select="$emit('duplicate', task)">
            <Layers class="mr-2 size-3.5" />
            <span>Duplicate</span>
            <ContextMenuShortcut>⌘D</ContextMenuShortcut>
          </ContextMenuItem>
    
          <ContextMenuSeparator />
    
          <!-- Delete -->
          <ContextMenuItem class="text-destructive focus:text-destructive" @select="$emit('delete', task)">
            <Trash2 class="mr-2 size-3.5" />
            <span>Delete Deliverable</span>
            <ContextMenuShortcut></ContextMenuShortcut>
          </ContextMenuItem>
        </ContextMenuContent>
      </ContextMenu>
    </template>
  • app/components/ui/gantt/types.ts 0.9 kB
    export type GanttScale = 'day' | 'week' | 'month' | 'quarter' | 'year'
    
    export type GanttTaskStatus = 'todo' | 'in-progress' | 'done' | 'blocked' | 'at-risk'
    
    export type GanttTaskPriority = 'low' | 'medium' | 'high' | 'urgent'
    
    export interface GanttDependency {
      fromId: string
      toId: string
      type?: 'finish-to-start' | 'start-to-start' | 'finish-to-finish'
    }
    
    export interface GanttAssignee {
      name: string
      avatar?: string
      initials?: string
      role?: string
    }
    
    export interface GanttTask {
      id: string
      name: string
      startDate: string // YYYY-MM-DD
      endDate: string // YYYY-MM-DD
      progress?: number // 0 to 100
      color?: string
      status?: GanttTaskStatus
      priority?: GanttTaskPriority
      assignee?: GanttAssignee
      isMilestone?: boolean
      isGroup?: boolean
      parentId?: string | null
      isExpanded?: boolean
      dependencies?: string[] // task IDs
      children?: GanttTask[]
    }
    
    export interface GanttColumn {
      key: string
      label: string
      width?: string
    }
  • app/components/ui/gantt/index.ts 0.4 kB
    export { default as Gantt } from './Gantt.vue'
    export { default as GanttHeader } from './GanttHeader.vue'
    export { default as GanttTree } from './GanttTree.vue'
    export { default as GanttTimeline } from './GanttTimeline.vue'
    export { default as GanttBar } from './GanttBar.vue'
    export { default as GanttMilestone } from './GanttMilestone.vue'
    export { default as GanttContextMenu } from './GanttContextMenu.vue'
    export * from './types'

Raw manifest: https://uipkge.dev/r/vue/gantt.json