From 00ac954fa7d934962f2974bffcbbade27bf85156 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Mon, 23 Mar 2026 12:30:21 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[HR]=20=EC=A1=B0=EC=A7=81=EB=8F=84=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../(protected)/hr/org-chart/page.tsx | 23 + src/components/hr/OrgChart/CompanyHeader.tsx | 24 + src/components/hr/OrgChart/DepartmentNode.tsx | 208 ++++++ src/components/hr/OrgChart/EmployeeCard.tsx | 91 +++ .../hr/OrgChart/HiddenDepartmentsPanel.tsx | 47 ++ src/components/hr/OrgChart/OrgChartStats.tsx | 24 + src/components/hr/OrgChart/OrgChartTree.tsx | 67 ++ .../hr/OrgChart/UnassignedPanel.tsx | 83 +++ src/components/hr/OrgChart/actions.ts | 148 +++++ src/components/hr/OrgChart/index.tsx | 590 ++++++++++++++++++ src/components/hr/OrgChart/types.ts | 191 ++++++ 11 files changed, 1496 insertions(+) create mode 100644 src/app/[locale]/(protected)/hr/org-chart/page.tsx create mode 100644 src/components/hr/OrgChart/CompanyHeader.tsx create mode 100644 src/components/hr/OrgChart/DepartmentNode.tsx create mode 100644 src/components/hr/OrgChart/EmployeeCard.tsx create mode 100644 src/components/hr/OrgChart/HiddenDepartmentsPanel.tsx create mode 100644 src/components/hr/OrgChart/OrgChartStats.tsx create mode 100644 src/components/hr/OrgChart/OrgChartTree.tsx create mode 100644 src/components/hr/OrgChart/UnassignedPanel.tsx create mode 100644 src/components/hr/OrgChart/actions.ts create mode 100644 src/components/hr/OrgChart/index.tsx create mode 100644 src/components/hr/OrgChart/types.ts 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) ? ( + + ) : ( + + )} + +
+ + {/* 직원 목록 */} + {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} +
+ )} +
+
+ + + + {/* 부서 선택 모달 (배치 버튼 클릭 시) */} + !open && setAssignTarget(null)}> + + + + {assignTarget?.displayName} 배치할 부서 선택 + + + +
+ {data && flattenDepartments(data.departments).map((dept) => ( + + ))} +
+
+
+
+
+ ); +} + +// ============================================ +// 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; +}