feat: [HR] 조직도 페이지 추가
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
23
src/app/[locale]/(protected)/hr/org-chart/page.tsx
Normal file
23
src/app/[locale]/(protected)/hr/org-chart/page.tsx
Normal file
@@ -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 (
|
||||
<Suspense fallback={<ListPageSkeleton showHeader={false} />}>
|
||||
<OrgChartManagement />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
24
src/components/hr/OrgChart/CompanyHeader.tsx
Normal file
24
src/components/hr/OrgChart/CompanyHeader.tsx
Normal file
@@ -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 (
|
||||
<div className="flex items-center gap-2 md:gap-3 p-3 md:p-4 bg-violet-50 border border-violet-200 rounded-lg mb-3 md:mb-4">
|
||||
<div className="flex items-center justify-center w-8 h-8 md:w-10 md:h-10 bg-violet-600 rounded-lg text-white shrink-0">
|
||||
<Building2 className="h-4 w-4 md:h-5 md:w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-bold text-violet-900 text-sm md:text-base truncate">{company.name}</div>
|
||||
{company.ceoName && (
|
||||
<div className="text-xs md:text-sm text-violet-600 truncate">CEO: {company.ceoName}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
208
src/components/hr/OrgChart/DepartmentNode.tsx
Normal file
208
src/components/hr/OrgChart/DepartmentNode.tsx
Normal file
@@ -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 (
|
||||
<div className={cn('relative', isDragging && 'opacity-40')}>
|
||||
{depth > 0 && (
|
||||
<div
|
||||
className="absolute top-0 left-0 w-px bg-gray-300 hidden md:block"
|
||||
style={{ left: `${depth * 24 - 12}px`, height: '24px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ marginLeft: `${depth * 12}px` }} className="mb-1.5 md:mb-2">
|
||||
<div
|
||||
ref={setDropRef}
|
||||
className={cn(
|
||||
'rounded-lg border transition-all overflow-hidden',
|
||||
colors.bg, colors.border,
|
||||
isOver && 'ring-2 ring-blue-400 ring-offset-1 bg-blue-50',
|
||||
)}
|
||||
onDoubleClick={() => setShowHideButton((prev) => !prev)}
|
||||
>
|
||||
{/* 부서 헤더 */}
|
||||
<div className="flex items-center gap-1 md:gap-2 px-2 md:px-3 py-1.5 md:py-2 min-w-0">
|
||||
{/* 드래그 핸들 — 데스크톱만 */}
|
||||
<span
|
||||
ref={setDragRef}
|
||||
{...dragListeners}
|
||||
{...dragAttrs}
|
||||
className="hidden md:inline cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground shrink-0"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</span>
|
||||
|
||||
{/* 접기/펼치기 */}
|
||||
{(hasChildren || hasEmployees) ? (
|
||||
<button
|
||||
className="shrink-0 p-0.5"
|
||||
onClick={() => setIsExpanded((v) => !v)}
|
||||
>
|
||||
{isExpanded
|
||||
? <ChevronDown className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
: <ChevronRight className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-4 md:w-5 shrink-0" />
|
||||
)}
|
||||
|
||||
<DepthIcon className={cn('h-3.5 w-3.5 shrink-0 hidden md:inline', colors.icon)} />
|
||||
|
||||
<span className={cn('font-semibold text-xs md:text-sm truncate', colors.text)}>
|
||||
{department.name}
|
||||
</span>
|
||||
|
||||
{department.code && (
|
||||
<Badge variant="outline" className="text-[10px] md:text-xs font-mono rounded-sm hidden sm:inline-flex">
|
||||
{department.code}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{hasEmployees && (
|
||||
<span className="text-[10px] md:text-xs text-muted-foreground shrink-0">
|
||||
{department.employees.length}명
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 순서 변경 + 숨기기 */}
|
||||
<div className="flex items-center gap-0 ml-auto shrink-0">
|
||||
{canMoveUp && (
|
||||
<button
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => { e.stopPropagation(); onMoveDeptUp(department.id); }}
|
||||
>
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{canMoveDown && (
|
||||
<button
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => { e.stopPropagation(); onMoveDeptDown(department.id); }}
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{showHideButton && (
|
||||
<button
|
||||
className="ml-1 px-1.5 py-0.5 text-[10px] md:text-xs bg-destructive text-destructive-foreground rounded"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onHideDepartment(department.id);
|
||||
setShowHideButton(false);
|
||||
}}
|
||||
>
|
||||
숨기기
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 직원 목록 */}
|
||||
{isExpanded && hasEmployees && (
|
||||
<div className="px-2 md:px-3 pb-1.5 md:pb-2 space-y-1">
|
||||
{department.employees.map((emp) => (
|
||||
<EmployeeCard
|
||||
key={emp.id}
|
||||
employee={emp}
|
||||
sourceDeptId={department.id}
|
||||
showUnassignButton
|
||||
onUnassign={onUnassignEmployee}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하위 부서 (재귀) */}
|
||||
{isExpanded && hasChildren && (
|
||||
<div className="mt-1">
|
||||
{department.children.map((child, idx) => (
|
||||
<DepartmentNode
|
||||
key={child.id}
|
||||
department={child}
|
||||
depth={depth + 1}
|
||||
siblingCount={department.children.length}
|
||||
siblingIndex={idx}
|
||||
onUnassignEmployee={onUnassignEmployee}
|
||||
onAssignEmployee={onAssignEmployee}
|
||||
onHideDepartment={onHideDepartment}
|
||||
onMoveDeptUp={onMoveDeptUp}
|
||||
onMoveDeptDown={onMoveDeptDown}
|
||||
allDepartments={allDepartments}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
src/components/hr/OrgChart/EmployeeCard.tsx
Normal file
91
src/components/hr/OrgChart/EmployeeCard.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
'flex items-center gap-1 md:gap-2 px-1.5 md:px-2 py-1 md:py-1.5 rounded-md border bg-white text-xs md:text-sm',
|
||||
'hover:bg-gray-50 transition-colors',
|
||||
isDragging && 'opacity-50 shadow-lg z-50',
|
||||
)}
|
||||
>
|
||||
{/* 드래그 핸들 — 데스크톱만 */}
|
||||
<span
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className="hidden md:inline cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground shrink-0"
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
|
||||
<span className="truncate flex-1 min-w-0">
|
||||
{employee.positionLabel && (
|
||||
<span className="text-muted-foreground mr-1">{employee.positionLabel}</span>
|
||||
)}
|
||||
<span className="font-medium">{employee.displayName}</span>
|
||||
</span>
|
||||
|
||||
{/* 미배치 버튼 */}
|
||||
{showUnassignButton && onUnassign && (
|
||||
<div className="relative group/unassign shrink-0">
|
||||
<button
|
||||
onClick={() => onUnassign(employee.id)}
|
||||
className="text-orange-400 hover:text-orange-600"
|
||||
>
|
||||
<UserX className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span className="absolute bottom-full right-0 mb-1 px-2 py-1 text-xs text-white bg-gray-800 rounded whitespace-nowrap opacity-0 group-hover/unassign:opacity-100 transition-opacity duration-100 pointer-events-none hidden md:block">
|
||||
미배치 직원으로 변경
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 배치 버튼 — 클릭 시 부서 선택 모달 */}
|
||||
{showAssignButton && onAssignClick && (
|
||||
<button
|
||||
onClick={() => onAssignClick(employee)}
|
||||
className="text-blue-500 hover:text-blue-700 shrink-0"
|
||||
title="부서 배치"
|
||||
>
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
src/components/hr/OrgChart/HiddenDepartmentsPanel.tsx
Normal file
47
src/components/hr/OrgChart/HiddenDepartmentsPanel.tsx
Normal file
@@ -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 (
|
||||
<div className="border rounded-lg bg-white p-3 mt-4">
|
||||
<div className="text-sm font-semibold text-muted-foreground mb-2">
|
||||
숨겨진 부서 ({departments.length})
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{departments.map((dept) => (
|
||||
<div
|
||||
key={dept.id}
|
||||
className="flex items-center gap-1.5 px-2 py-1 bg-gray-50 border rounded-md"
|
||||
>
|
||||
<span className="text-sm">{dept.name}</span>
|
||||
{dept.code && (
|
||||
<Badge variant="outline" className="text-xs font-mono rounded-sm">
|
||||
{dept.code}
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-blue-600 hover:text-blue-700 hover:bg-blue-50"
|
||||
onClick={() => onRestore(dept.id)}
|
||||
title="복원"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
src/components/hr/OrgChart/OrgChartStats.tsx
Normal file
24
src/components/hr/OrgChart/OrgChartStats.tsx
Normal file
@@ -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 (
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs md:text-sm">
|
||||
<Badge variant="outline" className="px-1.5 md:px-2.5">
|
||||
전체 <span className="font-bold ml-1">{stats.total}</span>
|
||||
</Badge>
|
||||
<Badge variant="outline" className="px-1.5 md:px-2.5 text-blue-600 border-blue-200 bg-blue-50">
|
||||
배치 <span className="font-bold ml-1">{stats.assigned}</span>
|
||||
</Badge>
|
||||
<Badge variant="outline" className="px-1.5 md:px-2.5 text-orange-600 border-orange-200 bg-orange-50">
|
||||
미배치 <span className="font-bold ml-1">{stats.unassigned}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
src/components/hr/OrgChart/OrgChartTree.tsx
Normal file
67
src/components/hr/OrgChart/OrgChartTree.tsx
Normal file
@@ -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 (
|
||||
<div className="flex-1 min-w-0 border rounded-lg bg-white">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4">
|
||||
<CompanyHeader company={company} />
|
||||
|
||||
{visibleDepartments.length > 0 ? (
|
||||
visibleDepartments.map((dept, idx) => (
|
||||
<DepartmentNode
|
||||
key={dept.id}
|
||||
department={dept}
|
||||
depth={0}
|
||||
siblingCount={visibleDepartments.length}
|
||||
siblingIndex={idx}
|
||||
onUnassignEmployee={onUnassignEmployee}
|
||||
onHideDepartment={onHideDepartment}
|
||||
onMoveDeptUp={onMoveDeptUp}
|
||||
onMoveDeptDown={onMoveDeptDown}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center text-sm text-muted-foreground py-8">
|
||||
표시할 부서가 없습니다
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 숨겨진 부서를 트리에서 재귀적으로 제거 */
|
||||
function filterHiddenDepartments(departments: OrgDepartment[]): OrgDepartment[] {
|
||||
return departments
|
||||
.filter((dept) => !dept.orgchartHidden)
|
||||
.map((dept) => ({
|
||||
...dept,
|
||||
children: filterHiddenDepartments(dept.children),
|
||||
}));
|
||||
}
|
||||
83
src/components/hr/OrgChart/UnassignedPanel.tsx
Normal file
83
src/components/hr/OrgChart/UnassignedPanel.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
'w-full md:w-72 shrink-0 flex flex-col border rounded-lg bg-white transition-colors h-full',
|
||||
isOver && 'bg-orange-50 ring-2 ring-orange-300',
|
||||
)}
|
||||
>
|
||||
<div className="p-2 md:p-3 border-b">
|
||||
<div className="flex items-center gap-2 mb-1.5 md:mb-2">
|
||||
<UserX className="h-4 w-4 text-orange-500 shrink-0" />
|
||||
<span className="font-semibold text-xs md:text-sm">미배치 직원</span>
|
||||
<span className="text-[10px] md:text-xs text-muted-foreground ml-auto">{employees.length}명</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="이름, 직급 검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-7 md:h-8 pl-7 md:pl-8 text-xs md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-1.5 md:p-2 space-y-1">
|
||||
{filtered.length > 0 ? (
|
||||
filtered.map((emp) => (
|
||||
<EmployeeCard
|
||||
key={emp.id}
|
||||
employee={emp}
|
||||
sourceDeptId={null}
|
||||
showAssignButton
|
||||
onAssignClick={onAssignClick}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center text-xs md:text-sm text-muted-foreground py-6 md:py-8">
|
||||
{searchQuery ? '검색 결과가 없습니다' : '미배치 직원이 없습니다'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
src/components/hr/OrgChart/actions.ts
Normal file
148
src/components/hr/OrgChart/actions.ts
Normal file
@@ -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<ActionResult<OrgChartData>> {
|
||||
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<ActionResult<OrgChartStats>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/org-chart/stats'),
|
||||
errorMessage: '통계 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 미배치 직원 목록
|
||||
* GET /api/v1/org-chart/unassigned
|
||||
*/
|
||||
export async function getUnassignedEmployees(q?: string): Promise<ActionResult<OrgEmployee[]>> {
|
||||
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<ActionResult> {
|
||||
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<ActionResult> {
|
||||
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<ActionResult> {
|
||||
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<ActionResult> {
|
||||
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<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/org-chart/departments/${departmentId}/toggle-hide`),
|
||||
method: 'PATCH',
|
||||
body: { hidden },
|
||||
errorMessage: '부서 숨기기 변경에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
590
src/components/hr/OrgChart/index.tsx
Normal file
590
src/components/hr/OrgChart/index.tsx
Normal file
@@ -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<OrgChartData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [activeDrag, setActiveDrag] = useState<DragData | null>(null);
|
||||
const [showUnassigned, setShowUnassigned] = useState(false);
|
||||
const [assignTarget, setAssignTarget] = useState<OrgEmployee | null>(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 (
|
||||
<PageLayout>
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex items-center justify-center h-96 text-muted-foreground">
|
||||
조직도 데이터를 불러올 수 없습니다.
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader title="조직도 관리" actions={<OrgChartStats stats={data.stats} />} />
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{/* 모바일: 미배치 직원 토글 버튼 */}
|
||||
<div className="md:hidden mb-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-between"
|
||||
onClick={() => setShowUnassigned((v) => !v)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<UserX className="h-4 w-4 text-orange-500" />
|
||||
미배치 직원 ({data.unassigned.length}명)
|
||||
</span>
|
||||
{showUnassigned
|
||||
? <ChevronUp className="h-4 w-4" />
|
||||
: <ChevronDown className="h-4 w-4" />
|
||||
}
|
||||
</Button>
|
||||
{showUnassigned && (
|
||||
<div className="mt-2 h-64">
|
||||
<UnassignedPanel employees={data.unassigned} onAssignClick={setAssignTarget} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-4 h-auto md:h-[calc(100vh-220px)]">
|
||||
{/* 데스크톱: 미배치 직원 패널 */}
|
||||
<div className="hidden md:block">
|
||||
<UnassignedPanel employees={data.unassigned} onAssignClick={setAssignTarget} />
|
||||
</div>
|
||||
|
||||
<OrgChartTree
|
||||
company={data.company}
|
||||
departments={data.departments}
|
||||
onUnassignEmployee={handleUnassign}
|
||||
onHideDepartment={handleHideDepartment}
|
||||
onMoveDeptUp={handleMoveDeptUp}
|
||||
onMoveDeptDown={handleMoveDeptDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 드래그 오버레이 */}
|
||||
<DragOverlay>
|
||||
{activeDrag?.type === 'employee' && activeDrag.employee && (
|
||||
<div className="px-3 py-1.5 bg-white border shadow-lg rounded-md text-sm font-medium">
|
||||
{activeDrag.employee.positionLabel && (
|
||||
<span className="text-muted-foreground mr-1">
|
||||
{activeDrag.employee.positionLabel}
|
||||
</span>
|
||||
)}
|
||||
{activeDrag.employee.displayName}
|
||||
</div>
|
||||
)}
|
||||
{activeDrag?.type === 'department' && activeDrag.department && (
|
||||
<div className="px-3 py-1.5 bg-violet-100 border border-violet-300 shadow-lg rounded-md text-sm font-semibold text-violet-700">
|
||||
{activeDrag.department.name}
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
<HiddenDepartmentsPanel
|
||||
departments={data.hiddenDepartments}
|
||||
onRestore={handleRestoreDepartment}
|
||||
/>
|
||||
|
||||
{/* 부서 선택 모달 (배치 버튼 클릭 시) */}
|
||||
<Dialog open={!!assignTarget} onOpenChange={(open) => !open && setAssignTarget(null)}>
|
||||
<DialogContent className="max-w-xs">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">
|
||||
{assignTarget?.displayName} 배치할 부서 선택
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-64">
|
||||
<div className="space-y-0.5">
|
||||
{data && flattenDepartments(data.departments).map((dept) => (
|
||||
<button
|
||||
key={dept.id}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 rounded truncate"
|
||||
style={{ paddingLeft: `${dept.depth * 12 + 12}px` }}
|
||||
onClick={async () => {
|
||||
if (assignTarget) {
|
||||
await handleAssign(assignTarget.id, dept.id);
|
||||
setAssignTarget(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{dept.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 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;
|
||||
}
|
||||
191
src/components/hr/OrgChart/types.ts
Normal file
191
src/components/hr/OrgChart/types.ts
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user