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 Vue ->

Installation

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

Examples

Loading interactive previews…

Props

Name Type / Values Default Required
tasks GanttTask[] required
scale GanttScale optional
onScaleChange (scale: GanttScale) => void optional
startDate string optional
endDate string optional
rowHeight number optional
headerHeight number optional
treeWidth number optional
onTaskClick (task: GanttTask) => void optional
onTaskChange (task: GanttTask) => void optional

Schema

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

GanttContextValue
interface GanttContextValue {
  scale: GanttScale
  setScale: (scale: GanttScale) => void
  startDate: Date
  endDate: Date
  totalDays: number
  columnWidth: number
  rowHeight: number
  headerHeight: number
  treeWidth: number
  tasks: GanttTask[]
  onTaskClick?: (task: GanttTask) => void
  onTaskChange?: (task: GanttTask) => void
}
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)

  • components/ui/gantt/Gantt.tsx 4.2 kB
    'use client'
    
    import * as React from 'react'
    import { cn } from '@/lib/utils'
    import type { GanttScale, GanttTask } from './types'
    
    export interface GanttContextValue {
      scale: GanttScale
      setScale: (scale: GanttScale) => void
      startDate: Date
      endDate: Date
      totalDays: number
      columnWidth: number
      rowHeight: number
      headerHeight: number
      treeWidth: number
      tasks: GanttTask[]
      onTaskClick?: (task: GanttTask) => void
      onTaskChange?: (task: GanttTask) => void
    }
    
    export const GanttContext = React.createContext<GanttContextValue | null>(null)
    
    export function useGantt() {
      const context = React.useContext(GanttContext)
      if (!context) {
        throw new Error('useGantt must be used within a <Gantt /> component')
      }
      return context
    }
    
    export interface GanttProps extends React.HTMLAttributes<HTMLDivElement> {
      tasks: GanttTask[]
      scale?: GanttScale
      onScaleChange?: (scale: GanttScale) => void
      startDate?: string
      endDate?: string
      rowHeight?: number
      headerHeight?: number
      treeWidth?: number
      onTaskClick?: (task: GanttTask) => void
      onTaskChange?: (task: GanttTask) => void
    }
    
    export const Gantt = React.forwardRef<HTMLDivElement, GanttProps>(
      (
        {
          className,
          tasks,
          scale: controlledScale,
          onScaleChange,
          startDate,
          endDate,
          rowHeight = 40,
          headerHeight = 48,
          treeWidth = 280,
          onTaskClick,
          onTaskChange,
          children,
          ...props
        },
        ref,
      ) => {
        const [internalScale, setInternalScale] = React.useState<GanttScale>(controlledScale ?? 'day')
        const currentScale = controlledScale ?? internalScale
    
        const handleSetScale = React.useCallback(
          (newScale: GanttScale) => {
            setInternalScale(newScale)
            onScaleChange?.(newScale)
          },
          [onScaleChange],
        )
    
        const resolvedStartDate = React.useMemo(() => {
          if (startDate) return new Date(startDate)
          if (tasks.length === 0) return new Date()
          const dates = tasks.map((t) => new Date(t.startDate).getTime())
          const min = Math.min(...dates)
          const d = new Date(min)
          d.setDate(d.getDate() - 3)
          return d
        }, [startDate, tasks])
    
        const resolvedEndDate = React.useMemo(() => {
          if (endDate) return new Date(endDate)
          if (tasks.length === 0) {
            const d = new Date()
            d.setDate(d.getDate() + 30)
            return d
          }
          const dates = tasks.map((t) => new Date(t.endDate).getTime())
          const max = Math.max(...dates)
          const d = new Date(max)
          d.setDate(d.getDate() + 7)
          return d
        }, [endDate, tasks])
    
        const totalDays = React.useMemo(() => {
          const diff = resolvedEndDate.getTime() - resolvedStartDate.getTime()
          return Math.max(1, Math.ceil(diff / (1000 * 60 * 60 * 24)))
        }, [resolvedStartDate, resolvedEndDate])
    
        const columnWidth = React.useMemo(() => {
          switch (currentScale) {
            case 'day':
              return 44
            case 'week':
              return 120
            case 'month':
              return 180
            case 'year':
              return 240
            default:
              return 44
          }
        }, [currentScale])
    
        const contextValue = React.useMemo<GanttContextValue>(
          () => ({
            scale: currentScale,
            setScale: handleSetScale,
            startDate: resolvedStartDate,
            endDate: resolvedEndDate,
            totalDays,
            columnWidth,
            rowHeight,
            headerHeight,
            treeWidth,
            tasks,
            onTaskClick,
            onTaskChange,
          }),
          [
            currentScale,
            handleSetScale,
            resolvedStartDate,
            resolvedEndDate,
            totalDays,
            columnWidth,
            rowHeight,
            headerHeight,
            treeWidth,
            tasks,
            onTaskClick,
            onTaskChange,
          ],
        )
    
        return (
          <GanttContext.Provider value={contextValue}>
            <div
              ref={ref}
              data-uipkge=""
              data-slot="gantt"
              className={cn(
                'border-border bg-card text-card-foreground relative flex w-full flex-col overflow-hidden rounded-xl border shadow-xs',
                className,
              )}
              {...props}
            >
              {children}
            </div>
          </GanttContext.Provider>
        )
      },
    )
    
    Gantt.displayName = 'Gantt'
  • components/ui/gantt/GanttHeader.tsx 1.9 kB
    'use client'
    
    import * as React from 'react'
    import { Calendar } from 'lucide-react'
    import { cn } from '@/lib/utils'
    import { Button, ButtonGroup } from '@/components/ui/button'
    import { useGantt } from './Gantt'
    
    export interface GanttHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
      title?: string
      showScaleSwitcher?: boolean
      actions?: React.ReactNode
    }
    
    export const GanttHeader = React.forwardRef<HTMLDivElement, GanttHeaderProps>(
      ({ className, title = 'Project Timeline', showScaleSwitcher = true, actions, children, ...props }, ref) => {
        const { scale, setScale } = useGantt()
    
        return (
          <div
            ref={ref}
            data-uipkge=""
            data-slot="gantt-header"
            className={cn('border-border bg-muted/30 flex items-center justify-between border-b px-4 py-2.5', className)}
            {...props}
          >
            <div className="flex items-center gap-2">
              <Calendar className="text-primary size-4" />
              <span className="text-foreground text-sm font-semibold">{title}</span>
            </div>
    
            <div className="flex items-center gap-3">
              {actions}
              {children}
    
              {showScaleSwitcher ? (
                <ButtonGroup>
                  <Button size="xs" variant={scale === 'day' ? 'default' : 'outline'} onClick={() => setScale('day')}>
                    Day
                  </Button>
                  <Button size="xs" variant={scale === 'week' ? 'default' : 'outline'} onClick={() => setScale('week')}>
                    Week
                  </Button>
                  <Button size="xs" variant={scale === 'month' ? 'default' : 'outline'} onClick={() => setScale('month')}>
                    Month
                  </Button>
                  <Button size="xs" variant={scale === 'year' ? 'default' : 'outline'} onClick={() => setScale('year')}>
                    Year
                  </Button>
                </ButtonGroup>
              ) : null}
            </div>
          </div>
        )
      },
    )
    
    GanttHeader.displayName = 'GanttHeader'
  • components/ui/gantt/GanttTree.tsx 4 kB
    'use client'
    
    import * as React from 'react'
    import { ChevronRight, ChevronDown, Flag } from 'lucide-react'
    import { cn } from '@/lib/utils'
    import { useGantt } from './Gantt'
    import type { GanttTask } from './types'
    
    export interface GanttTreeProps extends React.HTMLAttributes<HTMLDivElement> {
      showAssignee?: boolean
      showPriority?: boolean
      onTaskClick?: (task: GanttTask) => void
    }
    
    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`
    }
    
    export const GanttTree = React.forwardRef<HTMLDivElement, GanttTreeProps>(
      ({ className, showAssignee = true, showPriority = true, onTaskClick: propOnTaskClick, ...props }, ref) => {
        const { treeWidth, headerHeight, rowHeight, tasks, onTaskClick: ctxOnTaskClick } = useGantt()
        const handleTaskClick = propOnTaskClick ?? ctxOnTaskClick
    
        return (
          <div
            ref={ref}
            data-uipkge=""
            data-slot="gantt-tree"
            style={{ width: `${treeWidth}px` }}
            className={cn(
              'border-border bg-card flex shrink-0 flex-col border-r transition-[width] select-none',
              className,
            )}
            {...props}
          >
            <div
              style={{ height: `${headerHeight}px` }}
              className="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 className="flex-1 truncate">Deliverable</span>
              {showPriority ? <span className="w-12 shrink-0 text-center">Pri</span> : null}
              <span className="w-16 shrink-0 text-right">Duration</span>
            </div>
    
            <div className="divide-border/40 flex-1 divide-y overflow-y-auto">
              {tasks.map((task) => (
                <div
                  key={task.id}
                  style={{ height: `${rowHeight}px` }}
                  className={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',
                  )}
                  onClick={() => handleTaskClick?.(task)}
                >
                  <div className="flex min-w-0 flex-1 items-center gap-1.5 pr-2">
                    {task.parentId ? <span className="w-4 shrink-0" /> : null}
    
                    {task.status ? (
                      <span
                        className={cn(
                          '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',
                        )}
                      />
                    ) : null}
    
                    <span className="truncate font-medium">{task.name}</span>
                  </div>
    
                  {showPriority ? (
                    <div className="flex w-12 shrink-0 items-center justify-center">
                      {task.priority ? <Flag className={cn('size-3', priorityColors[task.priority])} /> : null}
                    </div>
                  ) : null}
    
                  <div className="text-muted-foreground w-16 shrink-0 text-right font-mono text-[11px]">
                    {task.isMilestone ? (
                      <span className="text-[10px] font-semibold text-amber-500">Milestone</span>
                    ) : (
                      <span>{calculateDays(task.startDate, task.endDate)}</span>
                    )}
                  </div>
                </div>
              ))}
            </div>
          </div>
        )
      },
    )
    
    GanttTree.displayName = 'GanttTree'
  • components/ui/gantt/GanttTimeline.tsx 8 kB
    'use client'
    
    import * as React from 'react'
    import { cn } from '@/lib/utils'
    import { useGantt } from './Gantt'
    import { GanttBar } from './GanttBar'
    import { GanttMilestone } from './GanttMilestone'
    import type { GanttTask } from './types'
    
    export interface GanttTimelineProps extends React.HTMLAttributes<HTMLDivElement> {
      showTodayLine?: boolean
      showDependencies?: boolean
      onTaskClick?: (task: GanttTask) => void
    }
    
    export const GanttTimeline = React.forwardRef<HTMLDivElement, GanttTimelineProps>(
      ({ className, showTodayLine = true, showDependencies = true, onTaskClick: propOnTaskClick, ...props }, ref) => {
        const {
          startDate,
          totalDays,
          columnWidth,
          headerHeight,
          rowHeight,
          tasks,
          onTaskClick: ctxOnTaskClick,
        } = useGantt()
        const handleTaskClick = propOnTaskClick ?? ctxOnTaskClick
    
        const columns = React.useMemo(() => {
          const list: { date: Date; label: string; subLabel: string; isWeekend: boolean }[] = []
          const start = new Date(startDate)
    
          for (let i = 0; i < totalDays; 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
        }, [startDate, totalDays])
    
        const timelineWidth = columns.length * columnWidth
    
        const getTaskCoordinates = React.useCallback(
          (task: GanttTask, index: number) => {
            const start = new Date(startDate).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 * columnWidth
            const width = durationDays * columnWidth
            const top = index * rowHeight + (rowHeight - 28) / 2
    
            return { left, width, top, height: 28 }
          },
          [startDate, columnWidth, rowHeight],
        )
    
        const todayPosition = React.useMemo(() => {
          const start = new Date(startDate).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 > totalDays) return null
          return diffDays * columnWidth + columnWidth / 2
        }, [startDate, totalDays, columnWidth])
    
        const dependencyPaths = React.useMemo(() => {
          if (!showDependencies) return []
          const taskMap = new Map<string, { task: GanttTask; index: number }>()
          tasks.forEach((t, i) => taskMap.set(t.id, { task: t, index: i }))
    
          const paths: { d: string; fromId: string; toId: string }[] = []
    
          tasks.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
        }, [showDependencies, tasks, getTaskCoordinates])
    
        return (
          <div
            ref={ref}
            data-uipkge=""
            data-slot="gantt-timeline"
            className={cn('bg-background relative flex-1 overflow-x-auto overflow-y-hidden select-none', className)}
            {...props}
          >
            <div style={{ width: `${timelineWidth}px` }} className="relative">
              <div
                style={{ height: `${headerHeight}px` }}
                className="border-border bg-muted/10 sticky top-0 z-20 flex border-b"
              >
                {columns.map((col, i) => (
                  <div
                    key={i}
                    style={{ width: `${columnWidth}px` }}
                    className={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 className="text-foreground font-medium">{col.label}</span>
                    <span className="text-[9px]">{col.subLabel}</span>
                  </div>
                ))}
              </div>
    
              <div className="relative">
                <div className="pointer-events-none absolute inset-0 flex">
                  {columns.map((col, i) => (
                    <div
                      key={i}
                      style={{ width: `${columnWidth}px` }}
                      className={cn('border-border/30 h-full border-r', col.isWeekend && 'bg-muted/15')}
                    />
                  ))}
                </div>
    
                {showTodayLine && todayPosition != null ? (
                  <div
                    style={{ left: `${todayPosition}px` }}
                    className="pointer-events-none absolute inset-y-0 z-30 flex flex-col items-center"
                  >
                    <div className="bg-destructive text-destructive-foreground rounded-full px-1.5 py-0.5 text-[9px] font-bold shadow-xs">
                      Today
                    </div>
                    <div className="bg-destructive/60 h-full w-[1.5px] border-r border-dashed" />
                  </div>
                ) : null}
    
                {dependencyPaths.length > 0 ? (
                  <svg
                    width={timelineWidth}
                    height={tasks.length * rowHeight}
                    className="pointer-events-none absolute inset-0 z-10"
                  >
                    <defs>
                      <marker
                        id="gantt-arrow-react"
                        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" className="fill-primary/60" />
                      </marker>
                    </defs>
                    {dependencyPaths.map((p, i) => (
                      <path
                        key={i}
                        d={p.d}
                        fill="none"
                        className="stroke-primary/50"
                        strokeWidth="1.5"
                        strokeDasharray="3,3"
                        markerEnd="url(#gantt-arrow-react)"
                      />
                    ))}
                  </svg>
                ) : null}
    
                {tasks.map((task, idx) => {
                  const coords = getTaskCoordinates(task, idx)
                  return (
                    <div
                      key={task.id}
                      style={{ height: `${rowHeight}px` }}
                      className="border-border/40 hover:bg-muted/10 relative border-b transition-colors"
                    >
                      {task.isMilestone ? (
                        <GanttMilestone task={task} left={coords.left} top={rowHeight / 2} onTaskClick={handleTaskClick} />
                      ) : (
                        <GanttBar
                          task={task}
                          left={coords.left}
                          width={coords.width}
                          top={(coords.height - 28) / 2 + 6}
                          height={28}
                          onTaskClick={handleTaskClick}
                        />
                      )}
                    </div>
                  )
                })}
              </div>
            </div>
          </div>
        )
      },
    )
    
    GanttTimeline.displayName = 'GanttTimeline'
  • components/ui/gantt/GanttBar.tsx 3.6 kB
    'use client'
    
    import * as React from 'react'
    import { cn } from '@/lib/utils'
    import type { GanttTask } from './types'
    
    export interface GanttBarProps extends React.HTMLAttributes<HTMLDivElement> {
      task: GanttTask
      left: number
      width: number
      top: number
      height: number
      onTaskClick?: (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',
    }
    
    export const GanttBar = React.forwardRef<HTMLDivElement, GanttBarProps>(
      ({ className, task, left, width, top, height, onTaskClick, ...props }, ref) => {
        if (task.isGroup) {
          return (
            <div
              ref={ref}
              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`,
              }}
              className={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',
                className,
              )}
              onClick={() => onTaskClick?.(task)}
              {...props}
            >
              <span className="truncate">{task.name}</span>
              {task.progress != null ? <span className="font-mono text-[10px] opacity-80">{task.progress}%</span> : null}
            </div>
          )
        }
    
        return (
          <div
            ref={ref}
            data-uipkge=""
            data-slot="gantt-bar"
            style={{
              left: `${left}px`,
              width: `${Math.max(24, width)}px`,
              top: `${top}px`,
              height: `${height}px`,
            }}
            className={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'],
              className,
            )}
            onClick={() => onTaskClick?.(task)}
            {...props}
          >
            {task.progress != null && task.progress > 0 ? (
              <div
                style={{ width: `${task.progress}%` }}
                className={cn('absolute inset-y-0 left-0 transition-all', progressColors[task.status ?? 'in-progress'])}
              />
            ) : null}
    
            <div className="relative z-10 flex w-full min-w-0 items-center justify-between px-2">
              <span className="truncate font-medium">{task.name}</span>
              {task.progress != null ? (
                <span className="ml-1 shrink-0 font-mono text-[10px] opacity-80">{task.progress}%</span>
              ) : null}
            </div>
    
            <div
              aria-hidden="true"
              className="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"
              className="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>
        )
      },
    )
    
    GanttBar.displayName = 'GanttBar'
  • components/ui/gantt/GanttMilestone.tsx 1.1 kB
    'use client'
    
    import * as React from 'react'
    import { cn } from '@/lib/utils'
    import type { GanttTask } from './types'
    
    export interface GanttMilestoneProps extends React.HTMLAttributes<HTMLDivElement> {
      task: GanttTask
      left: number
      top: number
      size?: number
      onTaskClick?: (task: GanttTask) => void
    }
    
    export const GanttMilestone = React.forwardRef<HTMLDivElement, GanttMilestoneProps>(
      ({ className, task, left, top, size = 16, onTaskClick, ...props }, ref) => {
        return (
          <div
            ref={ref}
            data-uipkge=""
            data-slot="gantt-milestone"
            style={{
              left: `${left - size / 2}px`,
              top: `${top - size / 2}px`,
              width: `${size}px`,
              height: `${size}px`,
            }}
            title={`${task.name} (${task.startDate})`}
            className={cn(
              'border-primary bg-primary absolute z-20 rotate-45 cursor-pointer rounded-xs border-2 shadow-sm transition-transform hover:scale-125',
              className,
            )}
            onClick={() => onTaskClick?.(task)}
            {...props}
          />
        )
      },
    )
    
    GanttMilestone.displayName = 'GanttMilestone'
  • components/ui/gantt/GanttContextMenu.tsx 5.6 kB
    'use client'
    
    import * as React from 'react'
    import {
      ContextMenu,
      ContextMenuContent,
      ContextMenuItem,
      ContextMenuLabel,
      ContextMenuRadioGroup,
      ContextMenuRadioItem,
      ContextMenuSeparator,
      ContextMenuShortcut,
      ContextMenuSub,
      ContextMenuSubContent,
      ContextMenuSubTrigger,
      ContextMenuTrigger,
    } from '@/components/ui/context-menu'
    import { Edit2, Copy, Trash2, Clock, Flag, Layers } from 'lucide-react'
    import type { GanttTask, GanttTaskStatus, GanttTaskPriority } from './types'
    
    export interface GanttContextMenuProps {
      task: GanttTask
      onEdit?: (task: GanttTask) => void
      onStatusChange?: (task: GanttTask, status: GanttTaskStatus) => void
      onPriorityChange?: (task: GanttTask, priority: GanttTaskPriority) => void
      onDuplicate?: (task: GanttTask) => void
      onDelete?: (task: GanttTask) => void
      children: React.ReactNode
    }
    
    export function GanttContextMenu({
      task,
      onEdit,
      onStatusChange,
      onPriorityChange,
      onDuplicate,
      onDelete,
      children,
    }: GanttContextMenuProps) {
      const copyTaskId = () => {
        if (typeof navigator !== 'undefined' && navigator.clipboard) {
          navigator.clipboard.writeText(task.id)
        }
      }
    
      return (
        <ContextMenu>
          <ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
          <ContextMenuContent className="w-56">
            <ContextMenuLabel className="flex items-center justify-between text-xs">
              <span className="truncate font-semibold">{task.name}</span>
              <span className="text-muted-foreground font-mono text-[10px]">{task.id}</span>
            </ContextMenuLabel>
            <ContextMenuSeparator />
    
            <ContextMenuItem onClick={() => onEdit?.(task)}>
              <Edit2 className="mr-2 size-3.5" />
              <span>View Details</span>
              <ContextMenuShortcut></ContextMenuShortcut>
            </ContextMenuItem>
    
            <ContextMenuSub>
              <ContextMenuSubTrigger>
                <Clock className="text-primary mr-2 size-3.5" />
                <span>Change Status</span>
              </ContextMenuSubTrigger>
              <ContextMenuSubContent className="w-44">
                <ContextMenuRadioGroup value={task.status ?? 'todo'}>
                  <ContextMenuRadioItem value="done" onClick={() => onStatusChange?.(task, 'done')}>
                    <span className="mr-2 size-2 rounded-full bg-emerald-500" />
                    <span>Completed</span>
                  </ContextMenuRadioItem>
                  <ContextMenuRadioItem value="in-progress" onClick={() => onStatusChange?.(task, 'in-progress')}>
                    <span className="bg-primary mr-2 size-2 rounded-full" />
                    <span>In Progress</span>
                  </ContextMenuRadioItem>
                  <ContextMenuRadioItem value="at-risk" onClick={() => onStatusChange?.(task, 'at-risk')}>
                    <span className="mr-2 size-2 rounded-full bg-amber-500" />
                    <span>At Risk</span>
                  </ContextMenuRadioItem>
                  <ContextMenuRadioItem value="blocked" onClick={() => onStatusChange?.(task, 'blocked')}>
                    <span className="bg-destructive mr-2 size-2 rounded-full" />
                    <span>Blocked</span>
                  </ContextMenuRadioItem>
                  <ContextMenuRadioItem value="todo" onClick={() => onStatusChange?.(task, 'todo')}>
                    <span className="bg-muted-foreground/40 mr-2 size-2 rounded-full" />
                    <span>To Do</span>
                  </ContextMenuRadioItem>
                </ContextMenuRadioGroup>
              </ContextMenuSubContent>
            </ContextMenuSub>
    
            <ContextMenuSub>
              <ContextMenuSubTrigger>
                <Flag className="mr-2 size-3.5 text-amber-500" />
                <span>Set Priority</span>
              </ContextMenuSubTrigger>
              <ContextMenuSubContent className="w-40">
                <ContextMenuRadioGroup value={task.priority ?? 'medium'}>
                  <ContextMenuRadioItem value="urgent" onClick={() => onPriorityChange?.(task, 'urgent')}>
                    <Flag className="text-destructive mr-2 size-3" />
                    <span>Urgent</span>
                  </ContextMenuRadioItem>
                  <ContextMenuRadioItem value="high" onClick={() => onPriorityChange?.(task, 'high')}>
                    <Flag className="mr-2 size-3 text-amber-500" />
                    <span>High</span>
                  </ContextMenuRadioItem>
                  <ContextMenuRadioItem value="medium" onClick={() => onPriorityChange?.(task, 'medium')}>
                    <Flag className="text-primary mr-2 size-3" />
                    <span>Medium</span>
                  </ContextMenuRadioItem>
                  <ContextMenuRadioItem value="low" onClick={() => onPriorityChange?.(task, 'low')}>
                    <Flag className="text-muted-foreground mr-2 size-3" />
                    <span>Low</span>
                  </ContextMenuRadioItem>
                </ContextMenuRadioGroup>
              </ContextMenuSubContent>
            </ContextMenuSub>
    
            <ContextMenuSeparator />
    
            <ContextMenuItem onClick={copyTaskId}>
              <Copy className="mr-2 size-3.5" />
              <span>Copy Task ID</span>
              <ContextMenuShortcut>⌘C</ContextMenuShortcut>
            </ContextMenuItem>
    
            <ContextMenuItem onClick={() => onDuplicate?.(task)}>
              <Layers className="mr-2 size-3.5" />
              <span>Duplicate</span>
              <ContextMenuShortcut>⌘D</ContextMenuShortcut>
            </ContextMenuItem>
    
            <ContextMenuSeparator />
    
            <ContextMenuItem className="text-destructive focus:text-destructive" onClick={() => onDelete?.(task)}>
              <Trash2 className="mr-2 size-3.5" />
              <span>Delete Deliverable</span>
              <ContextMenuShortcut></ContextMenuShortcut>
            </ContextMenuItem>
          </ContextMenuContent>
        </ContextMenu>
      )
    }
  • 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
    }
  • components/ui/gantt/index.ts 0.5 kB
    export { Gantt, useGantt, type GanttProps, type GanttContextValue } from './Gantt'
    export { GanttHeader, type GanttHeaderProps } from './GanttHeader'
    export { GanttTree, type GanttTreeProps } from './GanttTree'
    export { GanttTimeline, type GanttTimelineProps } from './GanttTimeline'
    export { GanttBar, type GanttBarProps } from './GanttBar'
    export { GanttMilestone, type GanttMilestoneProps } from './GanttMilestone'
    export { GanttContextMenu, type GanttContextMenuProps } from './GanttContextMenu'
    export * from './types'

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