diff --git a/src/app/[locale]/(protected)/hr/org-chart/page.tsx b/src/app/[locale]/(protected)/hr/org-chart/page.tsx
new file mode 100644
index 00000000..2b77242f
--- /dev/null
+++ b/src/app/[locale]/(protected)/hr/org-chart/page.tsx
@@ -0,0 +1,23 @@
+/**
+ * 조직도 관리 페이지 (Org Chart Management)
+ *
+ * 드래그앤드롭으로 직원 배치, 부서 순서/계층 변경, 부서 숨기기 기능 제공
+ */
+
+import { Suspense } from 'react';
+import { OrgChartManagement } from '@/components/hr/OrgChart';
+import { ListPageSkeleton } from '@/components/ui/skeleton';
+import type { Metadata } from 'next';
+
+export const metadata: Metadata = {
+ title: '조직도 관리',
+ description: '조직도를 관리합니다',
+};
+
+export default function OrgChartPage() {
+ return (
+ }>
+
+
+ );
+}
diff --git a/src/components/hr/OrgChart/CompanyHeader.tsx b/src/components/hr/OrgChart/CompanyHeader.tsx
new file mode 100644
index 00000000..63905629
--- /dev/null
+++ b/src/components/hr/OrgChart/CompanyHeader.tsx
@@ -0,0 +1,24 @@
+'use client';
+
+import { Building2 } from 'lucide-react';
+import type { CompanyInfo } from './types';
+
+interface CompanyHeaderProps {
+ company: CompanyInfo;
+}
+
+export function CompanyHeader({ company }: CompanyHeaderProps) {
+ return (
+
+
+
+
+
+
{company.name}
+ {company.ceoName && (
+
CEO: {company.ceoName}
+ )}
+
+
+ );
+}
diff --git a/src/components/hr/OrgChart/DepartmentNode.tsx b/src/components/hr/OrgChart/DepartmentNode.tsx
new file mode 100644
index 00000000..d190e961
--- /dev/null
+++ b/src/components/hr/OrgChart/DepartmentNode.tsx
@@ -0,0 +1,208 @@
+'use client';
+
+import { useState } from 'react';
+import { useDraggable, useDroppable } from '@dnd-kit/core';
+import {
+ ChevronDown, ChevronRight, ChevronUp, EyeOff,
+ GitBranch, GripVertical, Minus,
+} from 'lucide-react';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+import { EmployeeCard } from './EmployeeCard';
+import type { OrgDepartment } from './types';
+
+interface DepartmentNodeProps {
+ department: OrgDepartment;
+ depth: number;
+ siblingCount: number;
+ siblingIndex: number;
+ onUnassignEmployee: (employeeId: number) => void;
+ onAssignEmployee?: (employeeId: number, departmentId: number) => void;
+ onHideDepartment: (departmentId: number) => void;
+ onMoveDeptUp: (departmentId: number) => void;
+ onMoveDeptDown: (departmentId: number) => void;
+ allDepartments?: OrgDepartment[];
+}
+
+const DEPTH_COLORS = [
+ { bg: 'bg-violet-50', border: 'border-violet-200', text: 'text-violet-700', icon: 'text-violet-500' },
+ { bg: 'bg-indigo-50', border: 'border-indigo-200', text: 'text-indigo-700', icon: 'text-indigo-500' },
+ { bg: 'bg-gray-50', border: 'border-gray-200', text: 'text-gray-700', icon: 'text-gray-500' },
+];
+
+export function DepartmentNode({
+ department,
+ depth,
+ siblingCount,
+ siblingIndex,
+ onUnassignEmployee,
+ onAssignEmployee,
+ onHideDepartment,
+ onMoveDeptUp,
+ onMoveDeptDown,
+ allDepartments,
+}: DepartmentNodeProps) {
+ const [isExpanded, setIsExpanded] = useState(true);
+ const [showHideButton, setShowHideButton] = useState(false);
+
+ const { setNodeRef: setDropRef, isOver } = useDroppable({
+ id: `dept-${department.id}`,
+ data: { type: 'department', departmentId: department.id },
+ });
+
+ const {
+ attributes: dragAttrs,
+ listeners: dragListeners,
+ setNodeRef: setDragRef,
+ isDragging,
+ } = useDraggable({
+ id: `dept-drag-${department.id}`,
+ data: { type: 'department', department },
+ });
+
+ const hasChildren = department.children.length > 0;
+ const hasEmployees = department.employees.length > 0;
+ const colors = DEPTH_COLORS[Math.min(depth, DEPTH_COLORS.length - 1)];
+ const DepthIcon = depth === 0 ? GitBranch : Minus;
+
+ const canMoveUp = siblingIndex > 0;
+ const canMoveDown = siblingIndex < siblingCount - 1;
+
+ return (
+
+ {depth > 0 && (
+
+ )}
+
+
+
setShowHideButton((prev) => !prev)}
+ >
+ {/* 부서 헤더 */}
+
+ {/* 드래그 핸들 — 데스크톱만 */}
+
+
+
+
+ {/* 접기/펼치기 */}
+ {(hasChildren || hasEmployees) ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {department.name}
+
+
+ {department.code && (
+
+ {department.code}
+
+ )}
+
+ {hasEmployees && (
+
+ {department.employees.length}명
+
+ )}
+
+ {/* 순서 변경 + 숨기기 */}
+
+ {canMoveUp && (
+
+ )}
+ {canMoveDown && (
+
+ )}
+ {showHideButton && (
+
+ )}
+
+
+
+ {/* 직원 목록 */}
+ {isExpanded && hasEmployees && (
+
+ {department.employees.map((emp) => (
+
+ ))}
+
+ )}
+
+
+ {/* 하위 부서 (재귀) */}
+ {isExpanded && hasChildren && (
+
+ {department.children.map((child, idx) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/hr/OrgChart/EmployeeCard.tsx b/src/components/hr/OrgChart/EmployeeCard.tsx
new file mode 100644
index 00000000..e5ab5bd6
--- /dev/null
+++ b/src/components/hr/OrgChart/EmployeeCard.tsx
@@ -0,0 +1,91 @@
+'use client';
+
+import { useDraggable } from '@dnd-kit/core';
+import { GripVertical, UserX, ArrowRight } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import type { OrgEmployee, OrgDepartment } from './types';
+
+interface EmployeeCardProps {
+ employee: OrgEmployee;
+ sourceDeptId: number | null;
+ onUnassign?: (employeeId: number) => void;
+ onAssignClick?: (employee: OrgEmployee) => void;
+ showUnassignButton?: boolean;
+ showAssignButton?: boolean;
+}
+
+export function EmployeeCard({
+ employee,
+ sourceDeptId,
+ onUnassign,
+ onAssignClick,
+ showUnassignButton = false,
+ showAssignButton = false,
+}: EmployeeCardProps) {
+ const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
+ id: `emp-${employee.id}`,
+ data: {
+ type: 'employee' as const,
+ employee,
+ sourceDeptId,
+ },
+ });
+
+ const style = transform
+ ? { transform: `translate(${transform.x}px, ${transform.y}px)` }
+ : undefined;
+
+ return (
+
+ {/* 드래그 핸들 — 데스크톱만 */}
+
+
+
+
+
+ {employee.positionLabel && (
+ {employee.positionLabel}
+ )}
+ {employee.displayName}
+
+
+ {/* 미배치 버튼 */}
+ {showUnassignButton && onUnassign && (
+
+
+
+ 미배치 직원으로 변경
+
+
+ )}
+
+ {/* 배치 버튼 — 클릭 시 부서 선택 모달 */}
+ {showAssignButton && onAssignClick && (
+
+ )}
+
+ );
+}
diff --git a/src/components/hr/OrgChart/HiddenDepartmentsPanel.tsx b/src/components/hr/OrgChart/HiddenDepartmentsPanel.tsx
new file mode 100644
index 00000000..59ef3a01
--- /dev/null
+++ b/src/components/hr/OrgChart/HiddenDepartmentsPanel.tsx
@@ -0,0 +1,47 @@
+'use client';
+
+import { Eye } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import type { HiddenDepartment } from './types';
+
+interface HiddenDepartmentsPanelProps {
+ departments: HiddenDepartment[];
+ onRestore: (departmentId: number) => void;
+}
+
+export function HiddenDepartmentsPanel({ departments, onRestore }: HiddenDepartmentsPanelProps) {
+ if (departments.length === 0) return null;
+
+ return (
+
+
+ 숨겨진 부서 ({departments.length})
+
+
+ {departments.map((dept) => (
+
+ {dept.name}
+ {dept.code && (
+
+ {dept.code}
+
+ )}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/hr/OrgChart/OrgChartStats.tsx b/src/components/hr/OrgChart/OrgChartStats.tsx
new file mode 100644
index 00000000..a25a79a1
--- /dev/null
+++ b/src/components/hr/OrgChart/OrgChartStats.tsx
@@ -0,0 +1,24 @@
+'use client';
+
+import { Badge } from '@/components/ui/badge';
+import type { OrgChartStats as Stats } from './types';
+
+interface OrgChartStatsProps {
+ stats: Stats;
+}
+
+export function OrgChartStats({ stats }: OrgChartStatsProps) {
+ return (
+
+
+ 전체 {stats.total}
+
+
+ 배치 {stats.assigned}
+
+
+ 미배치 {stats.unassigned}
+
+
+ );
+}
diff --git a/src/components/hr/OrgChart/OrgChartTree.tsx b/src/components/hr/OrgChart/OrgChartTree.tsx
new file mode 100644
index 00000000..f6e64d8b
--- /dev/null
+++ b/src/components/hr/OrgChart/OrgChartTree.tsx
@@ -0,0 +1,67 @@
+'use client';
+
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { CompanyHeader } from './CompanyHeader';
+import { DepartmentNode } from './DepartmentNode';
+import type { OrgDepartment, CompanyInfo } from './types';
+
+interface OrgChartTreeProps {
+ company: CompanyInfo;
+ departments: OrgDepartment[];
+ onUnassignEmployee: (employeeId: number) => void;
+ onHideDepartment: (departmentId: number) => void;
+ onMoveDeptUp: (departmentId: number) => void;
+ onMoveDeptDown: (departmentId: number) => void;
+}
+
+export function OrgChartTree({
+ company,
+ departments,
+ onUnassignEmployee,
+ onHideDepartment,
+ onMoveDeptUp,
+ onMoveDeptDown,
+}: OrgChartTreeProps) {
+ // orgchartHidden인 부서 필터링 (트리에서 제외)
+ const visibleDepartments = filterHiddenDepartments(departments);
+
+ return (
+
+
+
+
+
+ {visibleDepartments.length > 0 ? (
+ visibleDepartments.map((dept, idx) => (
+
+ ))
+ ) : (
+
+ 표시할 부서가 없습니다
+
+ )}
+
+
+
+ );
+}
+
+/** 숨겨진 부서를 트리에서 재귀적으로 제거 */
+function filterHiddenDepartments(departments: OrgDepartment[]): OrgDepartment[] {
+ return departments
+ .filter((dept) => !dept.orgchartHidden)
+ .map((dept) => ({
+ ...dept,
+ children: filterHiddenDepartments(dept.children),
+ }));
+}
diff --git a/src/components/hr/OrgChart/UnassignedPanel.tsx b/src/components/hr/OrgChart/UnassignedPanel.tsx
new file mode 100644
index 00000000..d0201a60
--- /dev/null
+++ b/src/components/hr/OrgChart/UnassignedPanel.tsx
@@ -0,0 +1,83 @@
+'use client';
+
+import { useState } from 'react';
+import { useDroppable } from '@dnd-kit/core';
+import { Search, UserX } from 'lucide-react';
+import { Input } from '@/components/ui/input';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { cn } from '@/lib/utils';
+import { EmployeeCard } from './EmployeeCard';
+import type { OrgEmployee } from './types';
+
+interface UnassignedPanelProps {
+ employees: OrgEmployee[];
+ onAssignClick: (employee: OrgEmployee) => void;
+}
+
+export function UnassignedPanel({ employees, onAssignClick }: UnassignedPanelProps) {
+ const [searchQuery, setSearchQuery] = useState('');
+
+ const { setNodeRef, isOver } = useDroppable({
+ id: 'unassigned-zone',
+ data: { type: 'unassigned-zone' },
+ });
+
+ const filtered = searchQuery.trim()
+ ? employees.filter((e) => {
+ const q = searchQuery.toLowerCase();
+ return (
+ e.displayName.toLowerCase().includes(q) ||
+ (e.positionLabel && e.positionLabel.toLowerCase().includes(q))
+ );
+ })
+ : employees;
+
+ return (
+
+
+
+
+ 미배치 직원
+ {employees.length}명
+
+
+
+ setSearchQuery(e.target.value)}
+ className="h-7 md:h-8 pl-7 md:pl-8 text-xs md:text-sm"
+ />
+
+
+
+
+
+
+ {filtered.length > 0 ? (
+ filtered.map((emp) => (
+
+ ))
+ ) : (
+
+ {searchQuery ? '검색 결과가 없습니다' : '미배치 직원이 없습니다'}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/hr/OrgChart/actions.ts b/src/components/hr/OrgChart/actions.ts
new file mode 100644
index 00000000..5d85c206
--- /dev/null
+++ b/src/components/hr/OrgChart/actions.ts
@@ -0,0 +1,148 @@
+/**
+ * 조직도 관리 서버 액션
+ *
+ * API Endpoints:
+ * - GET /api/v1/org-chart - 조직도 전체 조회
+ * - GET /api/v1/org-chart/stats - 통계
+ * - GET /api/v1/org-chart/unassigned - 미배치 직원
+ * - POST /api/v1/org-chart/assign - 직원 배치
+ * - POST /api/v1/org-chart/unassign - 직원 미배치
+ * - PUT /api/v1/org-chart/reorder-employees - 직원 일괄 이동
+ * - PUT /api/v1/org-chart/reorder-departments - 부서 순서/계층 변경
+ * - PATCH /api/v1/org-chart/departments/{id}/toggle-hide - 부서 숨기기 토글
+ */
+
+'use server';
+
+import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
+import { buildApiUrl } from '@/lib/api/query-params';
+import type { ApiOrgChartResponse, OrgChartData, OrgChartStats, OrgEmployee, ApiEmployee } from './types';
+import { transformOrgChartResponse, transformEmployee } from './types';
+
+/**
+ * 조직도 전체 조회
+ * GET /api/v1/org-chart
+ */
+export async function getOrgChart(): Promise> {
+ return executeServerAction({
+ url: buildApiUrl('/api/v1/org-chart'),
+ transform: (data: ApiOrgChartResponse) => transformOrgChartResponse(data),
+ errorMessage: '조직도 조회에 실패했습니다.',
+ });
+}
+
+/**
+ * 조직도 통계
+ * GET /api/v1/org-chart/stats
+ */
+export async function getOrgChartStats(): Promise> {
+ return executeServerAction({
+ url: buildApiUrl('/api/v1/org-chart/stats'),
+ errorMessage: '통계 조회에 실패했습니다.',
+ });
+}
+
+/**
+ * 미배치 직원 목록
+ * GET /api/v1/org-chart/unassigned
+ */
+export async function getUnassignedEmployees(q?: string): Promise> {
+ return executeServerAction({
+ url: buildApiUrl('/api/v1/org-chart/unassigned', {
+ q: q || undefined,
+ }),
+ transform: (data: ApiEmployee[]) => data.map(transformEmployee),
+ errorMessage: '미배치 직원 조회에 실패했습니다.',
+ });
+}
+
+/**
+ * 직원 부서 배치
+ * POST /api/v1/org-chart/assign
+ */
+export async function assignEmployee(
+ employeeId: number,
+ departmentId: number,
+): Promise {
+ return executeServerAction({
+ url: buildApiUrl('/api/v1/org-chart/assign'),
+ method: 'POST',
+ body: {
+ employee_id: employeeId,
+ department_id: departmentId,
+ },
+ errorMessage: '직원 배치에 실패했습니다.',
+ });
+}
+
+/**
+ * 직원 미배치 처리
+ * POST /api/v1/org-chart/unassign
+ */
+export async function unassignEmployee(employeeId: number): Promise {
+ return executeServerAction({
+ url: buildApiUrl('/api/v1/org-chart/unassign'),
+ method: 'POST',
+ body: {
+ employee_id: employeeId,
+ },
+ errorMessage: '직원 미배치 처리에 실패했습니다.',
+ });
+}
+
+/**
+ * 직원 일괄 이동
+ * PUT /api/v1/org-chart/reorder-employees
+ */
+export async function reorderEmployees(
+ moves: { employeeId: number; departmentId: number | null }[],
+): Promise {
+ return executeServerAction({
+ url: buildApiUrl('/api/v1/org-chart/reorder-employees'),
+ method: 'PUT',
+ body: {
+ moves: moves.map((m) => ({
+ employee_id: m.employeeId,
+ department_id: m.departmentId,
+ })),
+ },
+ errorMessage: '직원 이동에 실패했습니다.',
+ });
+}
+
+/**
+ * 부서 순서/계층 변경
+ * PUT /api/v1/org-chart/reorder-departments
+ */
+export async function reorderDepartments(
+ orders: { id: number; parentId: number | null; sortOrder: number }[],
+): Promise {
+ return executeServerAction({
+ url: buildApiUrl('/api/v1/org-chart/reorder-departments'),
+ method: 'PUT',
+ body: {
+ orders: orders.map((o) => ({
+ id: o.id,
+ parent_id: o.parentId,
+ sort_order: o.sortOrder,
+ })),
+ },
+ errorMessage: '부서 순서 변경에 실패했습니다.',
+ });
+}
+
+/**
+ * 부서 숨기기/표시 토글
+ * PATCH /api/v1/org-chart/departments/{id}/toggle-hide
+ */
+export async function toggleDepartmentHide(
+ departmentId: number,
+ hidden: boolean,
+): Promise {
+ return executeServerAction({
+ url: buildApiUrl(`/api/v1/org-chart/departments/${departmentId}/toggle-hide`),
+ method: 'PATCH',
+ body: { hidden },
+ errorMessage: '부서 숨기기 변경에 실패했습니다.',
+ });
+}
diff --git a/src/components/hr/OrgChart/index.tsx b/src/components/hr/OrgChart/index.tsx
new file mode 100644
index 00000000..4a3f6026
--- /dev/null
+++ b/src/components/hr/OrgChart/index.tsx
@@ -0,0 +1,590 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import {
+ DndContext,
+ DragOverlay,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ type DragStartEvent,
+ type DragEndEvent,
+} from '@dnd-kit/core';
+import { toast } from 'sonner';
+import { PageLayout } from '@/components/organisms/PageLayout';
+import { PageHeader } from '@/components/organisms/PageHeader';
+import { Button } from '@/components/ui/button';
+import { Loader2, UserX, ChevronDown, ChevronUp } from 'lucide-react';
+import { OrgChartStats } from './OrgChartStats';
+import { UnassignedPanel } from './UnassignedPanel';
+import { OrgChartTree } from './OrgChartTree';
+import { HiddenDepartmentsPanel } from './HiddenDepartmentsPanel';
+import {
+ getOrgChart,
+ assignEmployee,
+ unassignEmployee as unassignEmployeeAction,
+ reorderEmployees,
+ reorderDepartments,
+ toggleDepartmentHide,
+} from './actions';
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import type { OrgChartData, OrgEmployee, OrgDepartment, DragData } from './types';
+import { findDepartmentById, isDescendantOf } from './types';
+
+export function OrgChartManagement() {
+ const [data, setData] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [activeDrag, setActiveDrag] = useState(null);
+ const [showUnassigned, setShowUnassigned] = useState(false);
+ const [assignTarget, setAssignTarget] = useState(null);
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
+ );
+
+ // 데이터 로딩
+ const loadData = useCallback(async () => {
+ const result = await getOrgChart();
+ if (result.success && result.data) {
+ setData(result.data);
+ } else {
+ toast.error(result.error || '조직도 조회에 실패했습니다.');
+ }
+ setIsLoading(false);
+ }, []);
+
+ useEffect(() => {
+ loadData();
+ }, [loadData]);
+
+ // ========== 드래그 핸들러 ==========
+
+ const handleDragStart = useCallback((event: DragStartEvent) => {
+ const dragData = event.active.data.current as DragData;
+ setActiveDrag(dragData);
+ }, []);
+
+ const handleDragEnd = useCallback(async (event: DragEndEvent) => {
+ setActiveDrag(null);
+ const { active, over } = event;
+ if (!over || !data) return;
+
+ const dragData = active.data.current as DragData;
+ const overId = String(over.id);
+ const overData = over.data.current;
+
+ // --- 직원 드래그 ---
+ if (dragData.type === 'employee' && dragData.employee) {
+ const employee = dragData.employee;
+
+ // 미배치 존으로 드롭
+ if (overId === 'unassigned-zone') {
+ if (dragData.sourceDeptId === null) return;
+ await handleUnassign(employee.id);
+ return;
+ }
+
+ // 부서로 드롭
+ if (overData?.type === 'department') {
+ const targetDeptId = overData.departmentId as number;
+ if (targetDeptId === dragData.sourceDeptId) return;
+
+ // 부서 간 이동 시 reorderEmployees 사용
+ if (dragData.sourceDeptId !== null) {
+ await handleEmployeeMove(employee.id, targetDeptId);
+ } else {
+ // 미배치 → 부서: assign 사용
+ await handleAssign(employee.id, targetDeptId);
+ }
+ }
+ return;
+ }
+
+ // --- 부서 드래그 (reparent) ---
+ if (dragData.type === 'department' && dragData.department) {
+ if (overData?.type !== 'department') return;
+
+ const movedDept = dragData.department;
+ const targetDeptId = overData.departmentId as number;
+
+ // 자기 자신에 드롭 무시
+ if (movedDept.id === targetDeptId) return;
+
+ // 순환 참조 방지: 자손에 드롭하면 무시
+ if (isDescendantOf(data.departments, targetDeptId, movedDept.id)) {
+ toast.error('하위 부서로 이동할 수 없습니다.');
+ return;
+ }
+
+ await handleDeptReparent(movedDept.id, targetDeptId);
+ }
+ }, [data]);
+
+ // ========== 직원 API 핸들러 ==========
+
+ /** 미배치 → 부서 (assign) */
+ const handleAssign = useCallback(async (employeeId: number, departmentId: number) => {
+ if (!data) return;
+ const prevData = data;
+ setData(optimisticAssign(data, employeeId, departmentId));
+
+ const result = await assignEmployee(employeeId, departmentId);
+ if (!result.success) {
+ setData(prevData);
+ toast.error(result.error || '직원 배치에 실패했습니다.');
+ }
+ }, [data]);
+
+ /** 부서 → 부서 (reorderEmployees) */
+ const handleEmployeeMove = useCallback(async (employeeId: number, departmentId: number) => {
+ if (!data) return;
+ const prevData = data;
+ setData(optimisticAssign(data, employeeId, departmentId));
+
+ const result = await reorderEmployees([{ employeeId, departmentId }]);
+ if (!result.success) {
+ setData(prevData);
+ toast.error(result.error || '직원 이동에 실패했습니다.');
+ }
+ }, [data]);
+
+ /** 직원 미배치 */
+ const handleUnassign = useCallback(async (employeeId: number) => {
+ if (!data) return;
+ const prevData = data;
+ setData(optimisticUnassign(data, employeeId));
+
+ const result = await unassignEmployeeAction(employeeId);
+ if (!result.success) {
+ setData(prevData);
+ toast.error(result.error || '직원 미배치에 실패했습니다.');
+ }
+ }, [data]);
+
+ // ========== 부서 API 핸들러 ==========
+
+ /** 부서 reparent (다른 부서의 하위로 이동) */
+ const handleDeptReparent = useCallback(async (deptId: number, newParentId: number) => {
+ if (!data) return;
+ const prevData = data;
+
+ // 대상 부서의 현재 자식 수 → sort_order 결정 (마지막에 추가)
+ const targetDept = findDepartmentById(data.departments, newParentId);
+ const newSortOrder = targetDept ? targetDept.children.length : 0;
+
+ setData(optimisticReparent(data, deptId, newParentId, newSortOrder));
+
+ const result = await reorderDepartments([
+ { id: deptId, parentId: newParentId, sortOrder: newSortOrder },
+ ]);
+ if (!result.success) {
+ setData(prevData);
+ toast.error(result.error || '부서 이동에 실패했습니다.');
+ }
+ }, [data]);
+
+ /** 부서 순서 위로 (같은 레벨 내) */
+ const handleMoveDeptUp = useCallback(async (deptId: number) => {
+ if (!data) return;
+ const prevData = data;
+ const updated = swapDeptOrder(data, deptId, -1);
+ if (!updated) return;
+
+ setData(updated.data);
+
+ const result = await reorderDepartments(updated.orders);
+ if (!result.success) {
+ setData(prevData);
+ toast.error(result.error || '부서 순서 변경에 실패했습니다.');
+ }
+ }, [data]);
+
+ /** 부서 순서 아래로 (같은 레벨 내) */
+ const handleMoveDeptDown = useCallback(async (deptId: number) => {
+ if (!data) return;
+ const prevData = data;
+ const updated = swapDeptOrder(data, deptId, 1);
+ if (!updated) return;
+
+ setData(updated.data);
+
+ const result = await reorderDepartments(updated.orders);
+ if (!result.success) {
+ setData(prevData);
+ toast.error(result.error || '부서 순서 변경에 실패했습니다.');
+ }
+ }, [data]);
+
+ /** 부서 숨기기 */
+ const handleHideDepartment = useCallback(async (departmentId: number) => {
+ if (!data) return;
+ const prevData = data;
+ setData(optimisticHide(data, departmentId));
+
+ const result = await toggleDepartmentHide(departmentId, true);
+ if (!result.success) {
+ setData(prevData);
+ toast.error(result.error || '부서 숨기기에 실패했습니다.');
+ }
+ }, [data]);
+
+ /** 부서 복원 */
+ const handleRestoreDepartment = useCallback(async (departmentId: number) => {
+ if (!data) return;
+
+ const result = await toggleDepartmentHide(departmentId, false);
+ if (result.success) {
+ await loadData();
+ } else {
+ toast.error(result.error || '부서 복원에 실패했습니다.');
+ }
+ }, [data, loadData]);
+
+ // ========== 렌더링 ==========
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (!data) {
+ return (
+
+
+ 조직도 데이터를 불러올 수 없습니다.
+
+
+ );
+ }
+
+ return (
+
+ } />
+
+
+ {/* 모바일: 미배치 직원 토글 버튼 */}
+
+
+ {showUnassigned && (
+
+
+
+ )}
+
+
+
+ {/* 데스크톱: 미배치 직원 패널 */}
+
+
+
+
+
+
+
+ {/* 드래그 오버레이 */}
+
+ {activeDrag?.type === 'employee' && activeDrag.employee && (
+
+ {activeDrag.employee.positionLabel && (
+
+ {activeDrag.employee.positionLabel}
+
+ )}
+ {activeDrag.employee.displayName}
+
+ )}
+ {activeDrag?.type === 'department' && activeDrag.department && (
+
+ {activeDrag.department.name}
+
+ )}
+
+
+
+
+
+ {/* 부서 선택 모달 (배치 버튼 클릭 시) */}
+
+
+ );
+}
+
+// ============================================
+// Optimistic Update 헬퍼
+// ============================================
+
+/** 직원을 부서에 배치 */
+function optimisticAssign(data: OrgChartData, employeeId: number, targetDeptId: number): OrgChartData {
+ let employee: OrgEmployee | undefined;
+
+ const unassignedIdx = data.unassigned.findIndex((e) => e.id === employeeId);
+ if (unassignedIdx !== -1) {
+ employee = data.unassigned[unassignedIdx];
+ }
+ if (!employee) {
+ employee = findEmployeeInTree(data.departments, employeeId);
+ }
+ if (!employee) return data;
+
+ const newUnassigned = data.unassigned.filter((e) => e.id !== employeeId);
+ const newDepartments = removeEmployeeFromTree(data.departments, employeeId);
+ const finalDepartments = addEmployeeToTree(newDepartments, targetDeptId, {
+ ...employee,
+ departmentId: targetDeptId,
+ });
+
+ const assignedDelta = unassignedIdx !== -1 ? 1 : 0;
+
+ return {
+ ...data,
+ unassigned: newUnassigned,
+ departments: finalDepartments,
+ stats: {
+ ...data.stats,
+ assigned: data.stats.assigned + assignedDelta,
+ unassigned: data.stats.unassigned - assignedDelta,
+ },
+ };
+}
+
+/** 직원을 미배치로 이동 */
+function optimisticUnassign(data: OrgChartData, employeeId: number): OrgChartData {
+ const employee = findEmployeeInTree(data.departments, employeeId);
+ if (!employee) return data;
+
+ const newDepartments = removeEmployeeFromTree(data.departments, employeeId);
+ const newUnassigned = [...data.unassigned, { ...employee, departmentId: null }];
+
+ return {
+ ...data,
+ unassigned: newUnassigned,
+ departments: newDepartments,
+ stats: {
+ ...data.stats,
+ assigned: data.stats.assigned - 1,
+ unassigned: data.stats.unassigned + 1,
+ },
+ };
+}
+
+/** 부서 숨기기 */
+function optimisticHide(data: OrgChartData, departmentId: number): OrgChartData {
+ const dept = findDepartmentById(data.departments, departmentId);
+ if (!dept) return data;
+
+ const newDepartments = setDeptHidden(data.departments, departmentId, true);
+ const newHidden = [
+ ...data.hiddenDepartments,
+ { id: dept.id, name: dept.name, code: dept.code },
+ ];
+
+ return { ...data, departments: newDepartments, hiddenDepartments: newHidden };
+}
+
+/** 부서 reparent (다른 부서의 하위로 이동) */
+function optimisticReparent(
+ data: OrgChartData,
+ deptId: number,
+ newParentId: number,
+ newSortOrder: number,
+): OrgChartData {
+ // 1. 트리에서 대상 부서를 제거
+ const dept = findDepartmentById(data.departments, deptId);
+ if (!dept) return data;
+
+ const withoutDept = removeDeptFromTree(data.departments, deptId);
+
+ // 2. 새 부모 아래에 추가
+ const movedDept: OrgDepartment = { ...dept, parentId: newParentId, sortOrder: newSortOrder };
+ const newDepartments = addDeptToTree(withoutDept, newParentId, movedDept);
+
+ return { ...data, departments: newDepartments };
+}
+
+/** 부서 순서 swap (같은 레벨 내, direction: -1=위, 1=아래) */
+function swapDeptOrder(
+ data: OrgChartData,
+ deptId: number,
+ direction: -1 | 1,
+): { data: OrgChartData; orders: { id: number; parentId: number | null; sortOrder: number }[] } | null {
+ const result = swapInTree(data.departments, deptId, direction);
+ if (!result) return null;
+
+ return {
+ data: { ...data, departments: result.tree },
+ orders: result.orders,
+ };
+}
+
+// ============================================
+// 트리 유틸리티
+// ============================================
+
+function findEmployeeInTree(departments: OrgDepartment[], employeeId: number): OrgEmployee | undefined {
+ for (const dept of departments) {
+ const emp = dept.employees.find((e) => e.id === employeeId);
+ if (emp) return emp;
+ if (dept.children.length > 0) {
+ const found = findEmployeeInTree(dept.children, employeeId);
+ if (found) return found;
+ }
+ }
+ return undefined;
+}
+
+function removeEmployeeFromTree(departments: OrgDepartment[], employeeId: number): OrgDepartment[] {
+ return departments.map((dept) => ({
+ ...dept,
+ employees: dept.employees.filter((e) => e.id !== employeeId),
+ children: removeEmployeeFromTree(dept.children, employeeId),
+ }));
+}
+
+function addEmployeeToTree(departments: OrgDepartment[], deptId: number, employee: OrgEmployee): OrgDepartment[] {
+ return departments.map((dept) => {
+ if (dept.id === deptId) {
+ return { ...dept, employees: [...dept.employees, employee] };
+ }
+ return { ...dept, children: addEmployeeToTree(dept.children, deptId, employee) };
+ });
+}
+
+function setDeptHidden(departments: OrgDepartment[], deptId: number, hidden: boolean): OrgDepartment[] {
+ return departments.map((dept) => {
+ if (dept.id === deptId) return { ...dept, orgchartHidden: hidden };
+ return { ...dept, children: setDeptHidden(dept.children, deptId, hidden) };
+ });
+}
+
+/** 트리에서 부서 제거 (재귀) */
+function removeDeptFromTree(departments: OrgDepartment[], deptId: number): OrgDepartment[] {
+ return departments
+ .filter((d) => d.id !== deptId)
+ .map((d) => ({ ...d, children: removeDeptFromTree(d.children, deptId) }));
+}
+
+/** 트리에서 특정 부서 아래에 자식 추가 (재귀) */
+function addDeptToTree(departments: OrgDepartment[], parentId: number, dept: OrgDepartment): OrgDepartment[] {
+ return departments.map((d) => {
+ if (d.id === parentId) {
+ return { ...d, children: [...d.children, dept] };
+ }
+ return { ...d, children: addDeptToTree(d.children, parentId, dept) };
+ });
+}
+
+/** 같은 레벨에서 부서 순서 swap */
+function swapInTree(
+ departments: OrgDepartment[],
+ deptId: number,
+ direction: -1 | 1,
+): { tree: OrgDepartment[]; orders: { id: number; parentId: number | null; sortOrder: number }[] } | null {
+ // 현재 레벨에서 찾기
+ const idx = departments.findIndex((d) => d.id === deptId);
+ if (idx !== -1) {
+ const swapIdx = idx + direction;
+ if (swapIdx < 0 || swapIdx >= departments.length) return null;
+
+ const newArr = [...departments];
+ [newArr[idx], newArr[swapIdx]] = [newArr[swapIdx], newArr[idx]];
+
+ // sort_order 재할당
+ const reordered = newArr.map((d, i) => ({ ...d, sortOrder: i }));
+ const parentId = departments[idx].parentId;
+ const orders = reordered.map((d, i) => ({ id: d.id, parentId, sortOrder: i }));
+
+ return { tree: reordered, orders };
+ }
+
+ // 하위 레벨에서 찾기
+ for (let i = 0; i < departments.length; i++) {
+ if (departments[i].children.length > 0) {
+ const result = swapInTree(departments[i].children, deptId, direction);
+ if (result) {
+ const newDepts = [...departments];
+ newDepts[i] = { ...newDepts[i], children: result.tree };
+ return { tree: newDepts, orders: result.orders };
+ }
+ }
+ }
+
+ return null;
+}
+
+/** 부서 트리를 flat 리스트로 변환 (depth 포함, hidden 제외) */
+function flattenDepartments(
+ departments: OrgDepartment[],
+ depth = 0,
+): (OrgDepartment & { depth: number })[] {
+ const result: (OrgDepartment & { depth: number })[] = [];
+ for (const dept of departments) {
+ if (!dept.orgchartHidden) {
+ result.push({ ...dept, depth });
+ if (dept.children.length > 0) {
+ result.push(...flattenDepartments(dept.children, depth + 1));
+ }
+ }
+ }
+ return result;
+}
diff --git a/src/components/hr/OrgChart/types.ts b/src/components/hr/OrgChart/types.ts
new file mode 100644
index 00000000..07909d5b
--- /dev/null
+++ b/src/components/hr/OrgChart/types.ts
@@ -0,0 +1,191 @@
+/**
+ * 조직도 관리 타입 정의
+ */
+
+// ============================================
+// API 응답 타입 (snake_case)
+// ============================================
+
+export interface ApiOrgChartResponse {
+ company: { name: string; ceo_name: string };
+ departments: ApiOrgDepartment[];
+ hidden_departments: ApiHiddenDepartment[];
+ unassigned: ApiEmployee[];
+ stats: { total: number; assigned: number; unassigned: number };
+}
+
+export interface ApiOrgDepartment {
+ id: number;
+ name: string;
+ code: string | null;
+ parent_id: number | null;
+ sort_order: number;
+ is_active: boolean;
+ orgchart_hidden: boolean;
+ children: ApiOrgDepartment[];
+ employees: ApiEmployee[];
+}
+
+export interface ApiEmployee {
+ id: number;
+ user_id: number;
+ department_id: number | null;
+ display_name: string;
+ position_label: string | null;
+}
+
+export interface ApiHiddenDepartment {
+ id: number;
+ name: string;
+ code: string | null;
+}
+
+// ============================================
+// 프론트엔드 타입 (camelCase)
+// ============================================
+
+export interface OrgDepartment {
+ id: number;
+ name: string;
+ code: string | null;
+ parentId: number | null;
+ sortOrder: number;
+ isActive: boolean;
+ orgchartHidden: boolean;
+ children: OrgDepartment[];
+ employees: OrgEmployee[];
+}
+
+export interface OrgEmployee {
+ id: number;
+ userId: number;
+ departmentId: number | null;
+ displayName: string;
+ positionLabel: string | null;
+}
+
+export interface HiddenDepartment {
+ id: number;
+ name: string;
+ code: string | null;
+}
+
+export interface OrgChartStats {
+ total: number;
+ assigned: number;
+ unassigned: number;
+}
+
+export interface CompanyInfo {
+ name: string;
+ ceoName: string;
+}
+
+export interface OrgChartData {
+ company: CompanyInfo;
+ departments: OrgDepartment[];
+ hiddenDepartments: HiddenDepartment[];
+ unassigned: OrgEmployee[];
+ stats: OrgChartStats;
+}
+
+// ============================================
+// DnD 타입
+// ============================================
+
+export type DragItemType = 'employee' | 'department';
+
+export interface DragData {
+ type: DragItemType;
+ employee?: OrgEmployee;
+ department?: OrgDepartment;
+ sourceDeptId?: number | null; // null = 미배치 패널
+}
+
+// ============================================
+// 유틸리티 함수
+// ============================================
+
+/** API 부서 → 프론트엔드 부서 변환 (재귀) */
+export function transformDepartment(api: ApiOrgDepartment): OrgDepartment {
+ return {
+ id: api.id,
+ name: api.name,
+ code: api.code,
+ parentId: api.parent_id,
+ sortOrder: api.sort_order,
+ isActive: api.is_active,
+ orgchartHidden: api.orgchart_hidden,
+ children: api.children.map(transformDepartment),
+ employees: api.employees.map(transformEmployee),
+ };
+}
+
+/** API 직원 → 프론트엔드 직원 변환 */
+export function transformEmployee(api: ApiEmployee): OrgEmployee {
+ return {
+ id: api.id,
+ userId: api.user_id,
+ departmentId: api.department_id,
+ displayName: api.display_name,
+ positionLabel: api.position_label,
+ };
+}
+
+/** API 전체 응답 변환 */
+export function transformOrgChartResponse(api: ApiOrgChartResponse): OrgChartData {
+ return {
+ company: {
+ name: api.company.name,
+ ceoName: api.company.ceo_name,
+ },
+ departments: api.departments.map(transformDepartment),
+ hiddenDepartments: api.hidden_departments,
+ unassigned: api.unassigned.map(transformEmployee),
+ stats: api.stats,
+ };
+}
+
+/** 부서 트리에서 특정 ID의 자손인지 확인 (순환 참조 방지) */
+export function isDescendantOf(
+ departments: OrgDepartment[],
+ targetId: number,
+ ancestorId: number,
+): boolean {
+ const find = (nodes: OrgDepartment[]): boolean => {
+ for (const node of nodes) {
+ if (node.id === ancestorId) {
+ return hasDescendant(node, targetId);
+ }
+ if (node.children.length > 0) {
+ const found = find(node.children);
+ if (found) return true;
+ }
+ }
+ return false;
+ };
+ return find(departments);
+}
+
+function hasDescendant(dept: OrgDepartment, targetId: number): boolean {
+ for (const child of dept.children) {
+ if (child.id === targetId) return true;
+ if (hasDescendant(child, targetId)) return true;
+ }
+ return false;
+}
+
+/** 부서 트리에서 ID로 부서 찾기 (재귀) */
+export function findDepartmentById(
+ departments: OrgDepartment[],
+ id: number,
+): OrgDepartment | null {
+ for (const dept of departments) {
+ if (dept.id === id) return dept;
+ if (dept.children.length > 0) {
+ const found = findDepartmentById(dept.children, id);
+ if (found) return found;
+ }
+ }
+ return null;
+}