Menu

Change language

GitHub 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.

Installation

$npx uipkge-ng@latest add employee-org-chart-tree
Manifest:https://uipkge.dev/r/angular/employee-org-chart-tree.jsonInstalls to:src/app/components/blocks/employee-org-chart-tree/

Variants

Loading interactive previews…

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[]
}

npm dependencies

Files installed (4)

  • src/app/components/blocks/employee-org-chart-tree/employee-org-chart-tree.component.ts35.4 kB
    // 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. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, computed, signal } from '@angular/core'
    import {
      ArrowRight,
      Briefcase,
      Building2,
      Calendar,
      Check,
      ChevronDown,
      ChevronRight,
      Copy,
      Mail,
      MapPin,
      Minus,
      Plus,
      RotateCcw,
      Search,
      User,
      Users,
      X,
      ZoomIn,
      ZoomOut,
      LucideAngularModule,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiAvatarComponent, UiAvatarFallbackComponent, UiAvatarImageComponent } from '@/ui/avatar/avatar.component'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import { DEFAULT_ORG_DATA } from './org-chart-data'
    import { getDeptBadgeClasses, getDeptTopStripClasses, type EmployeeNode } from './org-chart-types'
    
    export type { EmployeeNode }
    
    function flatten(node: EmployeeNode): EmployeeNode[] {
      const result: EmployeeNode[] = [node]
      if (node.children) {
        for (const child of node.children) result.push(...flatten(child))
      }
      return result
    }
    
    export interface DepartmentCount {
      label: string
      value: string
      count: number
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-org-chart-node-card',
      standalone: true,
      imports: [
        LucideAngularModule,
        UiAvatarComponent,
        UiAvatarFallbackComponent,
        UiAvatarImageComponent,
        UiBadgeComponent,
        UiButtonComponent,
      ],
      template: `
        <div
          [class]="cardClass"
          tabindex="0"
          role="button"
          [attr.aria-label]="'Select ' + node.name"
          (click)="select.emit(node)"
          (keydown.enter)="select.emit(node)"
          (keydown.space)="select.emit(node); $event.preventDefault()"
        >
          <!-- Top department accent strip -->
          <div [class]="cn('h-1 w-full rounded-t-xl', stripClass())"></div>
    
          <div class="space-y-3 p-4">
            <!-- Identity row -->
            <div class="flex items-start justify-between gap-2.5">
              <div class="flex min-w-0 items-center gap-2.5">
                <div class="relative">
                  <ui-avatar class="border-border ring-background size-10 border ring-1">
                    <ui-avatar-image [src]="node.avatar" [alt]="node.name" />
                    <ui-avatar-fallback>{{ node.initials }}</ui-avatar-fallback>
                  </ui-avatar>
                  <span class="ring-background bg-success absolute right-0 bottom-0 size-2.5 rounded-full ring-2"></span>
                </div>
                <div class="min-w-0">
                  <h4 class="text-foreground truncate text-sm font-semibold">{{ node.name }}</h4>
                  <p class="text-muted-foreground truncate text-xs">{{ node.role }}</p>
                </div>
              </div>
              <span ui-badge variant="outline" [class]="cn('shrink-0 text-xs font-normal', badgeClass())">
                {{ node.department }}
              </span>
            </div>
    
            <!-- Contact & Location info -->
            <div class="text-muted-foreground space-y-1 text-xs">
              <div class="flex items-center gap-1.5">
                <lucide-icon [img]="MapPin" class="size-3.5 shrink-0" />
                <span class="truncate">{{ node.location }}</span>
              </div>
              <div class="flex items-center gap-1.5">
                <lucide-icon [img]="Mail" class="size-3.5 shrink-0" />
                <span class="truncate">{{ node.email }}</span>
              </div>
            </div>
    
            <!-- Team Reports Count Pill -->
            <div class="bg-muted/60 flex items-center justify-between rounded-lg px-2.5 py-1.5 text-xs">
              <div class="text-foreground flex items-center gap-1.5 font-medium">
                <lucide-icon [img]="Users" class="text-muted-foreground size-3.5" />
                <span>{{ reportsLabel || 'Direct reports: ' + node.reportsCount }}</span>
              </div>
              <span class="text-muted-foreground text-xs">{{ headcountLabel || node.teamHeadcount + ' staff' }}</span>
            </div>
    
            <!-- Expand/Collapse Button (if has children) -->
            @if (node.children && node.children.length > 0 && toggleExpand.observed) {
              <button
                ui-button
                variant="ghost"
                size="sm"
                class="text-muted-foreground hover:text-foreground h-7 w-full justify-between text-xs font-medium"
                (click)="onToggle($event)"
              >
                <span class="flex items-center gap-1.5">
                  @if (isExpanded) {
                    <lucide-icon [img]="ChevronDown" class="size-3.5" />
                  } @else {
                    <lucide-icon [img]="ChevronRight" class="size-3.5" />
                  }
                  <span>{{
                    isExpanded
                      ? collapseLabel || 'Collapse Branch'
                      : expandLabel || 'Expand (' + node.children!.length + ')'
                  }}</span>
                </span>
                <span class="bg-muted rounded px-1.5 py-0.5 text-xs">{{ node.children!.length }}</span>
              </button>
            }
          </div>
        </div>
      `,
    })
    export class UiOrgChartNodeCardComponent {
      protected readonly ChevronDown = ChevronDown
      protected readonly ChevronRight = ChevronRight
      protected readonly Mail = Mail
      protected readonly MapPin = MapPin
      protected readonly Users = Users
      protected readonly cn = cn
    
      @Input() node!: EmployeeNode
      @Input() isSelected = false
      @Input() isHighlighted = false
      @Input() isDimmed = false
      @Input() isExpanded = false
      @Input() reportsLabel?: string
      @Input() headcountLabel?: string
      @Input() expandLabel?: string
      @Input() collapseLabel?: string
      @Output() select = new EventEmitter<EmployeeNode>()
      @Output() toggleExpand = new EventEmitter<string>()
    
      badgeClass(): string {
        return getDeptBadgeClasses(this.node.department)
      }
    
      stripClass(): string {
        return getDeptTopStripClasses(this.node.department)
      }
    
      get cardClass(): string {
        return cn(
          'bg-card border-border hover:border-primary/50 focus-visible:ring-ring relative w-72 cursor-pointer rounded-xl border text-left shadow-xs transition-colors duration-200 outline-none hover:shadow-md focus-visible:ring-2',
          this.isSelected && 'ring-primary border-primary bg-primary/[0.02] ring-2',
          this.isHighlighted && 'border-success bg-success/10/20 ring-success dark:bg-success/10 ring-2',
          this.isDimmed && 'opacity-35 grayscale-[25%]',
        )
      }
    
      onToggle(event: Event): void {
        event.stopPropagation()
        this.toggleExpand.emit(this.node.id)
      }
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-org-chart-toolbar',
      standalone: true,
      imports: [LucideAngularModule, UiButtonComponent, UiInputComponent],
      template: `
        <div>
          <!-- Header Section -->
          <div class="border-border flex flex-col gap-4 border-b p-5 md:flex-row md:items-center md:justify-between">
            <div>
              <div class="flex items-center gap-2">
                <div class="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-lg">
                  <lucide-icon [img]="Building2" class="size-4" />
                </div>
                <h2 class="text-foreground text-lg font-semibold tracking-tight">{{ title }}</h2>
              </div>
              <p class="text-muted-foreground mt-1 text-xs">{{ subtitle }}</p>
            </div>
    
            <!-- Header Action Controls -->
            <div class="flex flex-wrap items-center gap-2">
              <div class="relative max-w-xs min-w-[220px]">
                <lucide-icon
                  [img]="Search"
                  class="text-muted-foreground absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2"
                />
                <ui-input
                  [value]="searchQuery"
                  (valueChange)="searchChange.emit($event)"
                  placeholder="Search employee, title, skill..."
                  class="h-8 pr-7 pl-8 text-xs"
                />
                @if (searchQuery) {
                  <button
                    type="button"
                    class="text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute top-1/2 right-2 -translate-y-1/2 rounded-sm outline-none focus-visible:ring-2"
                    aria-label="Clear search"
                    (click)="searchChange.emit('')"
                  >
                    <lucide-icon [img]="X" class="size-3.5" />
                  </button>
                }
              </div>
    
              <div class="bg-border hidden h-6 w-px md:block"></div>
    
              <button ui-button variant="outline" size="sm" class="h-8 gap-1.5 text-xs" (click)="expandAll.emit()">
                <lucide-icon [img]="Plus" class="size-3.5" />
                <span>Expand All</span>
              </button>
    
              <button ui-button variant="outline" size="sm" class="h-8 gap-1.5 text-xs" (click)="collapseAll.emit()">
                <lucide-icon [img]="Minus" class="size-3.5" />
                <span>Collapse All</span>
              </button>
    
              <button
                ui-button
                variant="outline"
                size="sm"
                [class]="cn('h-8 gap-1.5 text-xs', isDrawerOpen && 'bg-accent text-accent-foreground')"
                (click)="toggleDrawer.emit()"
              >
                <lucide-icon [img]="User" class="size-3.5" />
                <span>{{ isDrawerOpen ? 'Hide Profile' : 'View Profile' }}</span>
              </button>
            </div>
          </div>
    
          <!-- Filter & Toolbar Bar -->
          <div class="border-border bg-muted/20 flex flex-wrap items-center justify-between gap-3 border-b px-5 py-3">
            <!-- Department Filter Pills -->
            <div class="flex flex-wrap items-center gap-1.5">
              <span class="text-muted-foreground mr-1 text-xs font-medium">Department:</span>
              @for (dept of departmentCounts; track dept.value) {
                <button type="button" [class]="pillClass(dept.value)" (click)="departmentSelect.emit(dept.value)">
                  <span>{{ dept.label }}</span>
                  <span [class]="pillCountClass(dept.value)">{{ dept.count }}</span>
                </button>
              }
            </div>
    
            <!-- Zoom and Search Banner -->
            <div class="flex items-center gap-2">
              @if (searchQuery.trim()) {
                <div class="text-success text-xs">
                  Found {{ matchingCount }} matching member{{ matchingCount === 1 ? '' : 's' }}
                </div>
              }
    
              <div class="bg-background border-border flex items-center rounded-lg border p-0.5 shadow-2xs">
                <button
                  ui-button
                  variant="ghost"
                  size="icon"
                  class="size-6 text-xs"
                  [disabled]="zoomLevel <= 70"
                  aria-label="Zoom out"
                  (click)="adjustZoom.emit(-10)"
                >
                  <lucide-icon [img]="ZoomOut" class="size-3" />
                </button>
                <span class="text-muted-foreground w-10 text-center text-xs font-medium">{{ zoomLevel }}%</span>
                <button
                  ui-button
                  variant="ghost"
                  size="icon"
                  class="size-6 text-xs"
                  [disabled]="zoomLevel >= 130"
                  aria-label="Zoom in"
                  (click)="adjustZoom.emit(10)"
                >
                  <lucide-icon [img]="ZoomIn" class="size-3" />
                </button>
                <div class="bg-border mx-0.5 h-3.5 w-px"></div>
                <button
                  ui-button
                  variant="ghost"
                  size="icon"
                  class="size-6 text-xs"
                  aria-label="Reset zoom"
                  (click)="resetZoom.emit()"
                >
                  <lucide-icon [img]="RotateCcw" class="size-3" />
                </button>
              </div>
            </div>
          </div>
        </div>
      `,
    })
    export class UiOrgChartToolbarComponent {
      protected readonly Building2 = Building2
      protected readonly Minus = Minus
      protected readonly Plus = Plus
      protected readonly RotateCcw = RotateCcw
      protected readonly Search = Search
      protected readonly User = User
      protected readonly X = X
      protected readonly ZoomIn = ZoomIn
      protected readonly ZoomOut = ZoomOut
      protected readonly cn = cn
    
      @Input() title = ''
      @Input() subtitle = ''
      @Input() searchQuery = ''
      @Input() isDrawerOpen = false
      @Input() departmentCounts: DepartmentCount[] = []
      @Input() selectedDepartment = 'All'
      @Input() matchingCount = 0
      @Input() zoomLevel = 100
      @Output() searchChange = new EventEmitter<string>()
      @Output() toggleDrawer = new EventEmitter<void>()
      @Output() departmentSelect = new EventEmitter<string>()
      @Output() expandAll = new EventEmitter<void>()
      @Output() collapseAll = new EventEmitter<void>()
      @Output() adjustZoom = new EventEmitter<number>()
      @Output() resetZoom = new EventEmitter<void>()
    
      pillClass(value: string): string {
        return cn(
          'focus-visible:ring-ring inline-flex h-7 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none',
          this.selectedDepartment === value
            ? 'bg-primary text-primary-foreground shadow-xs'
            : 'bg-background hover:bg-muted text-muted-foreground border-border border',
        )
      }
    
      pillCountClass(value: string): string {
        return cn(
          'py-0.2 rounded-full px-1.5 text-xs',
          this.selectedDepartment === value
            ? 'bg-primary-foreground/20 text-primary-foreground'
            : 'bg-muted text-muted-foreground',
        )
      }
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-employee-profile-drawer',
      standalone: true,
      imports: [
        LucideAngularModule,
        UiAvatarComponent,
        UiAvatarFallbackComponent,
        UiAvatarImageComponent,
        UiBadgeComponent,
        UiButtonComponent,
      ],
      template: `
        <div class="border-border bg-card flex w-full shrink-0 flex-col border-t lg:w-96 lg:border-t-0 lg:border-l">
          <!-- Drawer Header -->
          <div class="border-border flex items-center justify-between border-b p-4">
            <div class="flex items-center gap-2">
              <lucide-icon [img]="User" class="text-primary size-4" />
              <h3 class="text-foreground text-sm font-semibold">Employee Profile</h3>
            </div>
            <button
              ui-button
              variant="ghost"
              size="icon"
              class="size-7"
              aria-label="Close profile drawer"
              (click)="close.emit()"
            >
              <lucide-icon [img]="X" class="size-4" />
            </button>
          </div>
    
          <!-- Drawer Body -->
          <div class="max-h-[700px] flex-1 space-y-5 overflow-y-auto p-5">
            <!-- Profile Identity Header -->
            <div class="flex items-start gap-3.5">
              <div class="relative">
                <ui-avatar class="border-border ring-background size-14 border ring-2">
                  <ui-avatar-image [src]="employee.avatar" [alt]="employee.name" />
                  <ui-avatar-fallback>{{ employee.initials }}</ui-avatar-fallback>
                </ui-avatar>
                <span class="ring-background bg-success absolute right-0 bottom-0 size-3 rounded-full ring-2"></span>
              </div>
              <div class="min-w-0 flex-1">
                <h4 class="text-foreground text-base font-semibold tracking-tight">{{ employee.name }}</h4>
                <p class="text-muted-foreground text-xs">{{ employee.role }}</p>
                <div class="mt-2 flex flex-wrap items-center gap-1.5">
                  <span ui-badge variant="outline" [class]="cn('text-xs font-medium', deptClass())">{{
                    employee.department
                  }}</span>
                  <span ui-badge variant="secondary" class="text-xs font-normal">Active · Full-time</span>
                </div>
              </div>
            </div>
    
            <!-- Quick Action Buttons -->
            <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
              <a ui-button variant="outline" size="sm" class="h-8 gap-1.5 text-xs" [href]="'mailto:' + employee.email">
                <lucide-icon [img]="Mail" class="size-3.5" />
                <span>Send Email</span>
              </a>
              <button
                ui-button
                variant="outline"
                size="sm"
                class="h-8 gap-1.5 text-xs"
                (click)="copyEmail.emit(employee.email)"
              >
                @if (emailCopied) {
                  <lucide-icon [img]="Check" class="text-success size-3.5" />
                } @else {
                  <lucide-icon [img]="Copy" class="size-3.5" />
                }
                <span>{{ emailCopied ? 'Copied!' : 'Copy Email' }}</span>
              </button>
            </div>
    
            <div class="bg-border h-px"></div>
    
            <!-- Bio -->
            <div class="space-y-1.5">
              <h5 class="text-foreground text-xs font-semibold tracking-wide uppercase">About & Focus</h5>
              <p class="text-muted-foreground text-xs leading-relaxed">{{ employee.bio }}</p>
            </div>
    
            <!-- Key Details Grid -->
            <div class="space-y-1.5">
              <h5 class="text-foreground text-xs font-semibold tracking-wide uppercase">Overview Details</h5>
              <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
                <div class="bg-muted/40 border-border/60 rounded-lg border p-2.5">
                  <div class="text-muted-foreground flex items-center gap-1 text-xs">
                    <lucide-icon [img]="Users" class="size-3" />
                    <span>Team Scope</span>
                  </div>
                  <p class="text-foreground mt-1 text-xs font-semibold">{{ employee.teamHeadcount }} Members</p>
                </div>
                <div class="bg-muted/40 border-border/60 rounded-lg border p-2.5">
                  <div class="text-muted-foreground flex items-center gap-1 text-xs">
                    <lucide-icon [img]="MapPin" class="size-3" />
                    <span>Location</span>
                  </div>
                  <p class="text-foreground mt-1 truncate text-xs font-semibold">{{ employee.location }}</p>
                </div>
                <div class="bg-muted/40 border-border/60 rounded-lg border p-2.5">
                  <div class="text-muted-foreground flex items-center gap-1 text-xs">
                    <lucide-icon [img]="Calendar" class="size-3" />
                    <span>Tenure</span>
                  </div>
                  <p class="text-foreground mt-1 text-xs font-semibold">{{ employee.tenure }}</p>
                </div>
                <div class="bg-muted/40 border-border/60 rounded-lg border p-2.5">
                  <div class="text-muted-foreground flex items-center gap-1 text-xs">
                    <lucide-icon [img]="Briefcase" class="size-3" />
                    <span>Phone</span>
                  </div>
                  <p class="text-foreground mt-1 truncate text-xs font-semibold">{{ employee.phone }}</p>
                </div>
              </div>
            </div>
    
            <!-- Reporting Manager Context -->
            @if (employee.managerName) {
              <div class="space-y-1.5">
                <h5 class="text-foreground text-xs font-semibold tracking-wide uppercase">Reports To</h5>
                <div class="border-border/80 bg-muted/20 flex items-center justify-between rounded-lg border p-2.5">
                  <div class="min-w-0">
                    <p class="text-foreground text-xs font-medium">{{ employee.managerName }}</p>
                    <p class="text-muted-foreground text-xs">{{ employee.managerRole }}</p>
                  </div>
                  <button
                    ui-button
                    variant="ghost"
                    size="sm"
                    class="h-7 gap-1 text-xs"
                    (click)="viewManager.emit(employee.managerId)"
                  >
                    <span>View</span>
                    <lucide-icon [img]="ArrowRight" class="size-3" />
                  </button>
                </div>
              </div>
            }
    
            <!-- Direct Reports Roster -->
            @if (employee.children && employee.children.length > 0) {
              <div class="space-y-2">
                <div class="flex items-center justify-between">
                  <h5 class="text-foreground text-xs font-semibold tracking-wide uppercase">
                    Direct Reports ({{ employee.children!.length }})
                  </h5>
                  <span class="text-muted-foreground text-xs font-medium">{{ employee.reportsCount }} reporting teams</span>
                </div>
    
                <div class="space-y-1.5">
                  @for (sub of employee.children; track sub.id) {
                    <div
                      class="border-border/60 bg-muted/20 hover:bg-muted/40 flex cursor-pointer items-center justify-between rounded-lg border p-2 transition-colors"
                      (click)="selectEmployee.emit(sub)"
                    >
                      <div class="flex min-w-0 items-center gap-2">
                        <ui-avatar class="border-border size-7 border">
                          <ui-avatar-image [src]="sub.avatar" [alt]="sub.name" />
                          <ui-avatar-fallback>{{ sub.initials }}</ui-avatar-fallback>
                        </ui-avatar>
                        <div class="min-w-0">
                          <p class="text-foreground truncate text-xs font-medium">{{ sub.name }}</p>
                          <p class="text-muted-foreground truncate text-xs">{{ sub.role }}</p>
                        </div>
                      </div>
                      <span
                        ui-badge
                        variant="outline"
                        [class]="cn('shrink-0 px-1.5 py-0 text-xs', deptClassFor(sub.department))"
                      >
                        {{ sub.teamHeadcount }} staff
                      </span>
                    </div>
                  }
                </div>
              </div>
            }
    
            <!-- Skills & Competencies -->
            <div class="space-y-1.5">
              <h5 class="text-foreground text-xs font-semibold tracking-wide uppercase">Core Competencies</h5>
              <div class="flex flex-wrap gap-1.5">
                @for (skill of employee.skills; track skill) {
                  <span ui-badge variant="secondary" class="text-xs font-normal">{{ skill }}</span>
                }
              </div>
            </div>
          </div>
        </div>
      `,
    })
    export class UiEmployeeProfileDrawerComponent {
      protected readonly ArrowRight = ArrowRight
      protected readonly Briefcase = Briefcase
      protected readonly Calendar = Calendar
      protected readonly Check = Check
      protected readonly Copy = Copy
      protected readonly Mail = Mail
      protected readonly MapPin = MapPin
      protected readonly User = User
      protected readonly Users = Users
      protected readonly X = X
      protected readonly cn = cn
    
      @Input() employee!: EmployeeNode
      @Input() emailCopied = false
      @Output() copyEmail = new EventEmitter<string>()
      @Output() close = new EventEmitter<void>()
      @Output() selectEmployee = new EventEmitter<EmployeeNode>()
      @Output() viewManager = new EventEmitter<string | undefined>()
    
      deptClass(): string {
        return getDeptBadgeClasses(this.employee.department)
      }
    
      deptClassFor(dept: string): string {
        return getDeptBadgeClasses(dept)
      }
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-employee-org-chart-tree, [ui-employee-org-chart-tree]',
      standalone: true,
      // React renders the root <div> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [UiOrgChartNodeCardComponent, UiOrgChartToolbarComponent, UiEmployeeProfileDrawerComponent],
      template: `
        <div data-slot="employee-org-chart-tree" [class]="rootClass">
          <!-- Header & Toolbar Controls -->
          <ui-org-chart-toolbar
            [title]="titleState()"
            [subtitle]="subtitleState()"
            [searchQuery]="searchQuery()"
            [isDrawerOpen]="isDrawerOpen()"
            [departmentCounts]="departmentCounts()"
            [selectedDepartment]="selectedDepartment()"
            [matchingCount]="matchingIds().length"
            [zoomLevel]="zoomLevel()"
            (searchChange)="onSearchChange($event)"
            (toggleDrawer)="isDrawerOpen.set(!isDrawerOpen())"
            (departmentSelect)="selectedDepartment.set($event)"
            (expandAll)="expandAll()"
            (collapseAll)="expandedIds.set([])"
            (adjustZoom)="adjustZoom($event)"
            (resetZoom)="zoomLevel.set(100)"
          />
    
          <!-- Main Content: Canvas & Quick Info Drawer -->
          <div class="relative flex flex-1 flex-col lg:flex-row">
            <!-- Tree Hierarchy Node Canvas -->
            <div 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 + ')'"
                style="transform-origin: top center"
              >
                <!-- LEVEL 0: CEO Node -->
                <div class="flex flex-col items-center">
                  <ui-org-chart-node-card
                    [node]="org()"
                    [isSelected]="selectedEmployeeId() === org().id"
                    [isHighlighted]="isHighlighted(org())"
                    [isDimmed]="isDimmed(org())"
                    [isExpanded]="isExpanded(org().id)"
                    reportsLabel="Direct reports: 3 VPs"
                    [headcountLabel]="org().teamHeadcount + ' total org'"
                    expandLabel="Expand Executive Team"
                    collapseLabel="Collapse Executive Team"
                    (select)="selectEmployee($event)"
                    (toggleExpand)="toggleExpand($event)"
                  />
    
                  @if (isExpanded(org().id) && org().children?.length) {
                    <div class="bg-border h-8 w-px"></div>
    
                    <!-- LEVEL 1: VPs and Directors -->
                    <div class="relative flex items-start gap-10 pt-0">
                      @if ((org().children?.length ?? 0) > 1) {
                        <div class="bg-border absolute top-0 right-[16.666%] left-[16.666%] h-px"></div>
                      }
    
                      @for (vp of org().children; track vp.id) {
                        <div class="flex flex-col items-center">
                          <div class="bg-border h-8 w-px"></div>
    
                          <ui-org-chart-node-card
                            [node]="vp"
                            [isSelected]="selectedEmployeeId() === vp.id"
                            [isHighlighted]="isHighlighted(vp)"
                            [isDimmed]="isDimmed(vp)"
                            [isExpanded]="isExpanded(vp.id)"
                            [reportsLabel]="'Direct reports: ' + vp.reportsCount + ' teams'"
                            [headcountLabel]="vp.teamHeadcount + ' staff'"
                            [expandLabel]="'Expand (' + (vp.children?.length || 0) + ' Leads)'"
                            collapseLabel="Collapse Branch"
                            (select)="selectEmployee($event)"
                            (toggleExpand)="toggleExpand($event)"
                          />
    
                          @if (isExpanded(vp.id) && vp.children?.length) {
                            <div class="bg-border h-8 w-px"></div>
    
                            <!-- LEVEL 2: Staff Leads & Managers -->
                            <div class="relative flex items-start gap-6 pt-0">
                              @if (vp.children?.length === 3) {
                                <div class="bg-border absolute top-0 right-[16.666%] left-[16.666%] h-px"></div>
                              }
                              @if (vp.children?.length === 2) {
                                <div class="bg-border absolute top-0 right-[25%] left-[25%] h-px"></div>
                              }
    
                              @for (lead of vp.children; track lead.id) {
                                <div class="flex flex-col items-center">
                                  <div class="bg-border h-8 w-px"></div>
    
                                  <ui-org-chart-node-card
                                    [node]="lead"
                                    [isSelected]="selectedEmployeeId() === lead.id"
                                    [isHighlighted]="isHighlighted(lead)"
                                    [isDimmed]="isDimmed(lead)"
                                    [isExpanded]="isExpanded(lead.id)"
                                    [reportsLabel]="lead.reportsCount + ' reports'"
                                    [headcountLabel]="lead.teamHeadcount + ' members'"
                                    [expandLabel]="'Team (' + (lead.children?.length || 0) + ')'"
                                    collapseLabel="Collapse"
                                    (select)="selectEmployee($event)"
                                    (toggleExpand)="toggleExpand($event)"
                                  />
    
                                  @if (isExpanded(lead.id) && lead.children?.length) {
                                    <div class="bg-border h-8 w-px"></div>
    
                                    <!-- LEVEL 3: Team ICs -->
                                    <div class="relative flex items-start gap-4 pt-0">
                                      @if (lead.children?.length === 2) {
                                        <div class="bg-border absolute top-0 right-[25%] left-[25%] h-px"></div>
                                      }
    
                                      @for (ic of lead.children; track ic.id) {
                                        <div class="flex flex-col items-center">
                                          <div class="bg-border h-8 w-px"></div>
    
                                          <ui-org-chart-node-card
                                            [node]="ic"
                                            [isSelected]="selectedEmployeeId() === ic.id"
                                            [isHighlighted]="isHighlighted(ic)"
                                            [isDimmed]="isDimmed(ic)"
                                            [reportsLabel]="ic.reportsCount + ' direct'"
                                            [headcountLabel]="ic.teamHeadcount + ' member'"
                                            (select)="selectEmployee($event)"
                                          />
                                        </div>
                                      }
                                    </div>
                                  }
                                </div>
                              }
                            </div>
                          }
                        </div>
                      }
                    </div>
                  }
                </div>
              </div>
            </div>
    
            <!-- Quick Info Side Profile Drawer -->
            @if (isDrawerOpen()) {
              <ui-employee-profile-drawer
                [employee]="selectedEmployee()"
                [emailCopied]="emailCopied()"
                (copyEmail)="copyEmail($event)"
                (close)="isDrawerOpen.set(false)"
                (selectEmployee)="selectEmployee($event)"
                (viewManager)="viewManager($event)"
              />
            }
          </div>
        </div>
      `,
    })
    export class UiEmployeeOrgChartTreeComponent {
      @Input() set title(v: string | undefined) {
        if (v !== undefined) this.titleState.set(v)
      }
      @Input() set subtitle(v: string | undefined) {
        if (v !== undefined) this.subtitleState.set(v)
      }
      @Input() set orgData(v: EmployeeNode | undefined) {
        if (v) this.orgState.set(v)
      }
      @Input() set initialSelectedId(v: string | undefined) {
        if (v !== undefined) this.selectedEmployeeId.set(v)
      }
      @Input() set initialDepartment(v: string | undefined) {
        if (v !== undefined) this.selectedDepartment.set(v)
      }
      @Input() set initialSearch(v: string | undefined) {
        if (v !== undefined) this.searchQuery.set(v)
      }
      @Input() set initialExpandedIds(v: string[] | undefined) {
        if (v) this.expandedIds.set([...v])
      }
      @Input() set initialDrawerOpen(v: boolean | undefined) {
        if (v !== undefined) this.isDrawerOpen.set(v)
      }
      @Input('class') className?: string
    
      readonly titleState = signal('Company Organizational Chart')
      readonly subtitleState = signal('148 Employees across 5 Departments')
      readonly orgState = signal<EmployeeNode>(DEFAULT_ORG_DATA)
    
      readonly selectedEmployeeId = signal('emp-ceo')
      readonly selectedDepartment = signal('All')
      readonly searchQuery = signal('')
      readonly isDrawerOpen = signal(true)
      readonly zoomLevel = signal(100)
      readonly emailCopied = signal(false)
      readonly expandedIds = signal<string[]>(['emp-ceo', 'emp-eng-vp', 'emp-prod-vp', 'emp-ops-vp'])
    
      private copyTimer: ReturnType<typeof setTimeout> | undefined
    
      org(): EmployeeNode {
        return this.orgState()
      }
    
      readonly allEmployees = computed(() => flatten(this.orgState()))
    
      readonly departmentCounts = computed<DepartmentCount[]>(() => {
        const all = this.allEmployees()
        const count = (dept: string) => all.filter((e) => e.department === dept).length
        return [
          { label: 'All', value: 'All', count: all.length },
          { label: 'Executive', value: 'Executive', count: count('Executive') },
          { label: 'Engineering', value: 'Engineering', count: count('Engineering') },
          { label: 'Design', value: 'Design', count: count('Design') },
          { label: 'Product', value: 'Product', count: count('Product') },
          { label: 'Operations', value: 'Operations', count: count('Operations') },
        ]
      })
    
      readonly selectedEmployee = computed<EmployeeNode>(() => {
        return this.allEmployees().find((e) => e.id === this.selectedEmployeeId()) ?? this.orgState()
      })
    
      readonly matchingIds = computed<string[]>(() => {
        const q = this.searchQuery().trim().toLowerCase()
        if (!q) return []
        return this.allEmployees()
          .filter(
            (emp) =>
              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)),
          )
          .map((e) => e.id)
      })
    
      get rootClass(): string {
        return cn('bg-background border-border flex flex-col rounded-xl border shadow-xs', this.className)
      }
    
      isExpanded(id: string): boolean {
        return this.expandedIds().includes(id)
      }
    
      toggleExpand(id: string): void {
        this.expandedIds.update((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
      }
    
      expandAll(): void {
        this.expandedIds.set(
          this.allEmployees()
            .filter((e) => e.children?.length)
            .map((e) => e.id),
        )
      }
    
      onSearchChange(value: string): void {
        this.searchQuery.set(value)
        if (value.trim()) this.expandAll()
      }
    
      selectEmployee(emp: EmployeeNode): void {
        this.selectedEmployeeId.set(emp.id)
        this.isDrawerOpen.set(true)
      }
    
      viewManager(managerId: string | undefined): void {
        if (!managerId) return
        const manager = this.allEmployees().find((e) => e.id === managerId)
        if (manager) this.selectEmployee(manager)
      }
    
      copyEmail(email: string): void {
        try {
          void navigator.clipboard?.writeText(email)
        } catch {
          // Clipboard fallback
        }
        this.emailCopied.set(true)
        clearTimeout(this.copyTimer)
        this.copyTimer = setTimeout(() => this.emailCopied.set(false), 2000)
      }
    
      adjustZoom(delta: number): void {
        this.zoomLevel.update((prev) => Math.min(130, Math.max(70, prev + delta)))
      }
    
      isDimmed(node: EmployeeNode): boolean {
        const dept = this.selectedDepartment()
        if (dept !== 'All') {
          if (dept === 'Executive' && node.department !== 'Executive') return true
          if (dept === 'Engineering' && node.department !== 'Engineering' && node.department !== 'Executive') return true
          if (dept === 'Design' && node.department !== 'Design' && node.department !== 'Executive') return true
          if (dept === 'Product' && node.department !== 'Product' && node.department !== 'Executive') return true
          if (dept === 'Operations' && node.department !== 'Operations' && node.department !== 'Executive') return true
        }
        if (this.searchQuery().trim().length > 0) {
          const matches = new Set(this.matchingIds())
          const isDirectMatch = matches.has(node.id)
          const hasMatchingDescendant = flatten(node).some((n) => matches.has(n.id))
          return !isDirectMatch && !hasMatchingDescendant
        }
        return false
      }
    
      isHighlighted(node: EmployeeNode): boolean {
        if (this.searchQuery().trim().length > 0) return this.matchingIds().includes(node.id)
        return false
      }
    }
    
  • src/app/components/blocks/employee-org-chart-tree/org-chart-types.ts1.4 kB
  • src/app/components/blocks/employee-org-chart-tree/org-chart-data.ts23.2 kB
  • src/app/components/blocks/employee-org-chart-tree/index.ts0.4 kB

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