UIPackage
Menu

Framework

Change language

Boilerplate repo

Employee Org Chart Tree

blockhr

Interactive organizational hierarchy tree with manager reporting lines, team counts, multi-level expandable branches, department filter pills, employee search, and an employee profile quick info drawer.

Also available for React ->

Installation

$npx shadcn-vue@latest add https://uipkge.dev/r/vue/employee-org-chart-tree.json
Named registry:npx shadcn-vue@latest add @uipkge/employee-org-chart-treeInstalls to:app/components/blocks/

Variants

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
titlestring'Company Organizational Chart'optional
subtitlestring'148 Employees across 5 Departments'optional
orgDataEmployeeNode() => DEFAULT_ORG_DATA, initialSelectedId: 'emp-ceo', ini…optional
initialSelectedIdstringoptional
initialDepartmentstringoptional
initialSearchstringoptional
initialExpandedIdsstring[]optional
initialDrawerOpenbooleanoptional
classHTMLAttributes['class']optional

Schema

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

DepartmentCount
interface DepartmentCount {
  label: string
  value: string
  count: number
}
EmployeeNode
interface EmployeeNode {
  id: string
  name: string
  role: string
  department: 'Executive' | 'Engineering' | 'Design' | 'Product' | 'Operations'
  email: string
  phone: string
  location: string
  avatar: string
  initials: string
  reportsCount: number
  teamHeadcount: number
  managerId?: string
  managerName?: string
  managerRole?: string
  startDate: string
  tenure: string
  bio: string
  skills: string[]
  status: 'active' | 'on-leave'
  children?: EmployeeNode[]
}

Files installed (6)

  • app/components/blocks/EmployeeOrgChartTree.vue13 kB
    <script setup lang="ts">
    import { computed, onMounted, ref, watch, type HTMLAttributes } from 'vue'
    import { cn } from '@/lib/utils'
    import EmployeeProfileDrawer from './EmployeeProfileDrawer.vue'
    import OrgChartNodeCard from './OrgChartNodeCard.vue'
    import OrgChartToolbar from './OrgChartToolbar.vue'
    import { DEFAULT_ORG_DATA } from './org-chart-data'
    import type { EmployeeNode } from './org-chart-types'
    import { getDeptBadgeClasses } from './org-chart-types'
    
    export type { EmployeeNode }
    
    interface Props {
      title?: string
      subtitle?: string
      orgData?: EmployeeNode
      initialSelectedId?: string
      initialDepartment?: string
      initialSearch?: string
      initialExpandedIds?: string[]
      initialDrawerOpen?: boolean
      class?: HTMLAttributes['class']
    }
    
    const props = withDefaults(defineProps<Props>(), {
      title: 'Company Organizational Chart',
      subtitle: '148 Employees across 5 Departments',
      orgData: () => DEFAULT_ORG_DATA,
      initialSelectedId: 'emp-ceo',
      initialDepartment: 'All',
      initialSearch: '',
      initialExpandedIds: () => ['emp-ceo', 'emp-eng-vp', 'emp-prod-vp', 'emp-ops-vp'],
      initialDrawerOpen: true,
    })
    
    const canvasRef = ref<HTMLDivElement | null>(null)
    onMounted(() => {
      const el = canvasRef.value
      if (el) el.scrollLeft = Math.max(0, (el.scrollWidth - el.clientWidth) / 2)
    })
    
    function flatten(node: EmployeeNode): EmployeeNode[] {
      const result: EmployeeNode[] = [node]
      if (node.children) {
        for (const child of node.children) {
          result.push(...flatten(child))
        }
      }
      return result
    }
    
    const allEmployees = computed(() => flatten(props.orgData))
    
    const selectedEmployeeId = ref<string>(props.initialSelectedId)
    const selectedDepartment = ref<string>(props.initialDepartment)
    const searchQuery = ref<string>(props.initialSearch)
    const isDrawerOpen = ref<boolean>(props.initialDrawerOpen)
    const zoomLevel = ref<number>(100)
    const emailCopied = ref<boolean>(false)
    const expandedIds = ref<Set<string>>(new Set(props.initialExpandedIds))
    
    const departmentCounts = computed(() => [
      { label: 'All', value: 'All', count: allEmployees.value.length },
      {
        label: 'Executive',
        value: 'Executive',
        count: allEmployees.value.filter((e) => e.department === 'Executive').length,
      },
      {
        label: 'Engineering',
        value: 'Engineering',
        count: allEmployees.value.filter((e) => e.department === 'Engineering').length,
      },
      { label: 'Design', value: 'Design', count: allEmployees.value.filter((e) => e.department === 'Design').length },
      { label: 'Product', value: 'Product', count: allEmployees.value.filter((e) => e.department === 'Product').length },
      {
        label: 'Operations',
        value: 'Operations',
        count: allEmployees.value.filter((e) => e.department === 'Operations').length,
      },
    ])
    
    const selectedEmployee = computed(() => {
      return allEmployees.value.find((e) => e.id === selectedEmployeeId.value) || props.orgData
    })
    
    const matchingEmployeeIds = computed(() => {
      const q = searchQuery.value.trim().toLowerCase()
      if (!q) return new Set<string>()
    
      const matches = new Set<string>()
      for (const emp of allEmployees.value) {
        if (
          emp.name.toLowerCase().includes(q) ||
          emp.role.toLowerCase().includes(q) ||
          emp.department.toLowerCase().includes(q) ||
          emp.location.toLowerCase().includes(q) ||
          emp.skills.some((s) => s.toLowerCase().includes(q))
        ) {
          matches.add(emp.id)
        }
      }
      return matches
    })
    
    watch(
      () => searchQuery.value,
      (q) => {
        if (q.trim()) {
          const allParentIds = allEmployees.value.filter((e) => e.children && e.children.length > 0).map((e) => e.id)
          expandedIds.value = new Set(allParentIds)
        }
      },
    )
    
    function isExpanded(id: string): boolean {
      return expandedIds.value.has(id)
    }
    
    function toggleExpand(id: string) {
      const updated = new Set(expandedIds.value)
      if (updated.has(id)) {
        updated.delete(id)
      } else {
        updated.add(id)
      }
      expandedIds.value = updated
    }
    
    function expandAll() {
      const allParentIds = allEmployees.value.filter((e) => e.children && e.children.length > 0).map((e) => e.id)
      expandedIds.value = new Set(allParentIds)
    }
    
    function collapseAll() {
      expandedIds.value = new Set()
    }
    
    function selectEmployee(emp: EmployeeNode) {
      selectedEmployeeId.value = emp.id
      isDrawerOpen.value = true
    }
    
    function selectSelectedManager() {
      const manager = allEmployees.value.find((e) => e.id === selectedEmployee.value?.managerId)
      if (manager) selectEmployee(manager)
    }
    
    function copyEmail(email: string) {
      navigator.clipboard.writeText(email)
      emailCopied.value = true
      setTimeout(() => {
        emailCopied.value = false
      }, 2000)
    }
    
    function adjustZoom(delta: number) {
      zoomLevel.value = Math.min(130, Math.max(70, zoomLevel.value + delta))
    }
    
    function resetZoom() {
      zoomLevel.value = 100
    }
    
    function isNodeDimmed(node: EmployeeNode): boolean {
      if (selectedDepartment.value !== 'All') {
        if (selectedDepartment.value === 'Executive' && node.department !== 'Executive') return true
        if (
          selectedDepartment.value === 'Engineering' &&
          node.department !== 'Engineering' &&
          node.department !== 'Executive'
        )
          return true
        if (selectedDepartment.value === 'Design' && node.department !== 'Design' && node.department !== 'Executive')
          return true
        if (selectedDepartment.value === 'Product' && node.department !== 'Product' && node.department !== 'Executive')
          return true
        if (
          selectedDepartment.value === 'Operations' &&
          node.department !== 'Operations' &&
          node.department !== 'Executive'
        )
          return true
      }
    
      if (searchQuery.value.trim().length > 0) {
        const isDirectMatch = matchingEmployeeIds.value.has(node.id)
        const hasMatchingDescendant = flatten(node).some((n) => matchingEmployeeIds.value.has(n.id))
        return !isDirectMatch && !hasMatchingDescendant
      }
    
      return false
    }
    
    function isNodeHighlighted(node: EmployeeNode): boolean {
      if (searchQuery.value.trim().length > 0) {
        return matchingEmployeeIds.value.has(node.id)
      }
      return false
    }
    </script>
    
    <template>
      <div
        data-slot="employee-org-chart-tree"
        :class="cn('bg-background border-border flex flex-col rounded-xl border shadow-xs', props.class)"
      >
        <!-- Header & Toolbar Controls -->
        <OrgChartToolbar
          :title="props.title"
          :subtitle="props.subtitle"
          :search-query="searchQuery"
          :is-drawer-open="isDrawerOpen"
          :department-counts="departmentCounts"
          :selected-department="selectedDepartment"
          :matching-count="matchingEmployeeIds.size"
          :zoom-level="zoomLevel"
          @update:search-query="searchQuery = $event"
          @update:is-drawer-open="isDrawerOpen = $event"
          @update:selected-department="selectedDepartment = $event"
          @expand-all="expandAll"
          @collapse-all="collapseAll"
          @adjust-zoom="adjustZoom"
          @reset-zoom="resetZoom"
        />
    
        <!-- Main Content: Canvas & Quick Info Drawer -->
        <div class="relative flex flex-1 flex-col lg:flex-row">
          <!-- Tree Hierarchy Node Canvas -->
          <div ref="canvasRef" class="bg-muted/10 relative flex-1 overflow-x-auto overflow-y-visible p-6 md:p-10">
            <div
              class="flex min-w-max flex-col items-center transition-transform duration-200"
              :style="{ transform: `scale(${zoomLevel / 100})`, transformOrigin: 'top center' }"
            >
              <!-- LEVEL 0: CEO Node -->
              <div class="flex flex-col items-center">
                <OrgChartNodeCard
                  :node="orgData"
                  :is-selected="selectedEmployeeId === orgData.id"
                  :is-highlighted="isNodeHighlighted(orgData)"
                  :is-dimmed="isNodeDimmed(orgData)"
                  :is-expanded="isExpanded(orgData.id)"
                  reports-label="Direct reports: 3 VPs"
                  :headcount-label="`${orgData.teamHeadcount} total org`"
                  expand-label="Expand Executive Team"
                  collapse-label="Collapse Executive Team"
                  @select="selectEmployee"
                  @toggle-expand="toggleExpand"
                />
    
                <!-- Connector stem down from CEO -->
                <div v-if="isExpanded(orgData.id) && orgData.children?.length" class="bg-border h-8 w-px" />
    
                <!-- LEVEL 1: VPs and Directors -->
                <div
                  v-if="isExpanded(orgData.id) && orgData.children?.length"
                  class="relative flex items-start gap-10 pt-0"
                >
                  <!-- Horizontal branching rail connecting Level 1 nodes -->
                  <div
                    v-if="orgData.children.length > 1"
                    class="bg-border absolute top-0 right-[16.666%] left-[16.666%] h-px"
                  />
    
                  <!-- Iterate Level 1 VPs -->
                  <div v-for="vp in orgData.children" :key="vp.id" class="flex flex-col items-center">
                    <!-- Top stem connecting rail to VP card -->
                    <div class="bg-border h-8 w-px" />
    
                    <!-- VP Node Card -->
                    <OrgChartNodeCard
                      :node="vp"
                      :is-selected="selectedEmployeeId === vp.id"
                      :is-highlighted="isNodeHighlighted(vp)"
                      :is-dimmed="isNodeDimmed(vp)"
                      :is-expanded="isExpanded(vp.id)"
                      :reports-label="`Direct reports: ${vp.reportsCount} teams`"
                      :headcount-label="`${vp.teamHeadcount} staff`"
                      :expand-label="`Expand (${vp.children?.length || 0} Leads)`"
                      collapse-label="Collapse Branch"
                      @select="selectEmployee"
                      @toggle-expand="toggleExpand"
                    />
    
                    <!-- Connector down from VP -->
                    <div v-if="isExpanded(vp.id) && vp.children?.length" class="bg-border h-8 w-px" />
    
                    <!-- LEVEL 2: Staff Leads & Managers -->
                    <div v-if="isExpanded(vp.id) && vp.children?.length" class="relative flex items-start gap-6 pt-0">
                      <!-- Horizontal rail for Level 2 nodes -->
                      <div
                        v-if="vp.children.length === 3"
                        class="bg-border absolute top-0 right-[16.666%] left-[16.666%] h-px"
                      />
                      <div
                        v-else-if="vp.children.length === 2"
                        class="bg-border absolute top-0 right-[25%] left-[25%] h-px"
                      />
    
                      <!-- Iterate Level 2 Leads -->
                      <div v-for="lead in vp.children" :key="lead.id" class="flex flex-col items-center">
                        <div class="bg-border h-8 w-px" />
    
                        <!-- Lead Node Card -->
                        <OrgChartNodeCard
                          :node="lead"
                          :is-selected="selectedEmployeeId === lead.id"
                          :is-highlighted="isNodeHighlighted(lead)"
                          :is-dimmed="isNodeDimmed(lead)"
                          :is-expanded="isExpanded(lead.id)"
                          :reports-label="`${lead.reportsCount} reports`"
                          :headcount-label="`${lead.teamHeadcount} members`"
                          :expand-label="`Team (${lead.children?.length || 0})`"
                          collapse-label="Collapse"
                          @select="selectEmployee"
                          @toggle-expand="toggleExpand"
                        />
    
                        <!-- Connector down from Lead -->
                        <div v-if="isExpanded(lead.id) && lead.children?.length" class="bg-border h-8 w-px" />
    
                        <!-- LEVEL 3: Team ICs / Senior Contributors -->
                        <div
                          v-if="isExpanded(lead.id) && lead.children?.length"
                          class="relative flex items-start gap-4 pt-0"
                        >
                          <div
                            v-if="lead.children.length === 2"
                            class="bg-border absolute top-0 right-[25%] left-[25%] h-px"
                          />
    
                          <!-- Iterate Level 3 ICs -->
                          <div v-for="ic in lead.children" :key="ic.id" class="flex flex-col items-center">
                            <div class="bg-border h-8 w-px" />
    
                            <!-- IC Card -->
                            <OrgChartNodeCard
                              :node="ic"
                              :is-selected="selectedEmployeeId === ic.id"
                              :is-highlighted="isNodeHighlighted(ic)"
                              :is-dimmed="isNodeDimmed(ic)"
                              :reports-label="`${ic.reportsCount} direct`"
                              :headcount-label="`${ic.teamHeadcount} member`"
                              @select="selectEmployee"
                            />
                          </div>
                        </div>
                      </div>
                    </div>
                  </div>
                </div>
              </div>
            </div>
          </div>
    
          <!-- Quick Info Side Profile Drawer -->
          <EmployeeProfileDrawer
            v-if="isDrawerOpen"
            :employee="selectedEmployee"
            :email-copied="emailCopied"
            :copy-email="copyEmail"
            :get-dept-badge-classes="getDeptBadgeClasses"
            @close="isDrawerOpen = false"
            @select-employee="selectEmployee"
            @view-manager="selectSelectedManager"
          />
        </div>
      </div>
    </template>
    
  • app/components/blocks/EmployeeProfileDrawer.vue7.9 kB
  • app/components/blocks/OrgChartToolbar.vue5.9 kB
  • app/components/blocks/OrgChartNodeCard.vue4.4 kB
  • app/components/blocks/org-chart-types.ts1.4 kB
  • app/components/blocks/org-chart-data.ts23.2 kB

Raw manifest:https://uipkge.dev/r/vue/employee-org-chart-tree.json