feat: 단가관리 페이지 마이그레이션 및 HR 관리 기능 추가
## 단가관리 (Pricing Management) - 단가 목록 페이지 (IntegratedListTemplateV2 공통 템플릿 적용) - 단가 등록/수정 폼 (원가/마진 자동 계산) - 이력 조회, 수정 이력, 최종 확정 다이얼로그 - 판매관리 > 단가관리 네비게이션 메뉴 추가 ## HR 관리 (Human Resources) - 사원관리 (목록, 등록, 수정, 상세, CSV 업로드) - 부서관리 (트리 구조) - 근태관리 (기본 구조) ## 품목관리 개선 - Radix UI Select controlled mode 버그 수정 (key prop 적용) - DynamicItemForm 파일 업로드 지원 - 수정 페이지 데이터 로딩 개선 ## 문서화 - 단가관리 마이그레이션 체크리스트 - HR 관리 구현 체크리스트 - Radix UI Select 버그 수정 가이드 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
92
src/components/hr/DepartmentManagement/DepartmentDialog.tsx
Normal file
92
src/components/hr/DepartmentManagement/DepartmentDialog.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import type { DepartmentDialogProps } from './types';
|
||||
|
||||
/**
|
||||
* 부서 추가/수정 다이얼로그
|
||||
*/
|
||||
export function DepartmentDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
mode,
|
||||
parentDepartment,
|
||||
department,
|
||||
onSubmit
|
||||
}: DepartmentDialogProps) {
|
||||
const [name, setName] = useState('');
|
||||
|
||||
// 다이얼로그 열릴 때 초기값 설정
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (mode === 'edit' && department) {
|
||||
setName(department.name);
|
||||
} else {
|
||||
setName('');
|
||||
}
|
||||
}
|
||||
}, [isOpen, mode, department]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name.trim()) {
|
||||
onSubmit(name.trim());
|
||||
setName('');
|
||||
}
|
||||
};
|
||||
|
||||
const title = mode === 'add' ? '부서 추가' : '부서 수정';
|
||||
const submitText = mode === 'add' ? '등록' : '수정';
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 부모 부서 표시 (추가 모드일 때) */}
|
||||
{mode === 'add' && parentDepartment && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
상위 부서: <span className="font-medium">{parentDepartment.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 부서명 입력 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="department-name">부서명</Label>
|
||||
<Input
|
||||
id="department-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="부서명을 입력하세요"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={!name.trim()}>
|
||||
{submitText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
18
src/components/hr/DepartmentManagement/DepartmentStats.tsx
Normal file
18
src/components/hr/DepartmentManagement/DepartmentStats.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import type { DepartmentStatsProps } from './types';
|
||||
|
||||
/**
|
||||
* 전체 부서 카운트 카드
|
||||
*/
|
||||
export function DepartmentStats({ totalCount }: DepartmentStatsProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="text-sm text-muted-foreground">전체 부서</div>
|
||||
<div className="text-3xl font-bold">{totalCount}개</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
53
src/components/hr/DepartmentManagement/DepartmentToolbar.tsx
Normal file
53
src/components/hr/DepartmentManagement/DepartmentToolbar.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Search, Plus, Trash2 } from 'lucide-react';
|
||||
import type { DepartmentToolbarProps } from './types';
|
||||
|
||||
/**
|
||||
* 검색 + 추가/삭제 버튼 툴바
|
||||
*/
|
||||
export function DepartmentToolbar({
|
||||
totalCount,
|
||||
selectedCount,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
onAdd,
|
||||
onDelete
|
||||
}: DepartmentToolbarProps) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
{/* 검색창 */}
|
||||
<div className="relative w-full sm:w-80">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="부서명 검색"
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 선택 카운트 + 버튼 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {totalCount}건 {selectedCount > 0 && `| ${selectedCount}건 선택`}
|
||||
</span>
|
||||
<Button size="sm" onClick={onAdd}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={onDelete}
|
||||
disabled={selectedCount === 0}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
src/components/hr/DepartmentManagement/DepartmentTree.tsx
Normal file
70
src/components/hr/DepartmentManagement/DepartmentTree.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { DepartmentTreeItem } from './DepartmentTreeItem';
|
||||
import type { DepartmentTreeProps } from './types';
|
||||
import { getAllDepartmentIds } from './types';
|
||||
|
||||
/**
|
||||
* 트리 구조 테이블 컨테이너
|
||||
*/
|
||||
export function DepartmentTree({
|
||||
departments,
|
||||
expandedIds,
|
||||
selectedIds,
|
||||
onToggleExpand,
|
||||
onToggleSelect,
|
||||
onToggleSelectAll,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete
|
||||
}: DepartmentTreeProps) {
|
||||
const allIds = getAllDepartmentIds(departments);
|
||||
const isAllSelected = allIds.length > 0 && selectedIds.size === allIds.length;
|
||||
const isIndeterminate = selectedIds.size > 0 && selectedIds.size < allIds.length;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{/* 테이블 헤더 */}
|
||||
<div className="flex items-center px-4 py-3 border-b bg-muted/50">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<Checkbox
|
||||
checked={isIndeterminate ? 'indeterminate' : isAllSelected}
|
||||
onCheckedChange={onToggleSelectAll}
|
||||
aria-label="전체 선택"
|
||||
/>
|
||||
<span className="font-medium text-sm">부서명</span>
|
||||
</div>
|
||||
<div className="w-24 text-right font-medium text-sm">작업</div>
|
||||
</div>
|
||||
|
||||
{/* 트리 아이템 목록 */}
|
||||
<div className="divide-y">
|
||||
{departments.map(department => (
|
||||
<DepartmentTreeItem
|
||||
key={department.id}
|
||||
department={department}
|
||||
depth={0}
|
||||
expandedIds={expandedIds}
|
||||
selectedIds={selectedIds}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onToggleSelect={onToggleSelect}
|
||||
onAdd={onAdd}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 빈 상태 */}
|
||||
{departments.length === 0 && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
등록된 부서가 없습니다
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
117
src/components/hr/DepartmentManagement/DepartmentTreeItem.tsx
Normal file
117
src/components/hr/DepartmentManagement/DepartmentTreeItem.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronRight, ChevronDown, Plus, Pencil, Trash2 } from 'lucide-react';
|
||||
import type { DepartmentTreeItemProps } from './types';
|
||||
|
||||
/**
|
||||
* 트리 행 (재귀 렌더링)
|
||||
* - 무제한 깊이 지원
|
||||
* - depth에 따른 동적 들여쓰기
|
||||
*/
|
||||
export function DepartmentTreeItem({
|
||||
department,
|
||||
depth,
|
||||
expandedIds,
|
||||
selectedIds,
|
||||
onToggleExpand,
|
||||
onToggleSelect,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete
|
||||
}: DepartmentTreeItemProps) {
|
||||
const hasChildren = department.children && department.children.length > 0;
|
||||
const isExpanded = expandedIds.has(department.id);
|
||||
const isSelected = selectedIds.has(department.id);
|
||||
|
||||
// 들여쓰기 계산 (depth * 24px)
|
||||
const paddingLeft = depth * 24;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 현재 행 */}
|
||||
<div
|
||||
className="group flex items-center px-4 py-3 hover:bg-muted/50 transition-colors"
|
||||
style={{ paddingLeft: `${paddingLeft + 16}px` }}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{/* 펼침/접힘 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 w-6 p-0 ${!hasChildren ? 'invisible' : ''}`}
|
||||
onClick={() => onToggleExpand(department.id)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* 체크박스 */}
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleSelect(department.id)}
|
||||
aria-label={`${department.name} 선택`}
|
||||
/>
|
||||
|
||||
{/* 부서명 */}
|
||||
<span className="truncate">{department.name}</span>
|
||||
</div>
|
||||
|
||||
{/* 작업 버튼 (호버 시 표시) */}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => onAdd(department.id)}
|
||||
title="하위 부서 추가"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => onEdit(department)}
|
||||
title="수정"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
|
||||
onClick={() => onDelete(department)}
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하위 부서 (재귀) */}
|
||||
{hasChildren && isExpanded && (
|
||||
<>
|
||||
{department.children!.map(child => (
|
||||
<DepartmentTreeItem
|
||||
key={child.id}
|
||||
department={child}
|
||||
depth={depth + 1}
|
||||
expandedIds={expandedIds}
|
||||
selectedIds={selectedIds}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onToggleSelect={onToggleSelect}
|
||||
onAdd={onAdd}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
352
src/components/hr/DepartmentManagement/index.tsx
Normal file
352
src/components/hr/DepartmentManagement/index.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { Building2 } from 'lucide-react';
|
||||
import { DepartmentStats } from './DepartmentStats';
|
||||
import { DepartmentToolbar } from './DepartmentToolbar';
|
||||
import { DepartmentTree } from './DepartmentTree';
|
||||
import { DepartmentDialog } from './DepartmentDialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import type { Department } from './types';
|
||||
import { countAllDepartments, getAllDepartmentIds, findDepartmentById } from './types';
|
||||
|
||||
/**
|
||||
* 무제한 깊이 트리 구조 목업 데이터
|
||||
*/
|
||||
const mockDepartments: Department[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: '회사명',
|
||||
parentId: null,
|
||||
depth: 0,
|
||||
children: [
|
||||
{
|
||||
id: 2,
|
||||
name: '경영지원본부',
|
||||
parentId: 1,
|
||||
depth: 1,
|
||||
children: [
|
||||
{
|
||||
id: 4,
|
||||
name: '인사팀',
|
||||
parentId: 2,
|
||||
depth: 2,
|
||||
children: [
|
||||
{
|
||||
id: 7,
|
||||
name: '채용파트',
|
||||
parentId: 4,
|
||||
depth: 3,
|
||||
children: [
|
||||
{ id: 10, name: '신입채용셀', parentId: 7, depth: 4, children: [] },
|
||||
{ id: 11, name: '경력채용셀', parentId: 7, depth: 4, children: [] },
|
||||
]
|
||||
},
|
||||
{ id: 8, name: '교육파트', parentId: 4, depth: 3, children: [] },
|
||||
]
|
||||
},
|
||||
{ id: 5, name: '총무팀', parentId: 2, depth: 2, children: [] },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '개발본부',
|
||||
parentId: 1,
|
||||
depth: 1,
|
||||
children: [
|
||||
{ id: 6, name: '프론트엔드팀', parentId: 3, depth: 2, children: [] },
|
||||
{ id: 9, name: '백엔드팀', parentId: 3, depth: 2, children: [] },
|
||||
]
|
||||
},
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export function DepartmentManagement() {
|
||||
// 부서 데이터 상태
|
||||
const [departments, setDepartments] = useState<Department[]>(mockDepartments);
|
||||
|
||||
// 선택 상태
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// 펼침 상태 (기본: 최상위만 펼침)
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set([1]));
|
||||
|
||||
// 검색어
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [dialogMode, setDialogMode] = useState<'add' | 'edit'>('add');
|
||||
const [selectedDepartment, setSelectedDepartment] = useState<Department | undefined>();
|
||||
const [parentDepartment, setParentDepartment] = useState<Department | undefined>();
|
||||
|
||||
// 삭제 확인 다이얼로그
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [departmentToDelete, setDepartmentToDelete] = useState<Department | null>(null);
|
||||
const [isBulkDelete, setIsBulkDelete] = useState(false);
|
||||
|
||||
// 전체 부서 수 계산
|
||||
const totalCount = useMemo(() => countAllDepartments(departments), [departments]);
|
||||
|
||||
// 모든 부서 ID
|
||||
const allIds = useMemo(() => getAllDepartmentIds(departments), [departments]);
|
||||
|
||||
// 펼침/접힘 토글
|
||||
const handleToggleExpand = (id: number) => {
|
||||
setExpandedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 선택 토글
|
||||
const handleToggleSelect = (id: number) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 전체 선택/해제
|
||||
const handleToggleSelectAll = () => {
|
||||
if (selectedIds.size === allIds.length) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(allIds));
|
||||
}
|
||||
};
|
||||
|
||||
// 부서 추가 (행 버튼)
|
||||
const handleAdd = (parentId: number) => {
|
||||
const parent = findDepartmentById(departments, parentId);
|
||||
setParentDepartment(parent || undefined);
|
||||
setSelectedDepartment(undefined);
|
||||
setDialogMode('add');
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
// 부서 추가 (상단 버튼 - 선택된 부서의 하위에 일괄 추가)
|
||||
const handleBulkAdd = () => {
|
||||
if (selectedIds.size === 0) {
|
||||
// 선택된 부서가 없으면 최상위에 추가
|
||||
setParentDepartment(undefined);
|
||||
} else {
|
||||
// 선택된 첫 번째 부서를 부모로 설정
|
||||
const firstSelectedId = Array.from(selectedIds)[0];
|
||||
const parent = findDepartmentById(departments, firstSelectedId);
|
||||
setParentDepartment(parent || undefined);
|
||||
}
|
||||
setSelectedDepartment(undefined);
|
||||
setDialogMode('add');
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
// 부서 수정
|
||||
const handleEdit = (department: Department) => {
|
||||
setSelectedDepartment(department);
|
||||
setParentDepartment(undefined);
|
||||
setDialogMode('edit');
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
// 부서 삭제 (단일)
|
||||
const handleDelete = (department: Department) => {
|
||||
setDepartmentToDelete(department);
|
||||
setIsBulkDelete(false);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 부서 삭제 (일괄)
|
||||
const handleBulkDelete = () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
setDepartmentToDelete(null);
|
||||
setIsBulkDelete(true);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 확인
|
||||
const confirmDelete = () => {
|
||||
if (isBulkDelete) {
|
||||
// 일괄 삭제 로직
|
||||
setDepartments(prev => deleteDepartmentsRecursive(prev, selectedIds));
|
||||
setSelectedIds(new Set());
|
||||
} else if (departmentToDelete) {
|
||||
// 단일 삭제 로직
|
||||
setDepartments(prev => deleteDepartmentsRecursive(prev, new Set([departmentToDelete.id])));
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(departmentToDelete.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
setDeleteDialogOpen(false);
|
||||
setDepartmentToDelete(null);
|
||||
};
|
||||
|
||||
// 재귀적으로 부서 삭제
|
||||
const deleteDepartmentsRecursive = (depts: Department[], idsToDelete: Set<number>): Department[] => {
|
||||
return depts
|
||||
.filter(dept => !idsToDelete.has(dept.id))
|
||||
.map(dept => ({
|
||||
...dept,
|
||||
children: dept.children ? deleteDepartmentsRecursive(dept.children, idsToDelete) : []
|
||||
}));
|
||||
};
|
||||
|
||||
// 부서 추가/수정 제출
|
||||
const handleDialogSubmit = (name: string) => {
|
||||
if (dialogMode === 'add') {
|
||||
// 새 부서 추가
|
||||
const newId = Math.max(...allIds, 0) + 1;
|
||||
const newDept: Department = {
|
||||
id: newId,
|
||||
name,
|
||||
parentId: parentDepartment?.id || null,
|
||||
depth: parentDepartment ? parentDepartment.depth + 1 : 0,
|
||||
children: []
|
||||
};
|
||||
|
||||
if (parentDepartment) {
|
||||
// 부모 부서의 children에 추가
|
||||
setDepartments(prev => addChildDepartment(prev, parentDepartment.id, newDept));
|
||||
} else {
|
||||
// 최상위에 추가
|
||||
setDepartments(prev => [...prev, newDept]);
|
||||
}
|
||||
} else if (dialogMode === 'edit' && selectedDepartment) {
|
||||
// 부서 수정
|
||||
setDepartments(prev => updateDepartmentName(prev, selectedDepartment.id, name));
|
||||
}
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
// 재귀적으로 자식 부서 추가
|
||||
const addChildDepartment = (depts: Department[], parentId: number, newDept: Department): Department[] => {
|
||||
return depts.map(dept => {
|
||||
if (dept.id === parentId) {
|
||||
return {
|
||||
...dept,
|
||||
children: [...(dept.children || []), newDept]
|
||||
};
|
||||
}
|
||||
if (dept.children) {
|
||||
return {
|
||||
...dept,
|
||||
children: addChildDepartment(dept.children, parentId, newDept)
|
||||
};
|
||||
}
|
||||
return dept;
|
||||
});
|
||||
};
|
||||
|
||||
// 재귀적으로 부서명 업데이트
|
||||
const updateDepartmentName = (depts: Department[], id: number, name: string): Department[] => {
|
||||
return depts.map(dept => {
|
||||
if (dept.id === id) {
|
||||
return { ...dept, name };
|
||||
}
|
||||
if (dept.children) {
|
||||
return {
|
||||
...dept,
|
||||
children: updateDepartmentName(dept.children, id, name)
|
||||
};
|
||||
}
|
||||
return dept;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="부서관리"
|
||||
description="부서 정보를 관리합니다"
|
||||
icon={Building2}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 전체 부서 카운트 */}
|
||||
<DepartmentStats totalCount={totalCount} />
|
||||
|
||||
{/* 검색 + 추가/삭제 버튼 */}
|
||||
<DepartmentToolbar
|
||||
totalCount={totalCount}
|
||||
selectedCount={selectedIds.size}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onAdd={handleBulkAdd}
|
||||
onDelete={handleBulkDelete}
|
||||
/>
|
||||
|
||||
{/* 트리 테이블 */}
|
||||
<DepartmentTree
|
||||
departments={departments}
|
||||
expandedIds={expandedIds}
|
||||
selectedIds={selectedIds}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
onAdd={handleAdd}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 추가/수정 다이얼로그 */}
|
||||
<DepartmentDialog
|
||||
isOpen={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
mode={dialogMode}
|
||||
parentDepartment={parentDepartment}
|
||||
department={selectedDepartment}
|
||||
onSubmit={handleDialogSubmit}
|
||||
/>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>부서 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{isBulkDelete
|
||||
? `선택한 부서 ${selectedIds.size}개를 삭제하시겠습니까?`
|
||||
: `"${departmentToDelete?.name}" 부서를 삭제하시겠습니까?`
|
||||
}
|
||||
<br />
|
||||
<span className="text-destructive">
|
||||
삭제된 부서의 인원은 회사(기본) 인원으로 변경됩니다.
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
109
src/components/hr/DepartmentManagement/types.ts
Normal file
109
src/components/hr/DepartmentManagement/types.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 부서관리 타입 정의
|
||||
* @description 무제한 깊이 트리 구조 지원
|
||||
*/
|
||||
|
||||
/**
|
||||
* 부서 데이터 (무제한 깊이 재귀 구조)
|
||||
*/
|
||||
export interface Department {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number | null;
|
||||
depth: number; // 깊이 (0: 최상위, 1, 2, 3, ... 무제한)
|
||||
children?: Department[]; // 하위 부서 (재귀 - 무제한 깊이)
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 추가/수정 다이얼로그 Props
|
||||
*/
|
||||
export interface DepartmentDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
mode: 'add' | 'edit';
|
||||
parentDepartment?: Department; // 추가 시 부모 부서
|
||||
department?: Department; // 수정 시 대상 부서
|
||||
onSubmit: (name: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 트리 아이템 Props (재귀 렌더링)
|
||||
*/
|
||||
export interface DepartmentTreeItemProps {
|
||||
department: Department;
|
||||
depth: number;
|
||||
expandedIds: Set<number>;
|
||||
selectedIds: Set<number>;
|
||||
onToggleExpand: (id: number) => void;
|
||||
onToggleSelect: (id: number) => void;
|
||||
onAdd: (parentId: number) => void;
|
||||
onEdit: (department: Department) => void;
|
||||
onDelete: (department: Department) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 통계 Props
|
||||
*/
|
||||
export interface DepartmentStatsProps {
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 툴바 Props
|
||||
*/
|
||||
export interface DepartmentToolbarProps {
|
||||
totalCount: number;
|
||||
selectedCount: number;
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
onAdd: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 트리 컨테이너 Props
|
||||
*/
|
||||
export interface DepartmentTreeProps {
|
||||
departments: Department[];
|
||||
expandedIds: Set<number>;
|
||||
selectedIds: Set<number>;
|
||||
onToggleExpand: (id: number) => void;
|
||||
onToggleSelect: (id: number) => void;
|
||||
onToggleSelectAll: () => void;
|
||||
onAdd: (parentId: number) => void;
|
||||
onEdit: (department: Department) => void;
|
||||
onDelete: (department: Department) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 부서 수 계산 유틸리티 (재귀)
|
||||
*/
|
||||
export const countAllDepartments = (departments: Department[]): number => {
|
||||
return departments.reduce((count, dept) => {
|
||||
return count + 1 + (dept.children ? countAllDepartments(dept.children) : 0);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* 모든 부서 ID 추출 유틸리티 (재귀 - 전체 선택용)
|
||||
*/
|
||||
export const getAllDepartmentIds = (departments: Department[]): number[] => {
|
||||
return departments.flatMap(dept => [
|
||||
dept.id,
|
||||
...(dept.children ? getAllDepartmentIds(dept.children) : [])
|
||||
]);
|
||||
};
|
||||
|
||||
/**
|
||||
* ID로 부서 찾기 (재귀)
|
||||
*/
|
||||
export const findDepartmentById = (departments: Department[], id: number): Department | null => {
|
||||
for (const dept of departments) {
|
||||
if (dept.id === id) return dept;
|
||||
if (dept.children) {
|
||||
const found = findDepartmentById(dept.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
Reference in New Issue
Block a user