feat: [HR] 조직도 페이지 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
유병철
2026-03-23 12:30:21 +09:00
parent fa2f7be077
commit 00ac954fa7
11 changed files with 1496 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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),
}));
}

View 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>
);
}

View 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: '부서 숨기기 변경에 실패했습니다.',
});
}

View 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;
}

View 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;
}