Bluewoo HRMS
Micro-Step Build PlanBuilding Blocks

Phase 04: Org Chart Visualization

Interactive org chart using React Flow with zoom, pan, search, and drag-to-reassign

Phase 04: Org Chart Visualization

Goal: Build an interactive organizational chart using React Flow that displays employee hierarchy with zoom/pan, search, and optional drag-to-reassign manager functionality.

AttributeValue
Steps63-72
Estimated Time5-8 hours
DependenciesPhase 03 complete (Org structure with manager relationships)
Completion GateOrg chart renders with employee hierarchy, supports zoom/pan, search, and click-to-view employee

Step Timing Estimates

StepTaskEst. Time
63Install React Flow15 min
64Create org chart data API endpoint30 min
65Create EmployeeNode component25 min
66Create OrgChart component35 min
67Add org chart page to dashboard20 min
68Add zoom and pan controls20 min
69Add click-to-view employee detail25 min
70Add drag-to-reassign manager35 min
71Add department filter25 min
72Add search in org chart30 min

Phase Context (READ FIRST)

What This Phase Accomplishes

  • React Flow installed and configured
  • API endpoint that returns hierarchical org data for visualization
  • Custom EmployeeNode component with avatar, name, and job title
  • Interactive org chart with zoom, pan, and minimap
  • Click employee to view profile (slide-out panel or navigation)
  • Drag-and-drop to reassign manager relationships
  • Filter org chart by department
  • Search to find and highlight employees in the tree

What This Phase Does NOT Include

  • Printing/exporting org chart to PDF - future enhancement
  • Multiple org chart layouts (tree, radial) - stick with vertical tree
  • Animation transitions between views - keep simple
  • Real-time collaboration - single-user view
  • Org chart permissions (who can edit) - Phase later

Bluewoo Anti-Pattern Reminder

This phase intentionally has NO:

  • Custom chart rendering (D3.js, Canvas) - use React Flow
  • Complex state management (Redux) - TanStack Query + local state
  • Multiple visualization libraries - React Flow only
  • Backend-driven layout calculation - let React Flow handle positioning
  • Hardcoded department lists - load from API dynamically

If the AI suggests adding any of these, REJECT and continue with the spec.


Step 63: Install React Flow and Setup Prerequisites

Input

  • Phase 03 complete
  • Next.js app at apps/web

Constraints

  • DO NOT install additional visualization libraries
  • DO NOT modify any backend files
  • Install React Flow and TanStack Query

Task

cd apps/web

# Install React Flow
npm install @xyflow/react

# Install TanStack Query (if not already installed)
npm install @tanstack/react-query

# React Flow v12+ includes types, no separate @types package needed

Create apps/web/app/providers.tsx (TanStack Query Provider):

'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SessionProvider } from 'next-auth/react';
import { useState } from 'react';

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 60 * 1000, // 1 minute
            refetchOnWindowFocus: false,
          },
        },
      })
  );

  return (
    <SessionProvider>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </SessionProvider>
  );
}

Update apps/web/app/layout.tsx to use the Providers:

// In your root layout, wrap children with Providers:
import { Providers } from './providers';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

Create apps/web/lib/api.ts (API helper with tenant context):

/**
 * API helper for CLIENT-SIDE calls only.
 * Uses getSession() from next-auth/react.
 *
 * For server components or route handlers, use auth() or getServerSession() instead.
 */
import { getSession } from 'next-auth/react';

const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';

async function getTenantId(): Promise<string> {
  const session = await getSession();
  if (!session?.user?.tenantId) {
    throw new Error('No tenant context available');
  }
  return session.user.tenantId;
}

export const api = {
  async get<T = unknown>(path: string): Promise<{ data: T; error: null }> {
    const tenantId = await getTenantId();
    const res = await fetch(`${API_URL}/api/v1${path}`, {
      headers: {
        'x-tenant-id': tenantId,
      },
    });
    if (!res.ok) {
      throw new Error(`API request failed: ${res.status}`);
    }
    return res.json();
  },

  async post<T = unknown>(path: string, body: unknown): Promise<{ data: T; error: null }> {
    const tenantId = await getTenantId();
    const res = await fetch(`${API_URL}/api/v1${path}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-tenant-id': tenantId,
      },
      body: JSON.stringify(body),
    });
    if (!res.ok) {
      throw new Error(`API request failed: ${res.status}`);
    }
    return res.json();
  },

  async put<T = unknown>(path: string, body: unknown): Promise<{ data: T; error: null }> {
    const tenantId = await getTenantId();
    const res = await fetch(`${API_URL}/api/v1${path}`, {
      method: 'PUT',
      headers: {
        'Content-Type': 'application/json',
        'x-tenant-id': tenantId,
      },
      body: JSON.stringify(body),
    });
    if (!res.ok) {
      throw new Error(`API request failed: ${res.status}`);
    }
    return res.json();
  },

  async delete<T = unknown>(path: string): Promise<{ data: T; error: null }> {
    const tenantId = await getTenantId();
    const res = await fetch(`${API_URL}/api/v1${path}`, {
      method: 'DELETE',
      headers: {
        'x-tenant-id': tenantId,
      },
    });
    if (!res.ok) {
      throw new Error(`API request failed: ${res.status}`);
    }
    return res.json();
  },

  async patch<T = unknown>(path: string, body: unknown): Promise<{ data: T; error: null }> {
    const tenantId = await getTenantId();
    const res = await fetch(`${API_URL}/api/v1${path}`, {
      method: 'PATCH',
      headers: {
        'Content-Type': 'application/json',
        'x-tenant-id': tenantId,
      },
      body: JSON.stringify(body),
    });
    if (!res.ok) {
      const errorBody = await res.json().catch(() => ({}));
      throw new Error(errorBody.error?.message || `API request failed: ${res.status}`);
    }
    return res.json();
  },
};

Create apps/web/components/org-chart/index.ts:

// Barrel export for org chart components
// Components will be added in subsequent steps
export {};

Gate

cd apps/web

# Verify React Flow installation
cat package.json | grep "@xyflow/react"
# Should show: "@xyflow/react": "^12.x.x"

# Verify TanStack Query installation
cat package.json | grep "@tanstack/react-query"
# Should show: "@tanstack/react-query": "^5.x.x"

# Verify files exist
ls -la app/providers.tsx lib/api.ts components/org-chart/index.ts
# Should show all files

# Verify build works
npm run build
# Should build without errors

Common Errors

ErrorCauseFix
Module not found: @xyflow/reactNot installedRun npm install @xyflow/react
Module not found: @tanstack/react-queryNot installedRun npm install @tanstack/react-query
Type errorsOld React Flow versionEnsure v12+ installed
getSession not foundnext-auth not configuredEnsure Phase 01 is complete

Rollback

npm uninstall @xyflow/react @tanstack/react-query
rm -rf apps/web/components/org-chart
rm apps/web/app/providers.tsx
rm apps/web/lib/api.ts

Lock

apps/web/package.json (@xyflow/react, @tanstack/react-query dependencies)
apps/web/app/providers.tsx
apps/web/lib/api.ts

Checkpoint

  • @xyflow/react installed
  • @tanstack/react-query installed
  • providers.tsx created with QueryClientProvider
  • lib/api.ts created with tenant-aware API helper
  • org-chart folder created
  • npm run build succeeds

Step 64: Create Org Chart Data API Endpoint

Input

  • Step 63 complete
  • OrgRepository and OrgService from Phase 03

Constraints

  • DO NOT create new Prisma models
  • DO NOT modify existing repository methods
  • Use existing getDirectReports from Phase 03
  • Return flat node/edge format for React Flow

Task

Add to apps/api/src/org/org.repository.ts:

// Add this import at the top of org.repository.ts
import { Prisma } from '@prisma/client';

// Add this method to OrgRepository class

  // Get all employees with their primary manager for org chart
  async getOrgChartData(tenantId: string, options?: {
    departmentId?: string;
  }) {
    const where: Prisma.EmployeeWhereInput = {
      tenantId,
      status: { not: 'TERMINATED' },
    };

    // Filter by department if specified
    if (options?.departmentId) {
      where.orgRelations = {
        departments: {
          some: {
            departmentId: options.departmentId,
          },
        },
      };
    }

    const employees = await this.prisma.employee.findMany({
      where,
      select: {
        id: true,
        firstName: true,
        lastName: true,
        jobTitle: true,
        email: true,
        pictureUrl: true,
        status: true,
        orgRelations: {
          select: {
            primaryManagerId: true,
            departments: {
              where: { isPrimary: true },
              select: {
                department: {
                  select: { id: true, name: true },
                },
              },
            },
          },
        },
      },
      orderBy: [
        { lastName: 'asc' },
        { firstName: 'asc' },
      ],
    });

    // Transform to org chart format
    return employees.map(emp => ({
      id: emp.id,
      firstName: emp.firstName,
      lastName: emp.lastName,
      fullName: `${emp.firstName} ${emp.lastName}`,
      jobTitle: emp.jobTitle,
      email: emp.email,
      pictureUrl: emp.pictureUrl,
      status: emp.status,
      managerId: emp.orgRelations?.primaryManagerId ?? null,
      department: emp.orgRelations?.departments[0]?.department ?? null,
    }));
  }

Add to apps/api/src/org/org.service.ts:

// Add this method to OrgService class

  async getOrgChartData(tenantId: string, options?: {
    departmentId?: string;
  }) {
    const employees = await this.repository.getOrgChartData(tenantId, options);

    // Convert to React Flow nodes and edges
    const nodes = employees.map(emp => ({
      id: emp.id,
      type: 'employee',
      position: { x: 0, y: 0 }, // React Flow will calculate positions
      data: {
        id: emp.id,
        firstName: emp.firstName,
        lastName: emp.lastName,
        fullName: emp.fullName,
        jobTitle: emp.jobTitle,
        email: emp.email,
        pictureUrl: emp.pictureUrl,
        status: emp.status,
        department: emp.department,
      },
    }));

    const edges = employees
      .filter(emp => emp.managerId)
      .map(emp => ({
        id: `${emp.managerId}-${emp.id}`,
        source: emp.managerId!,
        target: emp.id,
        type: 'smoothstep',
      }));

    return { nodes, edges };
  }

Add endpoint to apps/api/src/org/org.controller.ts:

// Add this import at top
import { Query } from '@nestjs/common';
import { TenantId } from '../common/decorators';

// Add this method to OrgController class

  @Get('chart')
  async getOrgChart(
    @TenantId() tenantId: string,
    @Query('departmentId') departmentId?: string,
  ) {
    const data = await this.service.getOrgChartData(tenantId, {
      departmentId,
    });
    return { data, error: null };
  }

Gate

cd apps/api
npm run build
# Should build without errors

# Test endpoint (after starting API)
curl http://localhost:3001/api/v1/org/employees/chart \
  -H "x-tenant-id: <your-tenant-id>"
# Should return: { data: { nodes: [...], edges: [...] }, error: null }

Common Errors

ErrorCauseFix
Property 'orgRelations' does not existRelation not includedCheck include statement
Empty nodes arrayNo employeesCreate test employees first

Rollback

# Remove the new methods from org.repository.ts, org.service.ts, org.controller.ts

Lock

apps/api/src/org/org.repository.ts (getOrgChartData method)
apps/api/src/org/org.service.ts (getOrgChartData method)
apps/api/src/org/org.controller.ts (chart endpoint)

Checkpoint

  • getOrgChartData repository method works
  • Service returns nodes and edges
  • GET /api/v1/org/employees/chart endpoint returns data
  • TypeScript compiles

Step 65: Create EmployeeNode Component

Input

  • Step 64 complete
  • React Flow installed

Constraints

  • DO NOT use external avatar libraries
  • Use Shadcn Avatar component
  • Keep styling with Tailwind CSS

Task

Create apps/web/components/org-chart/employee-node.tsx:

'use client';

import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';

export interface EmployeeNodeData {
  id: string;
  firstName: string;
  lastName: string;
  fullName: string;
  jobTitle: string | null;
  email: string;
  pictureUrl: string | null;
  status: string;
  department: { id: string; name: string } | null;
}

interface EmployeeNodeProps extends NodeProps {
  data: EmployeeNodeData;
}

function EmployeeNodeComponent({ data, selected }: EmployeeNodeProps) {
  const initials = `${data.firstName[0]}${data.lastName[0]}`.toUpperCase();

  return (
    <div
      className={cn(
        'bg-card rounded-2xl shadow-lg shadow-gray-200/50 p-4 min-w-[180px] cursor-pointer transition-all',
        'hover:shadow-xl',
        selected && 'ring-2 ring-primary'
      )}
    >
      {/* Target handle (top) - for incoming edges from manager */}
      <Handle
        type="target"
        position={Position.Top}
        className="!bg-muted-foreground !w-2 !h-2"
      />

      <div className="flex items-center gap-3">
        <Avatar className="h-10 w-10">
          <AvatarImage src={data.pictureUrl ?? undefined} alt={data.fullName} />
          <AvatarFallback className="bg-primary/10 text-primary text-sm">
            {initials}
          </AvatarFallback>
        </Avatar>

        <div className="flex-1 min-w-0">
          <p className="font-medium text-sm truncate">{data.fullName}</p>
          {data.jobTitle && (
            <p className="text-xs text-muted-foreground truncate">
              {data.jobTitle}
            </p>
          )}
        </div>
      </div>

      {data.department && (
        <div className="mt-2">
          <Badge variant="secondary" className="text-xs">
            {data.department.name}
          </Badge>
        </div>
      )}

      {/* Source handle (bottom) - for outgoing edges to reports */}
      <Handle
        type="source"
        position={Position.Bottom}
        className="!bg-muted-foreground !w-2 !h-2"
      />
    </div>
  );
}

export const EmployeeNode = memo(EmployeeNodeComponent);

Update apps/web/components/org-chart/index.ts:

export { EmployeeNode, type EmployeeNodeData } from './employee-node';

Gate

cd apps/web
npm run build
# Should build without errors

# Verify component exists
cat components/org-chart/employee-node.tsx | head -20
# Should show component code

Common Errors

ErrorCauseFix
Module '@/components/ui/avatar' not foundShadcn Avatar not installedRun npx shadcn@latest add avatar
Module '@/components/ui/badge' not foundShadcn Badge not installedRun npx shadcn@latest add badge

Rollback

rm apps/web/components/org-chart/employee-node.tsx

Lock

apps/web/components/org-chart/employee-node.tsx

Checkpoint

  • EmployeeNode component created
  • Uses Shadcn Avatar and Badge
  • Has Handle components for connections
  • TypeScript compiles

Step 66: Create OrgChart Component

Input

  • Step 65 complete
  • EmployeeNode component exists

Constraints

  • Use dagre library for automatic tree layout
  • DO NOT implement custom layout algorithm
  • Use TanStack Query for data fetching

Task

Install dagre for automatic layout:

cd apps/web
npm install @dagrejs/dagre

Create apps/web/lib/queries/org-chart.ts:

import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';

interface OrgChartNode {
  id: string;
  type: string;
  position: { x: number; y: number };
  data: {
    id: string;
    firstName: string;
    lastName: string;
    fullName: string;
    jobTitle: string | null;
    email: string;
    pictureUrl: string | null;
    status: string;
    department: { id: string; name: string } | null;
  };
}

interface OrgChartEdge {
  id: string;
  source: string;
  target: string;
  type: string;
}

interface OrgChartData {
  nodes: OrgChartNode[];
  edges: OrgChartEdge[];
}

export function useOrgChart(options?: { departmentId?: string }) {
  return useQuery({
    queryKey: ['org-chart', options],
    queryFn: async (): Promise<OrgChartData> => {
      const params = new URLSearchParams();
      if (options?.departmentId) {
        params.set('departmentId', options.departmentId);
      }

      const response = await api.get(`/org/employees/chart?${params}`);
      return response.data;
    },
    staleTime: 5 * 60 * 1000, // 5 minutes
  });
}

Create apps/web/components/org-chart/use-layout.ts:

import { useMemo } from 'react';
import Dagre from '@dagrejs/dagre';
import type { Node, Edge } from '@xyflow/react';

interface LayoutOptions {
  direction?: 'TB' | 'BT' | 'LR' | 'RL';
  nodeWidth?: number;
  nodeHeight?: number;
}

export function useLayoutedElements(
  nodes: Node[],
  edges: Edge[],
  options: LayoutOptions = {}
) {
  const { direction = 'TB', nodeWidth = 200, nodeHeight = 80 } = options;

  return useMemo(() => {
    if (nodes.length === 0) {
      return { nodes: [], edges: [] };
    }

    const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
    g.setGraph({ rankdir: direction, nodesep: 50, ranksep: 100 });

    nodes.forEach((node) => {
      g.setNode(node.id, { width: nodeWidth, height: nodeHeight });
    });

    edges.forEach((edge) => {
      g.setEdge(edge.source, edge.target);
    });

    Dagre.layout(g);

    const layoutedNodes = nodes.map((node) => {
      const { x, y } = g.node(node.id);
      return {
        ...node,
        position: {
          x: x - nodeWidth / 2,
          y: y - nodeHeight / 2,
        },
      };
    });

    return { nodes: layoutedNodes, edges };
  }, [nodes, edges, direction, nodeWidth, nodeHeight]);
}

Create apps/web/components/org-chart/org-chart.tsx:

'use client';

import { useCallback, useEffect } from 'react';
import {
  ReactFlow,
  Background,
  Controls,
  MiniMap,
  useNodesState,
  useEdgesState,
  type Node,
  type Edge,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';

import { EmployeeNode, type EmployeeNodeData } from './employee-node';
import { useLayoutedElements } from './use-layout';

interface OrgChartProps {
  nodes: Node<EmployeeNodeData>[];
  edges: Edge[];
  onNodeClick?: (employeeId: string) => void;
  onManagerChange?: (employeeId: string, newManagerId: string | null) => void;
}

const nodeTypes = {
  employee: EmployeeNode,
};

export function OrgChart({
  nodes: initialNodes,
  edges: initialEdges,
  onNodeClick,
  onManagerChange,
}: OrgChartProps) {
  // Apply automatic layout
  const { nodes: layoutedNodes, edges: layoutedEdges } = useLayoutedElements(
    initialNodes,
    initialEdges
  );

  const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
  const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges);

  // Handle node click
  const handleNodeClick = useCallback(
    (_event: React.MouseEvent, node: Node<EmployeeNodeData>) => {
      onNodeClick?.(node.data.id);
    },
    [onNodeClick]
  );

  // Recalculate layout when data changes
  useEffect(() => {
    setNodes(layoutedNodes);
    setEdges(layoutedEdges);
  }, [layoutedNodes, layoutedEdges, setNodes, setEdges]);

  return (
    <div className="w-full h-full">
      <ReactFlow
        nodes={nodes}
        edges={edges}
        onNodesChange={onNodesChange}
        onEdgesChange={onEdgesChange}
        onNodeClick={handleNodeClick}
        nodeTypes={nodeTypes}
        fitView
        fitViewOptions={{ padding: 0.2 }}
        minZoom={0.1}
        maxZoom={2}
      >
        <Background color="#e5e7eb" gap={16} />
        <Controls />
        <MiniMap
          nodeColor={(node) => {
            return node.selected ? '#3b82f6' : '#94a3b8';
          }}
          maskColor="rgba(255, 255, 255, 0.8)"
        />
      </ReactFlow>
    </div>
  );
}

Update apps/web/components/org-chart/index.ts:

export { EmployeeNode, type EmployeeNodeData } from './employee-node';
export { OrgChart } from './org-chart';
export { useLayoutedElements } from './use-layout';

Gate

cd apps/web
npm run build
# Should build without errors

# Verify files exist
ls -la components/org-chart/
# Should show: employee-node.tsx, org-chart.tsx, use-layout.ts, index.ts

Common Errors

ErrorCauseFix
Module '@dagrejs/dagre' not foundNot installedRun npm install @dagrejs/dagre
Type errors with Node/EdgeWrong importImport from @xyflow/react

Rollback

npm uninstall @dagrejs/dagre
rm apps/web/components/org-chart/org-chart.tsx
rm apps/web/components/org-chart/use-layout.ts
rm apps/web/lib/queries/org-chart.ts

Lock

apps/web/components/org-chart/org-chart.tsx
apps/web/components/org-chart/use-layout.ts
apps/web/lib/queries/org-chart.ts

Checkpoint

  • dagre installed
  • useOrgChart query hook created
  • useLayoutedElements hook works
  • OrgChart component renders
  • TypeScript compiles

Step 67: Add Org Chart Page to Dashboard

Input

  • Step 66 complete
  • OrgChart component exists
  • Dashboard layout from Phase 01 with auth protection

Constraints

  • Add to dashboard route group
  • Dashboard layout already handles authentication (from Phase 01)
  • Use Client Component for interactive org chart

Task

Create apps/web/app/dashboard/org/page.tsx:

import { Metadata } from 'next';
import { OrgChartView } from './org-chart-view';

export const metadata: Metadata = {
  title: 'Organization Chart | HRMS',
  description: 'View your organization structure',
};

export default function OrgChartPage() {
  return (
    <div className="flex flex-col h-[calc(100vh-4rem)]">
      <div className="flex items-center justify-between p-6 border-b border-gray-100">
        <div>
          <h1 className="text-2xl font-bold">Organization Chart</h1>
          <p className="text-muted-foreground">
            View and explore your organization structure
          </p>
        </div>
      </div>

      <div className="flex-1">
        <OrgChartView />
      </div>
    </div>
  );
}

Create apps/web/app/dashboard/org/org-chart-view.tsx:

'use client';

import { useOrgChart } from '@/lib/queries/org-chart';
import { OrgChart } from '@/components/org-chart';
import { Skeleton } from '@/components/ui/skeleton';
import { AlertCircle } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';

export function OrgChartView() {
  const { data, isLoading, error } = useOrgChart();

  if (isLoading) {
    return (
      <div className="flex items-center justify-center h-full">
        <div className="space-y-4 w-64">
          <Skeleton className="h-20 w-full" />
          <Skeleton className="h-20 w-full" />
          <Skeleton className="h-20 w-full" />
        </div>
      </div>
    );
  }

  if (error) {
    return (
      <div className="flex items-center justify-center h-full p-4">
        <Alert variant="destructive" className="max-w-md">
          <AlertCircle className="h-4 w-4" />
          <AlertTitle>Error loading org chart</AlertTitle>
          <AlertDescription>
            {error instanceof Error ? error.message : 'Something went wrong'}
          </AlertDescription>
        </Alert>
      </div>
    );
  }

  if (!data || data.nodes.length === 0) {
    return (
      <div className="flex items-center justify-center h-full">
        <div className="text-center">
          <p className="text-lg text-muted-foreground">No employees found</p>
          <p className="text-sm text-muted-foreground mt-1">
            Add employees and assign managers to see the organization structure
          </p>
        </div>
      </div>
    );
  }

  return (
    <OrgChart
      nodes={data.nodes}
      edges={data.edges}
      onNodeClick={(id) => {
        // NOTE: Employee profile navigation will be added when profile pages are implemented
        console.log('Selected employee:', id);
      }}
    />
  );
}

Add navigation link (if sidebar exists):

// In your sidebar/navigation component, add:
{
  title: 'Organization',
  href: '/org',
  icon: Network, // from lucide-react
}

Gate

cd apps/web
npm run build
# Should build without errors

npm run dev
# Navigate to http://localhost:3000/org
# Should see org chart page with loading state, then chart

Common Errors

ErrorCauseFix
Page not foundRoute not createdCheck file path is correct
Skeleton not foundShadcn not installedRun npx shadcn@latest add skeleton
Alert not foundShadcn not installedRun npx shadcn@latest add alert

Rollback

rm -rf apps/web/app/dashboard/org

Lock

apps/web/app/dashboard/org/page.tsx
apps/web/app/dashboard/org/org-chart-view.tsx

Checkpoint

  • /org page created
  • Loading state shows skeleton
  • Error state shows alert
  • Empty state shows message
  • Org chart renders when data exists

Step 68: Add Zoom and Pan Controls

Input

  • Step 67 complete
  • Org chart page renders

Constraints

  • Use React Flow built-in controls
  • Add keyboard shortcuts for zoom
  • DO NOT implement custom zoom logic

Task

Update apps/web/components/org-chart/org-chart.tsx:

'use client';

import { useCallback, useEffect, useState } from 'react';
import {
  ReactFlow,
  Background,
  Controls,
  MiniMap,
  Panel,
  useNodesState,
  useEdgesState,
  useReactFlow,
  type Node,
  type Edge,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';

import { Button } from '@/components/ui/button';
import { ZoomIn, ZoomOut, Maximize2 } from 'lucide-react';
import { EmployeeNode, type EmployeeNodeData } from './employee-node';
import { useLayoutedElements } from './use-layout';

interface OrgChartProps {
  nodes: Node<EmployeeNodeData>[];
  edges: Edge[];
  onNodeClick?: (employeeId: string) => void;
  onManagerChange?: (employeeId: string, newManagerId: string | null) => void;
}

const nodeTypes = {
  employee: EmployeeNode,
};

function OrgChartInner({
  nodes: initialNodes,
  edges: initialEdges,
  onNodeClick,
}: OrgChartProps) {
  const { fitView, zoomIn, zoomOut } = useReactFlow();

  // Apply automatic layout
  const { nodes: layoutedNodes, edges: layoutedEdges } = useLayoutedElements(
    initialNodes,
    initialEdges
  );

  const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
  const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges);

  // Handle node click
  const handleNodeClick = useCallback(
    (_event: React.MouseEvent, node: Node<EmployeeNodeData>) => {
      onNodeClick?.(node.data.id);
    },
    [onNodeClick]
  );

  // Reset view
  const handleReset = useCallback(() => {
    fitView({ padding: 0.2, duration: 300 });
  }, [fitView]);

  // Recalculate layout when data changes
  useEffect(() => {
    setNodes(layoutedNodes);
    setEdges(layoutedEdges);
  }, [layoutedNodes, layoutedEdges, setNodes, setEdges]);

  return (
    <>
      <ReactFlow
        nodes={nodes}
        edges={edges}
        onNodesChange={onNodesChange}
        onEdgesChange={onEdgesChange}
        onNodeClick={handleNodeClick}
        nodeTypes={nodeTypes}
        fitView
        fitViewOptions={{ padding: 0.2 }}
        minZoom={0.1}
        maxZoom={2}
        panOnScroll
        selectionOnDrag
        panOnDrag={[1, 2]} // Middle and right mouse button for pan
      >
        <Background color="#e5e7eb" gap={16} />

        {/* Custom zoom controls panel */}
        <Panel position="top-right" className="flex gap-1 bg-background/80 backdrop-blur-sm p-2 rounded-2xl shadow-lg shadow-gray-200/50">
          <Button
            variant="ghost"
            size="icon"
            onClick={() => zoomIn({ duration: 200 })}
            title="Zoom In (Ctrl/Cmd + +)"
          >
            <ZoomIn className="h-4 w-4" />
          </Button>
          <Button
            variant="ghost"
            size="icon"
            onClick={() => zoomOut({ duration: 200 })}
            title="Zoom Out (Ctrl/Cmd + -)"
          >
            <ZoomOut className="h-4 w-4" />
          </Button>
          <Button
            variant="ghost"
            size="icon"
            onClick={handleReset}
            title="Fit View (Ctrl/Cmd + 0)"
          >
            <Maximize2 className="h-4 w-4" />
          </Button>
        </Panel>

        <Controls
          showZoom={false}
          showFitView={false}
          showInteractive={false}
        />

        <MiniMap
          nodeColor={(node) => {
            return node.selected ? '#3b82f6' : '#94a3b8';
          }}
          maskColor="rgba(255, 255, 255, 0.8)"
          pannable
          zoomable
        />
      </ReactFlow>
    </>
  );
}

// Wrapper to provide ReactFlowProvider context
import { ReactFlowProvider } from '@xyflow/react';

export function OrgChart(props: OrgChartProps) {
  return (
    <ReactFlowProvider>
      <OrgChartInner {...props} />
    </ReactFlowProvider>
  );
}

Gate

cd apps/web
npm run build
# Should build without errors

npm run dev
# Navigate to /org
# - Zoom in/out buttons should work
# - Scroll wheel should zoom
# - Drag with middle/right mouse should pan
# - Fit view button should center the chart
# - MiniMap should be draggable

Common Errors

ErrorCauseFix
useReactFlow must be used within ReactFlowProviderMissing providerWrap with ReactFlowProvider
Button not foundShadcn not installedRun npx shadcn@latest add button

Rollback

# Restore previous version of org-chart.tsx

Lock

apps/web/components/org-chart/org-chart.tsx (zoom controls)

Checkpoint

  • Zoom in/out buttons work
  • Mouse wheel zooms
  • Pan with drag works
  • Fit view button centers chart
  • MiniMap is interactive

Step 69: Add Click-to-View Employee Detail

Input

  • Step 68 complete
  • Zoom/pan controls work
  • Step 66 complete (Employee Org Summary endpoint from Phase 03)

Constraints

  • Use slide-out sheet (Shadcn Sheet component)
  • DO NOT navigate to separate page
  • Fetch full org summary for manager info and reporting chain
  • Use /api/v1/employees/:id/summary endpoint from Step 66

Task

Create apps/web/components/org-chart/employee-detail-sheet.tsx:

'use client';

import { useQuery } from '@tanstack/react-query';
import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
} from '@/components/ui/sheet';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Skeleton } from '@/components/ui/skeleton';
import { Mail, Building2, Users, ExternalLink, ChevronRight } from 'lucide-react';
import Link from 'next/link';
import { api } from '@/lib/api';
import type { EmployeeNodeData } from './employee-node';

interface ManagerInfo {
  id: string;
  firstName: string;
  lastName: string;
  jobTitle: string | null;
  pictureUrl?: string | null;
}

interface EmployeeOrgSummary {
  employee: {
    id: string;
    firstName: string;
    lastName: string;
    email: string;
    jobTitle: string | null;
    pictureUrl: string | null;
  };
  managers: {
    primary: ManagerInfo | null;
    dottedLine: ManagerInfo[];
    additional: ManagerInfo[];
  };
  departments: Array<{ id: string; name: string; isPrimary: boolean }>;
  teams: Array<{ id: string; name: string; role: string | null }>;
  roles: Array<{ id: string; name: string; category: string | null; isPrimary: boolean }>;
  reportingChain: Array<{ id: string; name: string; jobTitle: string | null }>;
}

interface EmployeeDetailSheetProps {
  employee: EmployeeNodeData | null;
  open: boolean;
  onOpenChange: (open: boolean) => void;
}

export function EmployeeDetailSheet({
  employee,
  open,
  onOpenChange,
}: EmployeeDetailSheetProps) {
  // Fetch full org summary when sheet is open
  const { data: summary, isLoading } = useQuery<EmployeeOrgSummary>({
    queryKey: ['employee-summary', employee?.id],
    queryFn: async () => {
      const response = await api.get(`/employees/${employee!.id}/summary`);
      return response.data as EmployeeOrgSummary;
    },
    enabled: open && !!employee?.id,
    staleTime: 30 * 1000, // 30 seconds
  });

  if (!employee) return null;

  const initials = `${employee.firstName[0]}${employee.lastName[0]}`.toUpperCase();

  return (
    <Sheet open={open} onOpenChange={onOpenChange}>
      <SheetContent className="w-[400px] sm:w-[540px] overflow-y-auto">
        <SheetHeader>
          <div className="flex items-center gap-4">
            <Avatar className="h-16 w-16">
              <AvatarImage src={employee.pictureUrl ?? undefined} alt={employee.fullName} />
              <AvatarFallback className="bg-primary/10 text-primary text-xl">
                {initials}
              </AvatarFallback>
            </Avatar>
            <div>
              <SheetTitle className="text-xl">{employee.fullName}</SheetTitle>
              <SheetDescription className="text-base">
                {employee.jobTitle || 'No title'}
              </SheetDescription>
            </div>
          </div>
        </SheetHeader>

        <div className="mt-6 space-y-6">
          {/* Status */}
          <div>
            <Badge
              variant={employee.status === 'ACTIVE' ? 'default' : 'secondary'}
            >
              {employee.status}
            </Badge>
          </div>

          <Separator />

          {/* Contact */}
          <div className="space-y-3">
            <h3 className="text-sm font-medium text-muted-foreground">Contact</h3>
            <div className="flex items-center gap-2 text-sm">
              <Mail className="h-4 w-4 text-muted-foreground" />
              <a
                href={`mailto:${employee.email}`}
                className="text-primary hover:underline"
              >
                {employee.email}
              </a>
            </div>
          </div>

          {/* Reports To (Primary Manager) */}
          {isLoading ? (
            <>
              <Separator />
              <div className="space-y-3">
                <h3 className="text-sm font-medium text-muted-foreground">Reports To</h3>
                <Skeleton className="h-12 w-full" />
              </div>
            </>
          ) : summary?.managers?.primary ? (
            <>
              <Separator />
              <div className="space-y-3">
                <h3 className="text-sm font-medium text-muted-foreground">Reports To</h3>
                <div className="flex items-center gap-3">
                  <Avatar className="h-10 w-10">
                    <AvatarImage src={summary.managers.primary.pictureUrl ?? undefined} />
                    <AvatarFallback className="bg-primary/10 text-primary text-sm">
                      {summary.managers.primary.firstName[0]}{summary.managers.primary.lastName[0]}
                    </AvatarFallback>
                  </Avatar>
                  <div>
                    <p className="font-medium text-sm">
                      {summary.managers.primary.firstName} {summary.managers.primary.lastName}
                    </p>
                    <p className="text-xs text-muted-foreground">
                      {summary.managers.primary.jobTitle || 'No title'}
                    </p>
                  </div>
                </div>
              </div>
            </>
          ) : null}

          {/* Departments */}
          {isLoading ? (
            <>
              <Separator />
              <div className="space-y-3">
                <h3 className="text-sm font-medium text-muted-foreground">Departments</h3>
                <Skeleton className="h-8 w-32" />
              </div>
            </>
          ) : summary?.departments && summary.departments.length > 0 ? (
            <>
              <Separator />
              <div className="space-y-3">
                <h3 className="text-sm font-medium text-muted-foreground">Departments</h3>
                <div className="flex flex-wrap gap-2">
                  {summary.departments.map((d) => (
                    <Badge
                      key={d.id}
                      variant={d.isPrimary ? 'default' : 'secondary'}
                    >
                      <Building2 className="h-3 w-3 mr-1" />
                      {d.name}
                      {d.isPrimary && ' (Primary)'}
                    </Badge>
                  ))}
                </div>
              </div>
            </>
          ) : employee.department ? (
            <>
              <Separator />
              <div className="space-y-3">
                <h3 className="text-sm font-medium text-muted-foreground">Department</h3>
                <div className="flex items-center gap-2 text-sm">
                  <Building2 className="h-4 w-4 text-muted-foreground" />
                  <span>{employee.department.name}</span>
                </div>
              </div>
            </>
          ) : null}

          {/* Teams */}
          {!isLoading && summary?.teams && summary.teams.length > 0 && (
            <>
              <Separator />
              <div className="space-y-3">
                <h3 className="text-sm font-medium text-muted-foreground">Teams</h3>
                <div className="flex flex-wrap gap-2">
                  {summary.teams.map((t) => (
                    <Badge key={t.id} variant="outline">
                      <Users className="h-3 w-3 mr-1" />
                      {t.name}
                      {t.role && ` (${t.role})`}
                    </Badge>
                  ))}
                </div>
              </div>
            </>
          )}

          {/* Reporting Chain */}
          {!isLoading && summary?.reportingChain && summary.reportingChain.length > 0 && (
            <>
              <Separator />
              <div className="space-y-3">
                <h3 className="text-sm font-medium text-muted-foreground">Reporting Chain</h3>
                <div className="flex items-center flex-wrap gap-1 text-sm text-muted-foreground">
                  <span className="text-foreground font-medium">
                    {employee.fullName}
                  </span>
                  {summary.reportingChain.map((mgr) => (
                    <span key={mgr.id} className="flex items-center">
                      <ChevronRight className="h-4 w-4 mx-1" />
                      <span>{mgr.name}</span>
                    </span>
                  ))}
                </div>
              </div>
            </>
          )}

          <Separator />

          {/* Actions */}
          <div className="flex gap-2">
            <Button asChild className="flex-1">
              <Link href={`/employees/${employee.id}`}>
                <ExternalLink className="h-4 w-4 mr-2" />
                View Full Profile
              </Link>
            </Button>
          </div>
        </div>
      </SheetContent>
    </Sheet>
  );
}

Update apps/web/app/dashboard/org/org-chart-view.tsx:

'use client';

import { useState } from 'react';
import { useOrgChart } from '@/lib/queries/org-chart';
import { OrgChart } from '@/components/org-chart';
import { EmployeeDetailSheet } from '@/components/org-chart/employee-detail-sheet';
import type { EmployeeNodeData } from '@/components/org-chart';
import { Skeleton } from '@/components/ui/skeleton';
import { AlertCircle } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';

export function OrgChartView() {
  const { data, isLoading, error } = useOrgChart();
  const [selectedEmployee, setSelectedEmployee] = useState<EmployeeNodeData | null>(null);
  const [sheetOpen, setSheetOpen] = useState(false);

  const handleNodeClick = (employeeId: string) => {
    const employee = data?.nodes.find((n) => n.data.id === employeeId)?.data;
    if (employee) {
      setSelectedEmployee(employee);
      setSheetOpen(true);
    }
  };

  if (isLoading) {
    return (
      <div className="flex items-center justify-center h-full">
        <div className="space-y-4 w-64">
          <Skeleton className="h-20 w-full" />
          <Skeleton className="h-20 w-full" />
          <Skeleton className="h-20 w-full" />
        </div>
      </div>
    );
  }

  if (error) {
    return (
      <div className="flex items-center justify-center h-full p-4">
        <Alert variant="destructive" className="max-w-md">
          <AlertCircle className="h-4 w-4" />
          <AlertTitle>Error loading org chart</AlertTitle>
          <AlertDescription>
            {error instanceof Error ? error.message : 'Something went wrong'}
          </AlertDescription>
        </Alert>
      </div>
    );
  }

  if (!data || data.nodes.length === 0) {
    return (
      <div className="flex items-center justify-center h-full">
        <div className="text-center">
          <p className="text-lg text-muted-foreground">No employees found</p>
          <p className="text-sm text-muted-foreground mt-1">
            Add employees and assign managers to see the organization structure
          </p>
        </div>
      </div>
    );
  }

  return (
    <>
      <OrgChart
        nodes={data.nodes}
        edges={data.edges}
        onNodeClick={handleNodeClick}
      />

      <EmployeeDetailSheet
        employee={selectedEmployee}
        open={sheetOpen}
        onOpenChange={setSheetOpen}
      />
    </>
  );
}

Update apps/web/components/org-chart/index.ts:

export { EmployeeNode, type EmployeeNodeData } from './employee-node';
export { OrgChart } from './org-chart';
export { useLayoutedElements } from './use-layout';
export { EmployeeDetailSheet } from './employee-detail-sheet';

Gate

cd apps/web

# Install Sheet if not present
npx shadcn@latest add sheet
npx shadcn@latest add separator

npm run build
# Should build without errors

npm run dev
# Navigate to /org
# Click on an employee node
# Sheet should slide in from right with employee details

Common Errors

ErrorCauseFix
Sheet not foundShadcn not installedRun npx shadcn@latest add sheet
Separator not foundShadcn not installedRun npx shadcn@latest add separator

Rollback

rm apps/web/components/org-chart/employee-detail-sheet.tsx

Lock

apps/web/components/org-chart/employee-detail-sheet.tsx
apps/web/app/dashboard/org/org-chart-view.tsx

Checkpoint

  • Click employee opens sheet
  • Sheet shows employee details
  • Sheet shows "Reports To" section with primary manager (from summary endpoint)
  • Sheet shows all departments with isPrimary indicators
  • Sheet shows teams with role
  • Sheet shows reporting chain breadcrumb (Employee → Manager → ... → CEO)
  • "View Full Profile" links to employee page
  • Sheet can be closed
  • Loading states shown while fetching summary

Step 70: Add Drag-to-Reassign Manager

Input

  • Step 69 complete
  • Employee detail sheet works

Constraints

  • Drag employee node and drop on new manager
  • Confirm action with dialog
  • Call API to update manager relationship
  • Refresh org chart after update

Task

Update apps/web/components/org-chart/org-chart.tsx to handle edge connections:

'use client';

import { useCallback, useEffect, useState } from 'react';
import {
  ReactFlow,
  Background,
  Controls,
  MiniMap,
  Panel,
  useNodesState,
  useEdgesState,
  useReactFlow,
  type Node,
  type Edge,
  type Connection,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';

import { Button } from '@/components/ui/button';
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { ZoomIn, ZoomOut, Maximize2 } from 'lucide-react';
import { EmployeeNode, type EmployeeNodeData } from './employee-node';
import { useLayoutedElements } from './use-layout';

interface OrgChartProps {
  nodes: Node<EmployeeNodeData>[];
  edges: Edge[];
  onNodeClick?: (employeeId: string) => void;
  onManagerChange?: (employeeId: string, newManagerId: string) => Promise<void>;
  highlightedNodeId?: string | null;
}

const nodeTypes = {
  employee: EmployeeNode,
};

interface PendingChange {
  employeeId: string;
  employeeName: string;
  newManagerId: string;
  newManagerName: string;
}

function OrgChartInner({
  nodes: initialNodes,
  edges: initialEdges,
  onNodeClick,
  onManagerChange,
  highlightedNodeId,
}: OrgChartProps) {
  const { fitView, zoomIn, zoomOut, setViewport } = useReactFlow();
  const [pendingChange, setPendingChange] = useState<PendingChange | null>(null);
  const [isUpdating, setIsUpdating] = useState(false);

  // Apply automatic layout
  const { nodes: layoutedNodes, edges: layoutedEdges } = useLayoutedElements(
    initialNodes,
    initialEdges
  );

  const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
  const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges);

  // Handle node click
  const handleNodeClick = useCallback(
    (_event: React.MouseEvent, node: Node<EmployeeNodeData>) => {
      onNodeClick?.(node.data.id);
    },
    [onNodeClick]
  );

  // Handle new connection (drag to reassign)
  const handleConnect = useCallback(
    (connection: Connection) => {
      if (!connection.source || !connection.target || !onManagerChange) return;

      // Prevent self-assignment (backend also validates, but save the round-trip)
      if (connection.source === connection.target) return;

      const employeeNode = nodes.find((n) => n.id === connection.target);
      const managerNode = nodes.find((n) => n.id === connection.source);

      if (!employeeNode || !managerNode) return;

      // Show confirmation dialog
      setPendingChange({
        employeeId: connection.target,
        employeeName: employeeNode.data.fullName,
        newManagerId: connection.source,
        newManagerName: managerNode.data.fullName,
      });
    },
    [nodes, onManagerChange]
  );

  // Confirm manager change
  const handleConfirmChange = async () => {
    if (!pendingChange || !onManagerChange) return;

    setIsUpdating(true);
    try {
      await onManagerChange(pendingChange.employeeId, pendingChange.newManagerId);
    } finally {
      setIsUpdating(false);
      setPendingChange(null);
    }
  };

  // Reset view
  const handleReset = useCallback(() => {
    fitView({ padding: 0.2, duration: 300 });
  }, [fitView]);

  // Recalculate layout when data changes
  useEffect(() => {
    setNodes(layoutedNodes);
    setEdges(layoutedEdges);
  }, [layoutedNodes, layoutedEdges, setNodes, setEdges]);

  // Handle highlighted node (from search)
  useEffect(() => {
    if (highlightedNodeId) {
      // Find and select the node
      setNodes((nds) =>
        nds.map((node) => ({
          ...node,
          selected: node.id === highlightedNodeId,
        }))
      );

      // Center on the highlighted node
      const node = nodes.find((n) => n.id === highlightedNodeId);
      if (node) {
        setViewport(
          {
            x: -node.position.x + window.innerWidth / 2 - 100,
            y: -node.position.y + window.innerHeight / 2 - 50,
            zoom: 1,
          },
          { duration: 500 }
        );
      }
    }
  }, [highlightedNodeId, nodes, setNodes, setViewport]);

  return (
    <>
      <ReactFlow
        nodes={nodes}
        edges={edges}
        onNodesChange={onNodesChange}
        onEdgesChange={onEdgesChange}
        onNodeClick={handleNodeClick}
        onConnect={onManagerChange ? handleConnect : undefined}
        nodeTypes={nodeTypes}
        fitView
        fitViewOptions={{ padding: 0.2 }}
        minZoom={0.1}
        maxZoom={2}
        panOnScroll
        selectionOnDrag
        panOnDrag={[1, 2]}
        connectOnClick={false}
      >
        <Background color="#e5e7eb" gap={16} />

        <Panel position="top-right" className="flex gap-1 bg-background/80 backdrop-blur-sm p-2 rounded-2xl shadow-lg shadow-gray-200/50">
          <Button
            variant="ghost"
            size="icon"
            onClick={() => zoomIn({ duration: 200 })}
            title="Zoom In"
          >
            <ZoomIn className="h-4 w-4" />
          </Button>
          <Button
            variant="ghost"
            size="icon"
            onClick={() => zoomOut({ duration: 200 })}
            title="Zoom Out"
          >
            <ZoomOut className="h-4 w-4" />
          </Button>
          <Button
            variant="ghost"
            size="icon"
            onClick={handleReset}
            title="Fit View"
          >
            <Maximize2 className="h-4 w-4" />
          </Button>
        </Panel>

        {onManagerChange && (
          <Panel position="bottom-left" className="bg-background/80 backdrop-blur-sm p-3 rounded-2xl shadow-lg shadow-gray-200/50 text-xs text-muted-foreground">
            Tip: Drag from a manager&apos;s bottom handle to an employee&apos;s top handle to reassign
          </Panel>
        )}

        <Controls showZoom={false} showFitView={false} showInteractive={false} />

        <MiniMap
          nodeColor={(node) => (node.selected ? '#3b82f6' : '#94a3b8')}
          maskColor="rgba(255, 255, 255, 0.8)"
          pannable
          zoomable
        />
      </ReactFlow>

      {/* Confirmation Dialog */}
      <AlertDialog open={!!pendingChange} onOpenChange={() => setPendingChange(null)}>
        <AlertDialogContent>
          <AlertDialogHeader>
            <AlertDialogTitle>Change Manager</AlertDialogTitle>
            <AlertDialogDescription>
              Are you sure you want to change {pendingChange?.employeeName}&apos;s manager to{' '}
              {pendingChange?.newManagerName}?
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel disabled={isUpdating}>Cancel</AlertDialogCancel>
            <AlertDialogAction onClick={handleConfirmChange} disabled={isUpdating}>
              {isUpdating ? 'Updating...' : 'Confirm'}
            </AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>
    </>
  );
}

import { ReactFlowProvider } from '@xyflow/react';

export function OrgChart(props: OrgChartProps) {
  return (
    <ReactFlowProvider>
      <OrgChartInner {...props} />
    </ReactFlowProvider>
  );
}

Add mutation hook to apps/web/lib/queries/org-chart.ts:

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';

// ... existing code ...

export function useUpdateManager() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({ employeeId, managerId }: { employeeId: string; managerId: string }) => {
      await api.post(`/org/employees/${employeeId}/manager`, {
        managerId,
        type: 'primary',
      });
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['org-chart'] });
    },
  });
}

Update apps/web/app/dashboard/org/org-chart-view.tsx to use the mutation:

'use client';

import { useState } from 'react';
import { useOrgChart, useUpdateManager } from '@/lib/queries/org-chart';
import { OrgChart } from '@/components/org-chart';
import { EmployeeDetailSheet } from '@/components/org-chart/employee-detail-sheet';
import type { EmployeeNodeData } from '@/components/org-chart';
import { Skeleton } from '@/components/ui/skeleton';
import { AlertCircle } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { toast } from 'sonner';

export function OrgChartView() {
  const { data, isLoading, error } = useOrgChart();
  const updateManager = useUpdateManager();
  const [selectedEmployee, setSelectedEmployee] = useState<EmployeeNodeData | null>(null);
  const [sheetOpen, setSheetOpen] = useState(false);

  const handleNodeClick = (employeeId: string) => {
    const employee = data?.nodes.find((n) => n.data.id === employeeId)?.data;
    if (employee) {
      setSelectedEmployee(employee);
      setSheetOpen(true);
    }
  };

  const handleManagerChange = async (employeeId: string, newManagerId: string) => {
    try {
      await updateManager.mutateAsync({ employeeId, managerId: newManagerId });
      toast.success('Manager updated successfully');
    } catch (err) {
      toast.error('Failed to update manager', {
        description: err instanceof Error ? err.message : 'Please try again',
      });
      throw err;
    }
  };

  if (isLoading) {
    return (
      <div className="flex items-center justify-center h-full">
        <div className="space-y-4 w-64">
          <Skeleton className="h-20 w-full" />
          <Skeleton className="h-20 w-full" />
          <Skeleton className="h-20 w-full" />
        </div>
      </div>
    );
  }

  if (error) {
    return (
      <div className="flex items-center justify-center h-full p-4">
        <Alert variant="destructive" className="max-w-md">
          <AlertCircle className="h-4 w-4" />
          <AlertTitle>Error loading org chart</AlertTitle>
          <AlertDescription>
            {error instanceof Error ? error.message : 'Something went wrong'}
          </AlertDescription>
        </Alert>
      </div>
    );
  }

  if (!data || data.nodes.length === 0) {
    return (
      <div className="flex items-center justify-center h-full">
        <div className="text-center">
          <p className="text-lg text-muted-foreground">No employees found</p>
          <p className="text-sm text-muted-foreground mt-1">
            Add employees and assign managers to see the organization structure
          </p>
        </div>
      </div>
    );
  }

  return (
    <>
      <OrgChart
        nodes={data.nodes}
        edges={data.edges}
        onNodeClick={handleNodeClick}
        onManagerChange={handleManagerChange}
      />

      <EmployeeDetailSheet
        employee={selectedEmployee}
        open={sheetOpen}
        onOpenChange={setSheetOpen}
      />
    </>
  );
}

Gate

cd apps/web

# Install AlertDialog and Sonner if not present
npx shadcn@latest add alert-dialog
npx shadcn@latest add sonner

Important: After installing Sonner, ensure the <Toaster /> component is added to your root layout:

// apps/web/app/layout.tsx (or wherever your root layout is)
import { Toaster } from '@/components/ui/sonner';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Providers>
          {children}
          <Toaster />
        </Providers>
      </body>
    </html>
  );
}
npm run build
# Should build without errors

npm run dev
# Navigate to /dashboard/org
# - Drag from manager bottom handle to employee top handle
# - Confirmation dialog should appear
# - Confirming should update the relationship and refresh the chart
# - Toast notification should appear

Common Errors

ErrorCauseFix
AlertDialog not foundShadcn not installedRun npx shadcn@latest add alert-dialog
toast not foundSonner not installedRun npx shadcn@latest add sonner
Toast not showingToaster not in layoutAdd <Toaster /> to root layout
Connection not triggeringHandles not connectedCheck Handle positions

Rollback

# Restore previous versions of org-chart.tsx and org-chart-view.tsx

Lock

apps/web/components/org-chart/org-chart.tsx (drag-to-reassign)
apps/web/lib/queries/org-chart.ts (useUpdateManager)

Checkpoint

  • Drag between nodes shows confirmation
  • Confirmation updates manager relationship
  • Chart refreshes after update
  • Toast notification appears

Step 71: Add Department Filter

Input

  • Step 70 complete
  • Drag-to-reassign works

Constraints

  • Use Shadcn Select component
  • Filter is applied via query parameter
  • DO NOT hardcode department options; always load from the /departments API

Task

Create apps/web/lib/queries/departments.ts:

import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';

interface Department {
  id: string;
  name: string;
  code: string | null;
}

export function useDepartments() {
  return useQuery({
    queryKey: ['departments'],
    queryFn: async (): Promise<Department[]> => {
      const response = await api.get('/departments');
      return response.data;
    },
    staleTime: 10 * 60 * 1000, // 10 minutes
  });
}

Update apps/web/app/dashboard/org/page.tsx:

import { Metadata } from 'next';
import { OrgChartView } from './org-chart-view';
import { DepartmentFilter } from './department-filter';
import { Suspense } from 'react';

export const metadata: Metadata = {
  title: 'Organization Chart | HRMS',
  description: 'View your organization structure',
};

export default function OrgChartPage() {
  return (
    <div className="flex flex-col h-[calc(100vh-4rem)]">
      <div className="flex items-center justify-between p-6 border-b border-gray-100">
        <div>
          <h1 className="text-2xl font-bold">Organization Chart</h1>
          <p className="text-muted-foreground">
            View and explore your organization structure
          </p>
        </div>
        <Suspense fallback={null}>
          <DepartmentFilter />
        </Suspense>
      </div>

      <div className="flex-1">
        <Suspense fallback={null}>
          <OrgChartView />
        </Suspense>
      </div>
    </div>
  );
}

Create apps/web/app/dashboard/org/department-filter.tsx:

'use client';

import { useRouter, useSearchParams } from 'next/navigation';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import { useDepartments } from '@/lib/queries/departments';

export function DepartmentFilter() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const currentDepartment = searchParams.get('departmentId') || 'all';

  const { data: departments, isLoading } = useDepartments();

  const handleChange = (value: string) => {
    const params = new URLSearchParams(searchParams.toString());
    if (value === 'all') {
      params.delete('departmentId');
    } else {
      params.set('departmentId', value);
    }
    router.push(`/org?${params.toString()}`);
  };

  return (
    <Select value={currentDepartment} onValueChange={handleChange}>
      <SelectTrigger className="w-[200px]">
        <SelectValue placeholder="All Departments" />
      </SelectTrigger>
      <SelectContent>
        <SelectItem value="all">All Departments</SelectItem>
        {departments?.map((dept) => (
          <SelectItem key={dept.id} value={dept.id}>
            {dept.name}
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  );
}

Update apps/web/app/dashboard/org/org-chart-view.tsx to use the filter:

'use client';

import { useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useOrgChart, useUpdateManager } from '@/lib/queries/org-chart';
import { OrgChart } from '@/components/org-chart';
import { EmployeeDetailSheet } from '@/components/org-chart/employee-detail-sheet';
import type { EmployeeNodeData } from '@/components/org-chart';
import { Skeleton } from '@/components/ui/skeleton';
import { AlertCircle } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { toast } from 'sonner';

export function OrgChartView() {
  const searchParams = useSearchParams();
  const departmentId = searchParams.get('departmentId') || undefined;

  const { data, isLoading, error } = useOrgChart({ departmentId });
  const updateManager = useUpdateManager();
  const [selectedEmployee, setSelectedEmployee] = useState<EmployeeNodeData | null>(null);
  const [sheetOpen, setSheetOpen] = useState(false);

  const handleNodeClick = (employeeId: string) => {
    const employee = data?.nodes.find((n) => n.data.id === employeeId)?.data;
    if (employee) {
      setSelectedEmployee(employee);
      setSheetOpen(true);
    }
  };

  const handleManagerChange = async (employeeId: string, newManagerId: string) => {
    try {
      await updateManager.mutateAsync({ employeeId, managerId: newManagerId });
      toast.success('Manager updated successfully');
    } catch (err) {
      toast.error('Failed to update manager', {
        description: err instanceof Error ? err.message : 'Please try again',
      });
      throw err;
    }
  };

  if (isLoading) {
    return (
      <div className="flex items-center justify-center h-full">
        <div className="space-y-4 w-64">
          <Skeleton className="h-20 w-full" />
          <Skeleton className="h-20 w-full" />
          <Skeleton className="h-20 w-full" />
        </div>
      </div>
    );
  }

  if (error) {
    return (
      <div className="flex items-center justify-center h-full p-4">
        <Alert variant="destructive" className="max-w-md">
          <AlertCircle className="h-4 w-4" />
          <AlertTitle>Error loading org chart</AlertTitle>
          <AlertDescription>
            {error instanceof Error ? error.message : 'Something went wrong'}
          </AlertDescription>
        </Alert>
      </div>
    );
  }

  if (!data || data.nodes.length === 0) {
    return (
      <div className="flex items-center justify-center h-full">
        <div className="text-center">
          <p className="text-lg text-muted-foreground">No employees found</p>
          <p className="text-sm text-muted-foreground mt-1">
            {departmentId
              ? 'No employees in this department'
              : 'Add employees to see them in the org chart'}
          </p>
        </div>
      </div>
    );
  }

  return (
    <>
      <OrgChart
        nodes={data.nodes}
        edges={data.edges}
        onNodeClick={handleNodeClick}
        onManagerChange={handleManagerChange}
      />

      <EmployeeDetailSheet
        employee={selectedEmployee}
        open={sheetOpen}
        onOpenChange={setSheetOpen}
      />
    </>
  );
}

Gate

cd apps/web

# Install Select if not present
npx shadcn@latest add select

npm run build
# Should build without errors

npm run dev
# Navigate to /org
# - Department dropdown should appear
# - Selecting a department should filter the org chart
# - URL should update with departmentId parameter

Common Errors

ErrorCauseFix
Select not foundShadcn not installedRun npx shadcn@latest add select
useSearchParams must be wrapped in SuspenseMissing SuspenseAdd Suspense boundary

Rollback

rm apps/web/app/dashboard/org/department-filter.tsx
rm apps/web/lib/queries/departments.ts

Lock

apps/web/app/dashboard/org/department-filter.tsx
apps/web/lib/queries/departments.ts

Checkpoint

  • Department dropdown renders
  • Selecting department filters chart
  • URL updates with departmentId
  • "All Departments" clears filter

Step 72: Add Search in Org Chart

Input

  • Step 71 complete
  • Department filter works

Constraints

  • Search by name (first or last)
  • Highlight matching nodes
  • Center view on selected result
  • Use Command palette pattern (Ctrl/Cmd + K)

Task

Create apps/web/components/org-chart/search-command.tsx:

'use client';

import { useEffect, useCallback } from 'react';
import {
  CommandDialog,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from '@/components/ui/command';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import type { EmployeeNodeData } from './employee-node';

interface SearchCommandProps {
  employees: EmployeeNodeData[];
  onSelect: (employeeId: string) => void;
  open: boolean;
  onOpenChange: (open: boolean) => void;
}

export function SearchCommand({ employees, onSelect, open, onOpenChange }: SearchCommandProps) {
  // Open with Ctrl/Cmd + K
  useEffect(() => {
    const down = (e: KeyboardEvent) => {
      if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        onOpenChange(!open);
      }
    };

    document.addEventListener('keydown', down);
    return () => document.removeEventListener('keydown', down);
  }, [open, onOpenChange]);

  const handleSelect = useCallback(
    (employeeId: string) => {
      onSelect(employeeId);
      onOpenChange(false);
    },
    [onSelect, onOpenChange]
  );

  return (
    <CommandDialog open={open} onOpenChange={onOpenChange}>
      <CommandInput placeholder="Search employees..." />
      <CommandList>
        <CommandEmpty>No employees found.</CommandEmpty>
        <CommandGroup heading="Employees">
          {employees.map((emp) => {
            const initials = `${emp.firstName[0]}${emp.lastName[0]}`.toUpperCase();
            return (
              <CommandItem
                key={emp.id}
                value={`${emp.firstName} ${emp.lastName} ${emp.email}`}
                onSelect={() => handleSelect(emp.id)}
              >
                <Avatar className="h-8 w-8 mr-2">
                  <AvatarImage src={emp.pictureUrl ?? undefined} />
                  <AvatarFallback className="text-xs">{initials}</AvatarFallback>
                </Avatar>
                <div className="flex flex-col">
                  <span>{emp.fullName}</span>
                  <span className="text-xs text-muted-foreground">
                    {emp.jobTitle || 'No title'}
                  </span>
                </div>
              </CommandItem>
            );
          })}
        </CommandGroup>
      </CommandList>
    </CommandDialog>
  );
}

Note: The highlightedNodeId prop and focus functionality were already added to OrgChart in Step 70.

Update apps/web/app/dashboard/org/org-chart-view.tsx:

'use client';

import { useState, useMemo } from 'react';
import { useSearchParams } from 'next/navigation';
import { useOrgChart, useUpdateManager } from '@/lib/queries/org-chart';
import { OrgChart } from '@/components/org-chart';
import { EmployeeDetailSheet } from '@/components/org-chart/employee-detail-sheet';
import { SearchCommand } from '@/components/org-chart/search-command';
import type { EmployeeNodeData } from '@/components/org-chart';
import { Skeleton } from '@/components/ui/skeleton';
import { AlertCircle } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { toast } from 'sonner';

export function OrgChartView() {
  const searchParams = useSearchParams();
  const departmentId = searchParams.get('departmentId') || undefined;

  const { data, isLoading, error } = useOrgChart({ departmentId });
  const updateManager = useUpdateManager();
  const [selectedEmployee, setSelectedEmployee] = useState<EmployeeNodeData | null>(null);
  const [sheetOpen, setSheetOpen] = useState(false);
  const [highlightedNodeId, setHighlightedNodeId] = useState<string | null>(null);
  const [searchOpen, setSearchOpen] = useState(false);

  // Extract employee data for search
  const employees = useMemo(
    () => data?.nodes.map((n) => n.data) ?? [],
    [data?.nodes]
  );

  const handleNodeClick = (employeeId: string) => {
    const employee = data?.nodes.find((n) => n.data.id === employeeId)?.data;
    if (employee) {
      setSelectedEmployee(employee);
      setSheetOpen(true);
    }
  };

  const handleManagerChange = async (employeeId: string, newManagerId: string) => {
    try {
      await updateManager.mutateAsync({ employeeId, managerId: newManagerId });
      toast.success('Manager updated successfully');
    } catch (err) {
      toast.error('Failed to update manager', {
        description: err instanceof Error ? err.message : 'Please try again',
      });
      throw err;
    }
  };

  const handleSearchSelect = (employeeId: string) => {
    setHighlightedNodeId(employeeId);
    // Clear highlight after animation
    setTimeout(() => setHighlightedNodeId(null), 2000);
  };

  if (isLoading) {
    return (
      <div className="flex items-center justify-center h-full">
        <div className="space-y-4 w-64">
          <Skeleton className="h-20 w-full" />
          <Skeleton className="h-20 w-full" />
          <Skeleton className="h-20 w-full" />
        </div>
      </div>
    );
  }

  if (error) {
    return (
      <div className="flex items-center justify-center h-full p-4">
        <Alert variant="destructive" className="max-w-md">
          <AlertCircle className="h-4 w-4" />
          <AlertTitle>Error loading org chart</AlertTitle>
          <AlertDescription>
            {error instanceof Error ? error.message : 'Something went wrong'}
          </AlertDescription>
        </Alert>
      </div>
    );
  }

  if (!data || data.nodes.length === 0) {
    return (
      <div className="flex items-center justify-center h-full">
        <div className="text-center">
          <p className="text-lg text-muted-foreground">No employees found</p>
          <p className="text-sm text-muted-foreground mt-1">
            {departmentId
              ? 'No employees in this department'
              : 'Add employees to see them in the org chart'}
          </p>
        </div>
      </div>
    );
  }

  return (
    <>
      <OrgChart
        nodes={data.nodes}
        edges={data.edges}
        onNodeClick={handleNodeClick}
        onManagerChange={handleManagerChange}
        highlightedNodeId={highlightedNodeId}
      />

      <EmployeeDetailSheet
        employee={selectedEmployee}
        open={sheetOpen}
        onOpenChange={setSheetOpen}
      />

      <SearchCommand
        employees={employees}
        onSelect={handleSearchSelect}
        open={searchOpen}
        onOpenChange={setSearchOpen}
      />
    </>
  );
}

Add search button to the page header that opens the search dialog. Since the search state lives in OrgChartView, we need to convert the page to a client component or lift the button there.

Recommended approach: Move the search button into OrgChartView as a floating button, keeping the page as a server component.

Update apps/web/app/dashboard/org/org-chart-view.tsx to include the search button:

// Add to imports
import { Button } from '@/components/ui/button';
import { Search } from 'lucide-react';

// Add the search button in the return, before the OrgChart:
return (
  <>
    {/* Search button - positioned in header area */}
    <div className="absolute top-4 right-4 z-10">
      <Button
        variant="outline"
        size="sm"
        className="gap-2"
        onClick={() => setSearchOpen(true)}
      >
        <Search className="h-4 w-4" />
        <span className="hidden sm:inline">Search</span>
        <kbd className="pointer-events-none hidden h-5 select-none items-center gap-1 rounded-lg bg-gray-100 shadow-sm px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex">
          <span className="text-xs"></span>K
        </kbd>
      </Button>
    </div>

    <OrgChart
      ...
    />
    ...
  </>
);

Alternatively, update the page.tsx header to use the simpler approach. Update apps/web/app/dashboard/org/page.tsx to remove the non-functional button and add a hint text:

Update apps/web/app/dashboard/org/page.tsx - remove the non-functional search button from the header (the working one is now in OrgChartView):

import { Metadata } from 'next';
import { OrgChartView } from './org-chart-view';
import { DepartmentFilter } from './department-filter';
import { Suspense } from 'react';

export const metadata: Metadata = {
  title: 'Organization Chart | HRMS',
  description: 'View your organization structure',
};

export default function OrgChartPage() {
  return (
    <div className="flex flex-col h-[calc(100vh-4rem)]">
      <div className="flex items-center justify-between p-6 border-b border-gray-100">
        <div>
          <h1 className="text-2xl font-bold">Organization Chart</h1>
          <p className="text-muted-foreground">
            View and explore your organization structure
          </p>
        </div>
        <div className="flex items-center gap-2">
          <Suspense fallback={null}>
            <DepartmentFilter />
          </Suspense>
        </div>
      </div>

      <div className="flex-1 relative">
        <Suspense fallback={null}>
          <OrgChartView />
        </Suspense>
      </div>
    </div>
  );
}

Update apps/web/components/org-chart/index.ts:

export { EmployeeNode, type EmployeeNodeData } from './employee-node';
export { OrgChart } from './org-chart';
export { useLayoutedElements } from './use-layout';
export { EmployeeDetailSheet } from './employee-detail-sheet';
export { SearchCommand } from './search-command';

Gate

cd apps/web

# Install Command if not present
npx shadcn@latest add command

npm run build
# Should build without errors

npm run dev
# Navigate to /org
# - Press Ctrl/Cmd + K to open search
# - Type employee name
# - Select result - view should center on that employee
# - Employee should be highlighted briefly

Common Errors

ErrorCauseFix
Command not foundShadcn not installedRun npx shadcn@latest add command
setViewport not defineduseReactFlow not calledImport and destructure from useReactFlow

Rollback

rm apps/web/components/org-chart/search-command.tsx

Lock

apps/web/components/org-chart/search-command.tsx
apps/web/app/dashboard/org/page.tsx (with search button)

Checkpoint

  • Ctrl/Cmd + K opens search
  • Search filters employees by name
  • Selecting result centers view
  • Selected node is highlighted
  • Phase 04 complete!

Phase 04 Complete Checklist

Dependencies Installed

  • @xyflow/react (React Flow v12+)
  • @dagrejs/dagre (automatic layout)

Shadcn Components Added

  • avatar
  • badge
  • button
  • skeleton
  • alert
  • sheet
  • separator
  • alert-dialog
  • sonner
  • select
  • command

API Endpoints

  • GET /api/v1/org/employees/chart - returns nodes and edges

Frontend Components

  • EmployeeNode - custom React Flow node
  • OrgChart - main chart component
  • useLayoutedElements - dagre layout hook
  • EmployeeDetailSheet - slide-out employee info
  • SearchCommand - Ctrl+K search dialog
  • DepartmentFilter - dropdown filter

Features Working

  • Org chart renders with hierarchy
  • Automatic tree layout
  • Zoom in/out buttons
  • Pan with drag
  • MiniMap for navigation
  • Click node opens detail sheet
  • Drag to reassign manager
  • Confirmation dialog for changes
  • Filter by department
  • Search employees (Ctrl/Cmd + K)
  • Center view on search result

Locked Files After Phase 04

  • All Phase 3 locks, plus:
  • apps/api/src/org/org.repository.ts (getOrgChartData)
  • apps/api/src/org/org.service.ts (getOrgChartData)
  • apps/api/src/org/org.controller.ts (chart endpoint)
  • apps/web/components/org-chart/*
  • apps/web/app/dashboard/org/*
  • apps/web/lib/queries/org-chart.ts
  • apps/web/lib/queries/departments.ts

Step 73: Add Org Chart Export (ORG-08)

Input

  • Step 72 complete
  • Org chart renders correctly

Constraints

  • Export to PNG for sharing
  • Export to PDF for printing
  • Use html2canvas for image capture
  • ONLY add export functionality

Task

1. Install dependencies:

cd apps/web
npm install html2canvas jspdf
npm install -D @types/html2canvas

2. Create Export Button Component at apps/web/components/org-chart/export-button.tsx:

'use client';

import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Download, FileImage, FileText, Loader2 } from 'lucide-react';
import { useReactFlow, getNodesBounds, getViewportForBounds } from '@xyflow/react';
import html2canvas from 'html2canvas';
import { jsPDF } from 'jspdf';

interface ExportButtonProps {
  filename?: string;
}

export function ExportButton({ filename = 'org-chart' }: ExportButtonProps) {
  const [isExporting, setIsExporting] = useState(false);
  const [exportType, setExportType] = useState<'png' | 'pdf' | null>(null);
  const { getNodes } = useReactFlow();

  const exportToPng = async () => {
    setIsExporting(true);
    setExportType('png');

    try {
      const nodes = getNodes();
      if (nodes.length === 0) {
        alert('No nodes to export');
        return;
      }

      // Find the React Flow viewport element
      const viewport = document.querySelector('.react-flow__viewport') as HTMLElement;
      if (!viewport) {
        throw new Error('Could not find org chart viewport');
      }

      // Get the bounds of all nodes
      const nodesBounds = getNodesBounds(nodes);
      const padding = 50;

      // Calculate dimensions
      const width = nodesBounds.width + padding * 2;
      const height = nodesBounds.height + padding * 2;

      // Create canvas with all nodes visible
      const canvas = await html2canvas(viewport, {
        backgroundColor: '#ffffff',
        scale: 2, // Higher resolution
        logging: false,
        useCORS: true,
        width,
        height,
        x: nodesBounds.x - padding,
        y: nodesBounds.y - padding,
      });

      // Create download link
      const link = document.createElement('a');
      link.download = `${filename}-${new Date().toISOString().split('T')[0]}.png`;
      link.href = canvas.toDataURL('image/png');
      link.click();
    } catch (error) {
      console.error('Export failed:', error);
      alert('Failed to export org chart. Please try again.');
    } finally {
      setIsExporting(false);
      setExportType(null);
    }
  };

  const exportToPdf = async () => {
    setIsExporting(true);
    setExportType('pdf');

    try {
      const nodes = getNodes();
      if (nodes.length === 0) {
        alert('No nodes to export');
        return;
      }

      const viewport = document.querySelector('.react-flow__viewport') as HTMLElement;
      if (!viewport) {
        throw new Error('Could not find org chart viewport');
      }

      const nodesBounds = getNodesBounds(nodes);
      const padding = 50;

      const width = nodesBounds.width + padding * 2;
      const height = nodesBounds.height + padding * 2;

      const canvas = await html2canvas(viewport, {
        backgroundColor: '#ffffff',
        scale: 2,
        logging: false,
        useCORS: true,
        width,
        height,
        x: nodesBounds.x - padding,
        y: nodesBounds.y - padding,
      });

      // Calculate PDF dimensions (fit to A4 or larger)
      const imgWidth = canvas.width;
      const imgHeight = canvas.height;
      const ratio = imgWidth / imgHeight;

      // Use landscape if wider than tall
      const orientation = ratio > 1 ? 'landscape' : 'portrait';
      const pdf = new jsPDF({
        orientation,
        unit: 'mm',
        format: 'a4',
      });

      const pdfWidth = pdf.internal.pageSize.getWidth();
      const pdfHeight = pdf.internal.pageSize.getHeight();

      // Scale to fit page while maintaining aspect ratio
      let finalWidth = pdfWidth - 20; // 10mm margin on each side
      let finalHeight = finalWidth / ratio;

      if (finalHeight > pdfHeight - 20) {
        finalHeight = pdfHeight - 20;
        finalWidth = finalHeight * ratio;
      }

      // Center on page
      const x = (pdfWidth - finalWidth) / 2;
      const y = (pdfHeight - finalHeight) / 2;

      // Add title
      pdf.setFontSize(16);
      pdf.text('Organization Chart', pdfWidth / 2, 10, { align: 'center' });

      // Add date
      pdf.setFontSize(10);
      pdf.text(
        `Generated: ${new Date().toLocaleDateString()}`,
        pdfWidth / 2,
        16,
        { align: 'center' }
      );

      // Add the chart image
      const imgData = canvas.toDataURL('image/png');
      pdf.addImage(imgData, 'PNG', x, 20, finalWidth, finalHeight);

      // Download
      pdf.save(`${filename}-${new Date().toISOString().split('T')[0]}.pdf`);
    } catch (error) {
      console.error('Export failed:', error);
      alert('Failed to export org chart. Please try again.');
    } finally {
      setIsExporting(false);
      setExportType(null);
    }
  };

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline" size="sm" disabled={isExporting}>
          {isExporting ? (
            <>
              <Loader2 className="h-4 w-4 mr-2 animate-spin" />
              Exporting...
            </>
          ) : (
            <>
              <Download className="h-4 w-4 mr-2" />
              Export
            </>
          )}
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={exportToPng} disabled={isExporting}>
          <FileImage className="h-4 w-4 mr-2" />
          Export as PNG
          {exportType === 'png' && <Loader2 className="h-4 w-4 ml-2 animate-spin" />}
        </DropdownMenuItem>
        <DropdownMenuItem onClick={exportToPdf} disabled={isExporting}>
          <FileText className="h-4 w-4 mr-2" />
          Export as PDF
          {exportType === 'pdf' && <Loader2 className="h-4 w-4 ml-2 animate-spin" />}
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

3. Add Export Button to Org Chart Page - Update apps/web/app/dashboard/org/page.tsx:

import { ExportButton } from '@/components/org-chart/export-button';

// In the header section, add after DepartmentFilter:
<div className="flex items-center gap-2">
  <Suspense fallback={null}>
    <DepartmentFilter />
  </Suspense>
  <ExportButton filename="org-chart" />
</div>

4. Wrap ExportButton in ReactFlowProvider - The ExportButton must be inside the ReactFlow context. Update apps/web/app/dashboard/org/org-chart-view.tsx:

// Add ExportButton inside the OrgChartView component, after ReactFlow controls
import { ExportButton } from '@/components/org-chart/export-button';

// Inside the ReactFlow component, add:
<Panel position="top-right" className="flex gap-2">
  <ExportButton filename="org-chart" />
</Panel>

5. Update exports in apps/web/components/org-chart/index.ts:

export { ExportButton } from './export-button';

Gate

cd apps/web

# Install shadcn dropdown-menu if not present
npx shadcn@latest add dropdown-menu

npm run build
# Should build without errors

npm run dev
# Navigate to /org
# - Click "Export" button in top right
# - Select "Export as PNG" - should download PNG file
# - Select "Export as PDF" - should download PDF file
# - PDF should have title and date

Common Errors

ErrorCauseFix
html2canvas is not definedImport missingCheck import statement
getNodes is undefinedNot in ReactFlow contextMove ExportButton inside ReactFlowProvider
Blurry exportLow scaleIncrease scale in html2canvas options

Rollback

rm apps/web/components/org-chart/export-button.tsx
npm uninstall html2canvas jspdf

Checkpoint

  • Export button appears in org chart
  • PNG export downloads image file
  • PDF export downloads PDF with title and date
  • Export captures all visible nodes
  • Type "GATE 73 PASSED" to continue

Quick Reference: API Endpoints

All endpoints require header: x-tenant-id: <tenant-id>

# Org Chart Data
GET /api/v1/org/employees/chart
GET /api/v1/org/employees/chart?departmentId=...

# Manager Assignment (from Phase 03)
POST /api/v1/org/employees/:id/manager
  Body: { "managerId": "...", "type": "primary" }

Phase Completion Checklist (MANDATORY)

BEFORE MOVING TO NEXT PHASE

Complete ALL items before proceeding. Do NOT skip any step.

1. Gate Verification

  • All step gates passed
  • Org chart renders with React Flow
  • Department filtering works
  • Zoom/pan controls functional
  • Export to PNG/PDF working

2. Update PROJECT_STATE.md

- Mark Phase 04 as COMPLETED with timestamp
- Update "Current Phase" to Phase 05
- Add session log entry

3. Update WHAT_EXISTS.md

## API Endpoints
- GET /api/v1/org/employees/chart

## Frontend Routes
- /dashboard/org

## Established Patterns
- OrgChart component with React Flow

4. Git Tag & Commit

git add PROJECT_STATE.md WHAT_EXISTS.md
git commit -m "chore: complete Phase 04 - Org Visualization"
git tag phase-04-org-visualization

Next Phase

After verification, proceed to Phase 05: Time Off


Last Updated: 2025-11-30

On this page

Phase 04: Org Chart VisualizationStep Timing EstimatesPhase Context (READ FIRST)What This Phase AccomplishesWhat This Phase Does NOT IncludeBluewoo Anti-Pattern ReminderStep 63: Install React Flow and Setup PrerequisitesInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 64: Create Org Chart Data API EndpointInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 65: Create EmployeeNode ComponentInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 66: Create OrgChart ComponentInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 67: Add Org Chart Page to DashboardInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 68: Add Zoom and Pan ControlsInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 69: Add Click-to-View Employee DetailInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 70: Add Drag-to-Reassign ManagerInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 71: Add Department FilterInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 72: Add Search in Org ChartInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointPhase 04 Complete ChecklistDependencies InstalledShadcn Components AddedAPI EndpointsFrontend ComponentsFeatures WorkingLocked Files After Phase 04Step 73: Add Org Chart Export (ORG-08)InputConstraintsTaskGateCommon ErrorsRollbackCheckpointQuick Reference: API EndpointsPhase Completion Checklist (MANDATORY)1. Gate Verification2. Update PROJECT_STATE.md3. Update WHAT_EXISTS.md4. Git Tag & CommitNext Phase