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.
| Attribute | Value |
|---|---|
| Steps | 63-72 |
| Estimated Time | 5-8 hours |
| Dependencies | Phase 03 complete (Org structure with manager relationships) |
| Completion Gate | Org chart renders with employee hierarchy, supports zoom/pan, search, and click-to-view employee |
Step Timing Estimates
| Step | Task | Est. Time |
|---|---|---|
| 63 | Install React Flow | 15 min |
| 64 | Create org chart data API endpoint | 30 min |
| 65 | Create EmployeeNode component | 25 min |
| 66 | Create OrgChart component | 35 min |
| 67 | Add org chart page to dashboard | 20 min |
| 68 | Add zoom and pan controls | 20 min |
| 69 | Add click-to-view employee detail | 25 min |
| 70 | Add drag-to-reassign manager | 35 min |
| 71 | Add department filter | 25 min |
| 72 | Add search in org chart | 30 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 neededCreate 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 errorsCommon Errors
| Error | Cause | Fix |
|---|---|---|
Module not found: @xyflow/react | Not installed | Run npm install @xyflow/react |
Module not found: @tanstack/react-query | Not installed | Run npm install @tanstack/react-query |
Type errors | Old React Flow version | Ensure v12+ installed |
getSession not found | next-auth not configured | Ensure 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.tsLock
apps/web/package.json (@xyflow/react, @tanstack/react-query dependencies)
apps/web/app/providers.tsx
apps/web/lib/api.tsCheckpoint
- @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
| Error | Cause | Fix |
|---|---|---|
Property 'orgRelations' does not exist | Relation not included | Check include statement |
Empty nodes array | No employees | Create test employees first |
Rollback
# Remove the new methods from org.repository.ts, org.service.ts, org.controller.tsLock
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 codeCommon Errors
| Error | Cause | Fix |
|---|---|---|
Module '@/components/ui/avatar' not found | Shadcn Avatar not installed | Run npx shadcn@latest add avatar |
Module '@/components/ui/badge' not found | Shadcn Badge not installed | Run npx shadcn@latest add badge |
Rollback
rm apps/web/components/org-chart/employee-node.tsxLock
apps/web/components/org-chart/employee-node.tsxCheckpoint
- 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/dagreCreate 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.tsCommon Errors
| Error | Cause | Fix |
|---|---|---|
Module '@dagrejs/dagre' not found | Not installed | Run npm install @dagrejs/dagre |
Type errors with Node/Edge | Wrong import | Import 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.tsLock
apps/web/components/org-chart/org-chart.tsx
apps/web/components/org-chart/use-layout.ts
apps/web/lib/queries/org-chart.tsCheckpoint
- 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 chartCommon Errors
| Error | Cause | Fix |
|---|---|---|
Page not found | Route not created | Check file path is correct |
Skeleton not found | Shadcn not installed | Run npx shadcn@latest add skeleton |
Alert not found | Shadcn not installed | Run npx shadcn@latest add alert |
Rollback
rm -rf apps/web/app/dashboard/orgLock
apps/web/app/dashboard/org/page.tsx
apps/web/app/dashboard/org/org-chart-view.tsxCheckpoint
- /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 draggableCommon Errors
| Error | Cause | Fix |
|---|---|---|
useReactFlow must be used within ReactFlowProvider | Missing provider | Wrap with ReactFlowProvider |
Button not found | Shadcn not installed | Run npx shadcn@latest add button |
Rollback
# Restore previous version of org-chart.tsxLock
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/summaryendpoint 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 detailsCommon Errors
| Error | Cause | Fix |
|---|---|---|
Sheet not found | Shadcn not installed | Run npx shadcn@latest add sheet |
Separator not found | Shadcn not installed | Run npx shadcn@latest add separator |
Rollback
rm apps/web/components/org-chart/employee-detail-sheet.tsxLock
apps/web/components/org-chart/employee-detail-sheet.tsx
apps/web/app/dashboard/org/org-chart-view.tsxCheckpoint
- 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's bottom handle to an employee'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}'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 sonnerImportant: 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 appearCommon Errors
| Error | Cause | Fix |
|---|---|---|
AlertDialog not found | Shadcn not installed | Run npx shadcn@latest add alert-dialog |
toast not found | Sonner not installed | Run npx shadcn@latest add sonner |
Toast not showing | Toaster not in layout | Add <Toaster /> to root layout |
Connection not triggering | Handles not connected | Check Handle positions |
Rollback
# Restore previous versions of org-chart.tsx and org-chart-view.tsxLock
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
/departmentsAPI
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 parameterCommon Errors
| Error | Cause | Fix |
|---|---|---|
Select not found | Shadcn not installed | Run npx shadcn@latest add select |
useSearchParams must be wrapped in Suspense | Missing Suspense | Add Suspense boundary |
Rollback
rm apps/web/app/dashboard/org/department-filter.tsx
rm apps/web/lib/queries/departments.tsLock
apps/web/app/dashboard/org/department-filter.tsx
apps/web/lib/queries/departments.tsCheckpoint
- 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 brieflyCommon Errors
| Error | Cause | Fix |
|---|---|---|
Command not found | Shadcn not installed | Run npx shadcn@latest add command |
setViewport not defined | useReactFlow not called | Import and destructure from useReactFlow |
Rollback
rm apps/web/components/org-chart/search-command.tsxLock
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.tsapps/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/html2canvas2. 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 dateCommon Errors
| Error | Cause | Fix |
|---|---|---|
html2canvas is not defined | Import missing | Check import statement |
getNodes is undefined | Not in ReactFlow context | Move ExportButton inside ReactFlowProvider |
| Blurry export | Low scale | Increase scale in html2canvas options |
Rollback
rm apps/web/components/org-chart/export-button.tsx
npm uninstall html2canvas jspdfCheckpoint
- 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 entry3. Update WHAT_EXISTS.md
## API Endpoints
- GET /api/v1/org/employees/chart
## Frontend Routes
- /dashboard/org
## Established Patterns
- OrgChart component with React Flow4. 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-visualizationNext Phase
After verification, proceed to Phase 05: Time Off
Last Updated: 2025-11-30