UIPackage
Menu

Framework

Change language

Boilerplate repo

Project Roadmap

block dashboard

Full interactive project roadmap and deliverables surface: Gantt timeline with multi-scale zooming, work breakdown task tree, overall progress metrics, search and status/assignee filters, task detail slide-over Sheet, and Add Task modal dialog.

Also available for React ->

Installation

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

Examples

Loading interactive previews…

Props

Name Type / Values Default Required
initialTasks GanttTask[] () => [ { id: 'task-1', name: 'System Architecture & Data… optional
title string 'Engineering Q3 Deliverables' optional
description string 'Track roadmap work breakdown optional

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

RoadmapProject
interface RoadmapProject {
  id: string
  name: string
  description?: string
  tasks: GanttTask[]
}

Files installed (6)

  • app/components/blocks/project-roadmap/ProjectRoadmap.vue 7.6 kB
    <script setup lang="ts">
    import { computed, ref } from 'vue'
    import { Plus, Search, Download, Filter } from 'lucide-vue-next'
    import { Button } from '@/components/ui/button'
    import { Input, InputGroup, InputGroupAddon } from '@/components/ui/input'
    import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
    import {
      Gantt,
      GanttHeader,
      GanttTree,
      GanttTimeline,
      GanttContextMenu,
      type GanttTask,
      type GanttScale,
      type GanttTaskStatus,
      type GanttTaskPriority,
    } from '@/components/ui/gantt'
    import RoadmapMetrics from './RoadmapMetrics.vue'
    import RoadmapTaskDetail from './RoadmapTaskDetail.vue'
    import RoadmapAddTaskDialog from './RoadmapAddTaskDialog.vue'
    
    interface Props {
      initialTasks?: GanttTask[]
      title?: string
      description?: string
    }
    
    const props = withDefaults(defineProps<Props>(), {
      title: 'Engineering Q3 Deliverables',
      description: 'Track roadmap work breakdown, sprint milestones, and cross-team dependencies.',
      initialTasks: () => [
        {
          id: 'task-1',
          name: 'System Architecture & Database Schema',
          startDate: '2026-08-01',
          endDate: '2026-08-08',
          progress: 100,
          status: 'done',
          priority: 'high',
          assignee: { name: 'Sarah Connor', initials: 'SC' },
        },
        {
          id: 'task-2',
          name: 'Auth, RBAC & Multi-Tenant Session Engine',
          startDate: '2026-08-06',
          endDate: '2026-08-16',
          progress: 85,
          status: 'in-progress',
          priority: 'urgent',
          assignee: { name: 'Marcus Rivera', initials: 'MR' },
        },
        {
          id: 'task-3',
          name: 'Real-time WebSocket Notification Bus',
          startDate: '2026-08-12',
          endDate: '2026-08-22',
          progress: 50,
          status: 'in-progress',
          priority: 'high',
          assignee: { name: 'Priya Nair', initials: 'PN' },
        },
        {
          id: 'task-4',
          name: 'Alpha Core API Release Milestone',
          startDate: '2026-08-23',
          endDate: '2026-08-23',
          isMilestone: true,
          status: 'todo',
          priority: 'urgent',
        },
        {
          id: 'task-5',
          name: 'Stripe Billing & Invoicing Integration',
          startDate: '2026-08-24',
          endDate: '2026-09-04',
          progress: 15,
          status: 'todo',
          priority: 'medium',
          assignee: { name: 'Sundar Krishnan', initials: 'SK' },
        },
        {
          id: 'task-6',
          name: 'End-to-End Playwright Automated Testing',
          startDate: '2026-08-28',
          endDate: '2026-09-08',
          progress: 0,
          status: 'todo',
          priority: 'low',
          assignee: { name: 'Diane Cho', initials: 'DC' },
        },
        {
          id: 'task-7',
          name: 'Production Cloudflare Pages Deployment',
          startDate: '2026-09-10',
          endDate: '2026-09-10',
          isMilestone: true,
          status: 'todo',
        },
      ],
    })
    
    const tasks = ref<GanttTask[]>([...props.initialTasks])
    const searchQuery = ref('')
    const statusFilter = ref('all')
    const assigneeFilter = ref('all')
    const scale = ref<GanttScale>('day')
    
    const selectedTask = ref<GanttTask | null>(null)
    const sheetOpen = ref(false)
    const addDialogOpen = ref(false)
    
    const filteredTasks = computed(() => {
      return tasks.value.filter((t) => {
        const matchesSearch = !searchQuery.value || t.name.toLowerCase().includes(searchQuery.value.toLowerCase())
        const matchesStatus = statusFilter.value === 'all' || t.status === statusFilter.value
        const matchesAssignee = assigneeFilter.value === 'all' || t.assignee?.name === assigneeFilter.value
        return matchesSearch && matchesStatus && matchesAssignee
      })
    })
    
    function handleTaskClick(task: GanttTask) {
      selectedTask.value = task
      sheetOpen.value = true
    }
    
    function handleAddTask(newTask: GanttTask) {
      tasks.value.push(newTask)
    }
    
    function handleStatusChange(task: GanttTask, status: GanttTaskStatus) {
      const target = tasks.value.find((t) => t.id === task.id)
      if (target) {
        target.status = status
        if (status === 'done') target.progress = 100
      }
    }
    
    function handlePriorityChange(task: GanttTask, priority: GanttTaskPriority) {
      const target = tasks.value.find((t) => t.id === task.id)
      if (target) target.priority = priority
    }
    
    function handleDuplicate(task: GanttTask) {
      const cloned: GanttTask = {
        ...task,
        id: `task-${Date.now()}`,
        name: `${task.name} (Copy)`,
      }
      tasks.value.push(cloned)
    }
    
    function handleDelete(task: GanttTask) {
      tasks.value = tasks.value.filter((t) => t.id !== task.id)
    }
    </script>
    
    <template>
      <div data-uipkge data-slot="project-roadmap" class="space-y-6">
        <!-- Section Top Header -->
        <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
          <div>
            <h2 class="text-foreground text-xl font-bold tracking-tight sm:text-2xl">{{ title }}</h2>
            <p class="text-muted-foreground mt-0.5 text-sm">{{ description }}</p>
          </div>
    
          <div class="flex items-center gap-2">
            <Button variant="outline" size="sm">
              <Download class="mr-1 size-3.5" />
              Export
            </Button>
            <Button size="sm" @click="addDialogOpen = true">
              <Plus class="mr-1 size-3.5" />
              Add Task
            </Button>
          </div>
        </div>
    
        <!-- Summary Metrics -->
        <RoadmapMetrics :tasks="tasks" />
    
        <!-- Filters & Action Bar -->
        <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
          <div class="flex max-w-lg flex-1 flex-wrap items-center gap-2">
            <InputGroup size="small" class="w-56">
              <InputGroupAddon>
                <Search class="size-3.5" />
              </InputGroupAddon>
              <Input v-model="searchQuery" placeholder="Filter tasks by name..." />
            </InputGroup>
    
            <Select v-model="statusFilter">
              <SelectTrigger class="h-8 w-32 text-xs">
                <SelectValue placeholder="All Status" />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value="all">All Status</SelectItem>
                <SelectItem value="done">Completed</SelectItem>
                <SelectItem value="in-progress">In Progress</SelectItem>
                <SelectItem value="todo">To Do</SelectItem>
              </SelectContent>
            </Select>
    
            <Select v-model="assigneeFilter">
              <SelectTrigger class="h-8 w-36 text-xs">
                <SelectValue placeholder="All Assignees" />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value="all">All Assignees</SelectItem>
                <SelectItem value="Sarah Connor">Sarah Connor</SelectItem>
                <SelectItem value="Marcus Rivera">Marcus Rivera</SelectItem>
                <SelectItem value="Priya Nair">Priya Nair</SelectItem>
                <SelectItem value="Sundar Krishnan">Sundar Krishnan</SelectItem>
                <SelectItem value="Diane Cho">Diane Cho</SelectItem>
              </SelectContent>
            </Select>
          </div>
    
          <p class="text-muted-foreground font-mono text-xs">
            Showing <span class="text-foreground font-bold">{{ filteredTasks.length }}</span> of
            {{ tasks.length }} deliverables
            <span class="text-muted-foreground/60 ml-2">(Right-click task for quick actions)</span>
          </p>
        </div>
    
        <!-- Main Interactive Gantt Workspace with Right-Click Context Actions -->
        <Gantt :tasks="filteredTasks" :scale="scale" class="h-[480px]">
          <GanttHeader :title="title" />
          <div class="flex min-h-0 flex-1 overflow-hidden">
            <GanttTree @click="handleTaskClick" />
            <GanttTimeline @task-click="handleTaskClick" />
          </div>
        </Gantt>
    
        <!-- Task Detail Drawer -->
        <RoadmapTaskDetail v-model:open="sheetOpen" :task="selectedTask" />
    
        <!-- Add Task Modal Dialog -->
        <RoadmapAddTaskDialog v-model:open="addDialogOpen" @add-task="handleAddTask" />
      </div>
    </template>
  • app/components/blocks/project-roadmap/RoadmapMetrics.vue 2.9 kB
    <script setup lang="ts">
    import { computed } from 'vue'
    import { CheckCircle2, Clock, AlertCircle, Milestone } from 'lucide-vue-next'
    import { Card, CardContent } from '@/components/ui/card'
    import type { GanttTask } from '@/components/ui/gantt'
    
    const props = defineProps<{
      tasks: GanttTask[]
    }>()
    
    const total = computed(() => props.tasks.length)
    const completed = computed(() => props.tasks.filter((t) => t.status === 'done').length)
    const inProgress = computed(() => props.tasks.filter((t) => t.status === 'in-progress').length)
    const milestones = computed(() => props.tasks.filter((t) => t.isMilestone).length)
    const progress = computed(() => {
      if (total.value === 0) return 0
      const sum = props.tasks.reduce((acc, t) => acc + (t.progress ?? 0), 0)
      return Math.round(sum / total.value)
    })
    </script>
    
    <template>
      <div data-slot="roadmap-metrics" class="grid grid-cols-2 gap-3 sm:grid-cols-4">
        <Card class="bg-card/50 border-border/80 shadow-none">
          <CardContent class="flex items-center justify-between p-3.5">
            <div>
              <p class="text-muted-foreground text-xs font-medium">Overall Progress</p>
              <p class="text-foreground mt-0.5 font-mono text-xl font-bold">{{ progress }}%</p>
            </div>
            <div class="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-full">
              <Clock class="size-4" />
            </div>
          </CardContent>
        </Card>
    
        <Card class="bg-card/50 border-border/80 shadow-none">
          <CardContent class="flex items-center justify-between p-3.5">
            <div>
              <p class="text-muted-foreground text-xs font-medium">In Progress</p>
              <p class="text-foreground mt-0.5 font-mono text-xl font-bold">{{ inProgress }}</p>
            </div>
            <div class="flex size-8 items-center justify-center rounded-full bg-blue-500/10 text-blue-500">
              <AlertCircle class="size-4" />
            </div>
          </CardContent>
        </Card>
    
        <Card class="bg-card/50 border-border/80 shadow-none">
          <CardContent class="flex items-center justify-between p-3.5">
            <div>
              <p class="text-muted-foreground text-xs font-medium">Completed</p>
              <p class="text-foreground mt-0.5 font-mono text-xl font-bold">{{ completed }}</p>
            </div>
            <div class="flex size-8 items-center justify-center rounded-full bg-emerald-500/10 text-emerald-500">
              <CheckCircle2 class="size-4" />
            </div>
          </CardContent>
        </Card>
    
        <Card class="bg-card/50 border-border/80 shadow-none">
          <CardContent class="flex items-center justify-between p-3.5">
            <div>
              <p class="text-muted-foreground text-xs font-medium">Milestones</p>
              <p class="text-foreground mt-0.5 font-mono text-xl font-bold">{{ milestones }}</p>
            </div>
            <div class="flex size-8 items-center justify-center rounded-full bg-amber-500/10 text-amber-500">
              <Milestone class="size-4" />
            </div>
          </CardContent>
        </Card>
      </div>
    </template>
  • app/components/blocks/project-roadmap/RoadmapTaskDetail.vue 3.6 kB
    <script setup lang="ts">
    import { computed } from 'vue'
    import { Calendar, User, CheckCircle2, Clock, X, Tag } from 'lucide-vue-next'
    import { Button } from '@/components/ui/button'
    import { Badge } from '@/components/ui/badge'
    import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet'
    import type { GanttTask } from '@/components/ui/gantt'
    
    const props = defineProps<{
      task: GanttTask | null
      open: boolean
    }>()
    
    const emits = defineEmits<{
      (e: 'update:open', val: boolean): void
    }>()
    
    const statusBadge = computed(() => {
      if (!props.task?.status) return { label: 'Active', variant: 'secondary' as const }
      switch (props.task.status) {
        case 'done':
          return { label: 'Completed', variant: 'outline' as const, class: 'text-emerald-600 border-emerald-500/30' }
        case 'in-progress':
          return { label: 'In Progress', variant: 'default' as const }
        case 'blocked':
          return { label: 'Blocked', variant: 'destructive' as const }
        default:
          return { label: 'To Do', variant: 'secondary' as const }
      }
    })
    </script>
    
    <template>
      <Sheet :open="open" @update:open="$emit('update:open', $event)">
        <SheetContent v-if="task" class="sm:max-w-md">
          <SheetHeader class="border-border space-y-2 border-b pb-4">
            <div class="flex items-center gap-2">
              <Badge :variant="statusBadge.variant" :class="statusBadge.class">
                {{ statusBadge.label }}
              </Badge>
              <Badge v-if="task.isMilestone" variant="outline" class="border-amber-500/30 text-amber-500">
                Milestone
              </Badge>
            </div>
            <SheetTitle class="text-foreground text-lg font-semibold">
              {{ task.name }}
            </SheetTitle>
            <SheetDescription class="text-muted-foreground text-xs">
              Task ID: <span class="font-mono">{{ task.id }}</span>
            </SheetDescription>
          </SheetHeader>
    
          <div class="space-y-5 py-5 text-sm">
            <!-- Date Schedule -->
            <div class="space-y-1.5">
              <span class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Schedule</span>
              <div class="text-foreground flex items-center gap-2 font-mono text-xs">
                <Calendar class="text-muted-foreground size-4" />
                <span>{{ task.startDate }}</span>
                <span>&rarr;</span>
                <span>{{ task.endDate }}</span>
              </div>
            </div>
    
            <!-- Progress -->
            <div v-if="task.progress != null" class="space-y-1.5">
              <div class="flex items-center justify-between text-xs">
                <span class="text-muted-foreground font-medium tracking-wider uppercase">Completion</span>
                <span class="font-mono font-bold">{{ task.progress }}%</span>
              </div>
              <div class="bg-muted h-2 w-full overflow-hidden rounded-full">
                <div
                  :style="{ width: `${task.progress}%` }"
                  class="bg-primary h-full rounded-full transition-all duration-300"
                />
              </div>
            </div>
    
            <!-- Assignee -->
            <div v-if="task.assignee" class="space-y-1.5">
              <span class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Owner</span>
              <div class="flex items-center gap-2.5">
                <div
                  class="bg-primary/10 text-primary flex size-7 items-center justify-center rounded-full text-xs font-medium"
                >
                  {{ task.assignee.initials || task.assignee.name.charAt(0) }}
                </div>
                <span class="text-foreground text-sm font-medium">{{ task.assignee.name }}</span>
              </div>
            </div>
          </div>
        </SheetContent>
      </Sheet>
    </template>
  • app/components/blocks/project-roadmap/RoadmapAddTaskDialog.vue 5.1 kB
    <script setup lang="ts">
    import { ref } from 'vue'
    import { Plus, Calendar as CalendarIcon } from 'lucide-vue-next'
    import { Button } from '@/components/ui/button'
    import { Input } from '@/components/ui/input'
    import { Label } from '@/components/ui/label'
    import { Checkbox } from '@/components/ui/checkbox'
    import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
    import {
      Dialog,
      DialogContent,
      DialogDescription,
      DialogFooter,
      DialogHeader,
      DialogTitle,
    } from '@/components/ui/dialog'
    import type { GanttTask } from '@/components/ui/gantt'
    
    const props = defineProps<{
      open: boolean
    }>()
    
    const emits = defineEmits<{
      (e: 'update:open', val: boolean): void
      (e: 'add-task', task: GanttTask): void
    }>()
    
    const name = ref('')
    const startDate = ref('2026-08-20')
    const endDate = ref('2026-08-28')
    const status = ref<'todo' | 'in-progress' | 'done'>('todo')
    const progress = ref(0)
    const isMilestone = ref(false)
    const assigneeName = ref('Marcus Rivera')
    
    function handleSubmit() {
      if (!name.value.trim()) return
    
      const newTask: GanttTask = {
        id: `task-${Date.now()}`,
        name: name.value.trim(),
        startDate: startDate.value,
        endDate: isMilestone.value ? startDate.value : endDate.value,
        status: status.value,
        progress: isMilestone.value ? 100 : Number(progress.value) || 0,
        isMilestone: isMilestone.value,
        assignee: {
          name: assigneeName.value,
          initials: assigneeName.value
            .split(' ')
            .map((p) => p[0])
            .join(''),
        },
      }
    
      emits('add-task', newTask)
      emits('update:open', false)
    
      // Reset form
      name.value = ''
      progress.value = 0
      isMilestone.value = false
    }
    </script>
    
    <template>
      <Dialog :open="open" @update:open="$emit('update:open', $event)">
        <DialogContent class="sm:max-w-[480px]">
          <DialogHeader>
            <DialogTitle>Add Roadmap Task</DialogTitle>
            <DialogDescription>
              Create a new scheduled deliverable or critical milestone in the project Gantt timeline.
            </DialogDescription>
          </DialogHeader>
    
          <form @submit.prevent="handleSubmit" class="space-y-4 py-2">
            <!-- Task Name -->
            <div class="space-y-1.5">
              <Label for="task-name">Task Name</Label>
              <Input id="task-name" v-model="name" placeholder="e.g. Design Token Hierarchy & Specs" required />
            </div>
    
            <!-- Date Range -->
            <div class="grid grid-cols-2 gap-3">
              <div class="space-y-1.5">
                <Label for="start-date">Start Date</Label>
                <Input id="start-date" v-model="startDate" type="date" required />
              </div>
              <div class="space-y-1.5">
                <Label for="end-date">End Date</Label>
                <Input id="end-date" v-model="endDate" type="date" :disabled="isMilestone" required />
              </div>
            </div>
    
            <!-- Milestone toggle -->
            <div class="border-border/80 bg-muted/20 flex items-center space-x-2 rounded-md border p-3">
              <Checkbox id="milestone-toggle" v-model:checked="isMilestone" />
              <div class="grid gap-0.5 leading-none">
                <label for="milestone-toggle" class="text-foreground cursor-pointer text-xs font-semibold select-none">
                  Key Project Milestone
                </label>
                <p class="text-muted-foreground text-[11px]">
                  Marks a zero-duration target deliverable (e.g. Beta Release, Audit sign-off)
                </p>
              </div>
            </div>
    
            <!-- Status & Assignee -->
            <div class="grid grid-cols-2 gap-3">
              <div class="space-y-1.5">
                <Label>Initial Status</Label>
                <Select v-model="status">
                  <SelectTrigger>
                    <SelectValue placeholder="Status" />
                  </SelectTrigger>
                  <SelectContent>
                    <SelectItem value="todo">To Do</SelectItem>
                    <SelectItem value="in-progress">In Progress</SelectItem>
                    <SelectItem value="done">Completed</SelectItem>
                  </SelectContent>
                </Select>
              </div>
    
              <div class="space-y-1.5">
                <Label>Assignee</Label>
                <Select v-model="assigneeName">
                  <SelectTrigger>
                    <SelectValue placeholder="Assignee" />
                  </SelectTrigger>
                  <SelectContent>
                    <SelectItem value="Marcus Rivera">Marcus Rivera</SelectItem>
                    <SelectItem value="Sarah Connor">Sarah Connor</SelectItem>
                    <SelectItem value="Priya Nair">Priya Nair</SelectItem>
                    <SelectItem value="Sundar Krishnan">Sundar Krishnan</SelectItem>
                    <SelectItem value="Diane Cho">Diane Cho</SelectItem>
                  </SelectContent>
                </Select>
              </div>
            </div>
    
            <DialogFooter class="pt-4">
              <Button variant="outline" type="button" @click="$emit('update:open', false)"> Cancel </Button>
              <Button type="submit">
                <Plus class="mr-1.5 size-3.5" />
                Create Task
              </Button>
            </DialogFooter>
          </form>
        </DialogContent>
      </Dialog>
    </template>
  • app/components/blocks/project-roadmap/types.ts 0.2 kB
    import type { GanttTask } from '@/components/ui/gantt'
    
    export interface RoadmapProject {
      id: string
      name: string
      description?: string
      tasks: GanttTask[]
    }
  • app/components/blocks/project-roadmap/index.ts 0.3 kB
    export { default as ProjectRoadmap } from './ProjectRoadmap.vue'
    export { default as RoadmapMetrics } from './RoadmapMetrics.vue'
    export { default as RoadmapTaskDetail } from './RoadmapTaskDetail.vue'
    export { default as RoadmapAddTaskDialog } from './RoadmapAddTaskDialog.vue'
    export * from './types'

Raw manifest: https://uipkge.dev/r/vue/project-roadmap.json