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:
byeongcheolryu
2025-12-06 11:36:38 +09:00
parent 751e65f59b
commit 48dbba0e5f
59 changed files with 9888 additions and 101 deletions

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

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

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

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

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

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

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