diff --git a/claudedocs/_index.md b/claudedocs/_index.md index ea33d3e9..13f4abc1 100644 --- a/claudedocs/_index.md +++ b/claudedocs/_index.md @@ -1,6 +1,6 @@ # claudedocs 문서 맵 -> 프로젝트 기술 문서 인덱스 (Last Updated: 2025-12-04) +> 프로젝트 기술 문서 인덱스 (Last Updated: 2025-12-05) ## 폴더 구조 @@ -8,6 +8,7 @@ claudedocs/ ├── _index.md # 이 파일 - 문서 맵 ├── auth/ # 🔐 인증 & 토큰 관리 +├── hr/ # 👥 인사관리 (부서/사원) ├── item-master/ # 📦 품목기준관리 ├── sales/ # 💰 판매관리 (견적/거래처) ├── dashboard/ # 📊 대시보드 & 사이드바 @@ -37,6 +38,15 @@ claudedocs/ --- +## 👥 hr/ - 인사관리 (부서/사원) + +| 파일 | 설명 | +|------|------| +| `[IMPL-2025-12-05] department-management-checklist.md` | ✅ **완료** - 부서관리 구현 체크리스트 (무제한 트리구조) | +| `[IMPL-2025-12-05] employee-management-checklist.md` | 🔄 **진행중** - 사원관리 구현 체크리스트 | + +--- + ## 📦 item-master/ - 품목기준관리 | 파일 | 설명 | @@ -62,10 +72,11 @@ claudedocs/ --- -## 💰 sales/ - 판매관리 (견적/거래처) +## 💰 sales/ - 판매관리 (견적/거래처/단가) | 파일 | 설명 | |------|------| +| `[IMPL-2025-12-05] pricing-management-migration.md` | 🔄 **진행중** - 단가관리 마이그레이션 계획 (7 Phase, 체크리스트, 원가/마진 계산 로직) | | `[API-2025-12-04] quote-api-request.md` | ⭐ **NEW** - 견적관리 API 요청서 (데이터 모델, 엔드포인트, 수식 계산) | | `[PLAN-2025-12-04] quote-management-implementation.md` | 📋 **NEW** - 견적관리 작업계획서 (6 Phase, 체크리스트) | | `[IMPL-2025-12-04] client-management-api-integration.md` | ✅ **완료** - 거래처관리 API 연동 체크리스트 (CRUD, 그룹 훅) | @@ -101,6 +112,7 @@ claudedocs/ | 파일 | 설명 | |------|------| +| `[FIX-2025-12-05] radix-ui-select-controlled-mode-bug.md` | ⭐ **NEW** - Radix UI Select 버그 해결 (Edit 모드 값 표시 안됨 → key prop 강제 리마운트) | | `i18n-usage-guide.md` | 다국어 사용 가이드 | | `form-validation-guide.md` | 폼 유효성 검사 | | `CSS-MIGRATION-WORKFLOW.md` | CSS 마이그레이션 워크플로우 | @@ -145,6 +157,7 @@ claudedocs/ - `DESIGN` - 설계 문서 - `TEST` - 테스트 가이드 - `NEXT` - 다음 작업 목록 +- `FIX` - 버그 해결 문서 ### 폴더 배치 기준 1. **기능/도메인 우선**: 문서 주제에 맞는 폴더에 배치 diff --git a/claudedocs/guides/[FIX-2025-12-05] radix-ui-select-controlled-mode-bug.md b/claudedocs/guides/[FIX-2025-12-05] radix-ui-select-controlled-mode-bug.md new file mode 100644 index 00000000..2e907915 --- /dev/null +++ b/claudedocs/guides/[FIX-2025-12-05] radix-ui-select-controlled-mode-bug.md @@ -0,0 +1,139 @@ +# Radix UI Select Controlled Mode 버그 해결 + +**작성일**: 2025-12-05 +**문제 유형**: UI 컴포넌트 버그 +**적용 범위**: 모든 Dropdown/Select 필드 + +--- + +## 문제 현상 + +Edit 모드에서 Select/Dropdown 컴포넌트에 기존 값이 표시되지 않음 + +**증상:** +- API에서 데이터 정상 수신 (예: `unit: 'M'`) +- formData에 값 정상 매핑 (예: `98_unit: 'M'`) +- resetForm 정상 호출 +- DropdownField에 value prop 정상 전달 (`rawValue: 'M'`) +- **하지만 UI에 선택된 값이 표시되지 않음!** + +--- + +## 원인 + +**Radix UI Select의 Controlled Mode 버그** + +Select 컴포넌트가 빈 값(`''`)으로 처음 마운트되면, 이후 value prop이 변경되어도 내부 상태가 업데이트되지 않는 문제 + +**문제 코드:** +```tsx + +``` + +--- + +## 적용 파일 + +`src/components/items/DynamicItemForm/fields/DropdownField.tsx` + +**수정 전:** +```tsx + +``` + +--- + +## 적용 범위 + +이 수정은 `DropdownField` 컴포넌트를 사용하는 **모든 품목 유형**에 자동 적용됨: + +- ✅ 소모품 (CS) +- ✅ 원자재 (RM) +- ✅ 부자재 (SM) +- ✅ 부품 (PT) +- ✅ 제품 (FG) + +--- + +## 디버깅 가이드 + +Edit 모드에서 Select 값이 안 나올 때 체크리스트: + +### 1. API 데이터 확인 +``` +[EditItem] ========== API 원본 데이터 ========== +unit: M ← 값 있음 ✅ +specification: null ← 백엔드에서 안 줌 ❌ +``` + +### 2. 필드 매핑 확인 +``` +[DynamicItemForm] fieldKeyMap: {unit: '98_unit', ...} +[DynamicItemForm] Mapped initialData: {98_unit: 'M', ...} +``` + +### 3. resetForm 확인 +``` +[useDynamicFormState] resetForm 호출됨: {98_unit: 'M', ...} +``` + +### 4. DropdownField 확인 +``` +[DropdownField] 단위 필드 디버깅: + rawValue: 'M' ← 값 있음 ✅ + valueInOptions: true ← 옵션에 존재 ✅ +``` + +**모든 값이 정상인데 UI에 안 나오면 → key prop 문제!** + +--- + +## 관련 이슈 + +### 추가 발견 사항 + +API 응답에서 일부 필드가 누락될 수 있음: +- `specification: null` → DB에 값 없거나 API 응답에서 제외 +- `note: undefined` → API 응답에 필드 자체가 없음 +- `remarks: null` → DB에 값 없음 + +이런 경우 **백엔드 API 수정 요청 필요** + +--- + +## 참고 + +- Radix UI Select 공식 문서에서는 이 동작을 버그로 인정하지 않음 +- React의 controlled component 패턴에서 초기값 변경 시 발생하는 일반적인 문제 +- `key` prop 패턴은 React 공식 권장 해결책 diff --git a/claudedocs/hr/[IMPL-2025-12-05] department-management-checklist.md b/claudedocs/hr/[IMPL-2025-12-05] department-management-checklist.md new file mode 100644 index 00000000..4cd912fb --- /dev/null +++ b/claudedocs/hr/[IMPL-2025-12-05] department-management-checklist.md @@ -0,0 +1,319 @@ +# 부서관리 화면 구현 체크리스트 + +> **생성일**: 2025-12-05 +> **상태**: 구현 완료 (API 연동 대기) +> **경로**: `인사관리 > 부서관리` +> **테스트 URL**: `http://localhost:3000/ko/hr/department-management` + +--- + +## 0. 핵심 요구사항 + +### 트리 구조 - 무제한 깊이 + +``` +회사명 +├── 부서A +│ ├── 팀A-1 +│ │ ├── 파트A-1-1 +│ │ │ └── ... (무제한) +│ │ └── 파트A-1-2 +│ └── 팀A-2 +├── 부서B +│ └── 팀B-1 +└── 부서C +``` + +- **깊이 제한 없음**: 부서 > 하위부서 > 하위부서 > ... 무한 재귀 가능 +- **재귀적 데이터 구조**: `children` 배열로 무한 중첩 표현 +- **동적 들여쓰기**: depth에 따라 padding-left 계산 (`depth * 24px` 등) +- **+/- 토글**: 하위 항목이 있는 모든 노드에 펼침/접힘 버튼 표시 + +--- + +## 1. 화면 분석 요약 + +### 1.1 스크린샷 기반 기능 정리 + +| 번호 | 구분 | 기능 | 설명 | +|------|------|------|------| +| 01 | 전체 선택 | 체크박스 | 전체 선택/해제 토글, 디폴트 해제 | +| 02 | 개별 선택 | 체크박스 | 개별 선택/해제 토글, 디폴트 해제 | +| 03 | 추가 버튼 | 상단 액션 | 관리 권한 없으면 숨김, 선택한 부서의 하위 부서 일괄 생성 | +| 04 | 삭제 버튼 | 상단 액션 | 관리 권한 없으면 숨김, "선택한 부서 N개를 삭제하시겠습니까?" Alert | +| 05 | 축소 버튼 | 트리 (-) | 클릭 시 확대 버튼으로 변경, 하위 부서 숨김 | +| 06 | 확대 버튼 | 트리 (+) | 클릭 시 축소 버튼으로 변경, 하위 부서 표시 | +| 07 | 추가 버튼 | 행 호버 | 관리 권한 없으면 숨김, 부서 추가 팝업 표시 | +| 08 | 수정 버튼 | 행 호버 | 관리 권한 없으면 숨김, 부서 수정 팝업 표시 | +| 09 | 삭제 버튼 | 행 호버 | 관리 권한 없으면 숨김, "{부서명} 부서를 삭제하시겠습니까?" Alert | + +### 1.2 팝업 구성 + +| 팝업 | 구성요소 | 비고 | +|------|----------|------| +| 부서 추가 | 부서명 인풋박스 + 취소/등록 버튼 | 기존 부서명 표시, 수정 가능 | +| 부서 수정 | 부서명 인풋박스 + 취소/수정 버튼 | 기존 부서명 표시 | + +### 1.3 삭제 로직 + +- 삭제 시 해당 부서의 인원은 **회사(기본) 인원으로 변경**됨 + +--- + +## 2. 컴포넌트 구조 + +``` +src/ +├── app/[locale]/(protected)/hr/ +│ └── department-management/ +│ └── page.tsx # 페이지 진입점 +│ +└── components/hr/ + └── DepartmentManagement/ + ├── index.tsx # 메인 컴포넌트 + ├── DepartmentStats.tsx # 전체 부서 카운트 카드 + ├── DepartmentToolbar.tsx # 검색 + 추가/삭제 버튼 + ├── DepartmentTree.tsx # 트리 구조 테이블 + ├── DepartmentTreeItem.tsx # 트리 행 (재귀) + ├── DepartmentDialog.tsx # 추가/수정 팝업 + └── types.ts # 타입 정의 +``` + +--- + +## 3. 타입 정의 + +```typescript +// types.ts + +/** + * 부서 데이터 (무제한 깊이 재귀 구조) + * - depth: 렌더링 시 들여쓰기 계산용 (0부터 시작, 제한 없음) + * - children: 하위 부서 배열 (재귀적으로 무한 중첩 가능) + */ +interface Department { + id: number; + name: string; + parentId: number | null; + depth: number; // 깊이 (0: 최상위, 1, 2, 3, ... 무제한) + children?: Department[]; // 하위 부서 (재귀 - 무제한 깊이) +} + +interface DepartmentDialogProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + mode: 'add' | 'edit'; + parentDepartment?: Department; // 추가 시 부모 부서 + department?: Department; // 수정 시 대상 부서 + onSubmit: (name: string) => void; +} + +/** + * 트리 아이템 Props (재귀 렌더링) + */ +interface DepartmentTreeItemProps { + department: Department; + depth: number; // 현재 깊이 (들여쓰기 계산용) + isExpanded: boolean; // 펼침 상태 + isSelected: boolean; // 선택 상태 + onToggleExpand: (id: number) => void; + onToggleSelect: (id: number) => void; + onAdd: (parentId: number) => void; + onEdit: (department: Department) => void; + onDelete: (department: Department) => void; + expandedIds: Set; // 전체 펼침 상태 (하위 재귀용) + selectedIds: Set; // 전체 선택 상태 (하위 재귀용) +} +``` + +--- + +## 4. 구현 체크리스트 + +### Phase 1: 기본 구조 설정 +- [x] `types.ts` - Department 타입 및 Props 인터페이스 정의 +- [x] `page.tsx` - 라우트 페이지 생성 (`/hr/department-management`) +- [x] `index.tsx` - DepartmentManagement 메인 컴포넌트 (PageLayout + PageHeader) + +### Phase 2: 상단 영역 구현 +- [x] `DepartmentStats.tsx` - 전체 부서 카운트 카드 ("전체 부서 N개") +- [x] `DepartmentToolbar.tsx` - 검색창 구현 +- [x] `DepartmentToolbar.tsx` - 선택 카운트 표시 ("총 N건 | N건 선택") +- [x] `DepartmentToolbar.tsx` - 추가 버튼 (선택한 부서 하위에 일괄 추가) +- [x] `DepartmentToolbar.tsx` - 삭제 버튼 (선택한 부서 일괄 삭제) + +### Phase 3: 트리 테이블 구현 +- [x] `DepartmentTree.tsx` - 테이블 헤더 (전체 선택 체크박스 + 부서명 + 작업) +- [x] `DepartmentTreeItem.tsx` - 트리 행 기본 구조 (체크박스 + 들여쓰기 + 부서명) +- [x] `DepartmentTreeItem.tsx` - +/- 버튼 (하위 부서 펼침/접힘) +- [x] `DepartmentTreeItem.tsx` - 호버 시 작업 버튼 표시 (추가, 수정, 삭제) +- [x] `DepartmentTreeItem.tsx` - 재귀 렌더링 (하위 부서 표시) + +### Phase 4: 다이얼로그 구현 +- [x] `DepartmentDialog.tsx` - 부서 추가 팝업 (부서명 입력 + 취소/등록) +- [x] `DepartmentDialog.tsx` - 부서 수정 팝업 (부서명 입력 + 취소/수정) +- [x] AlertDialog - 단일 삭제 확인 ("{부서명} 부서를 삭제하시겠습니까?") +- [x] AlertDialog - 일괄 삭제 확인 ("선택한 부서 N개를 삭제하시겠습니까?") + +### Phase 5: 상태 관리 & 인터랙션 +- [x] 체크박스 선택 상태 관리 (selectedIds: Set) +- [x] 전체 선택/해제 로직 구현 +- [x] 트리 펼침/접힘 상태 관리 (expandedIds: Set) +- [ ] 검색 필터링 로직 구현 (TODO: 추후 구현) +- [x] 목업 데이터 생성 및 연동 + +### Phase 6: 스타일 및 마무리 +- [x] 기존 프로젝트 디자인 패턴 적용 확인 +- [x] 반응형 레이아웃 확인 (모바일/데스크톱) +- [x] 호버 애니메이션 및 트랜지션 확인 +- [ ] 접근성 확인 (키보드 네비게이션) (TODO: 추후 보완) + +--- + +## 5. 디자인 기준 (기존 프로젝트 패턴) + +### 5.1 레이아웃 +- **페이지 패딩**: `p-4 md:p-6` +- **섹션 간격**: `space-y-4 md:space-y-6` +- **카드**: `border rounded-lg p-4` + +### 5.2 컴포넌트 +| 요소 | 컴포넌트 | 비고 | +|------|----------|------| +| 페이지 래퍼 | `PageLayout` | maxWidth="full" | +| 헤더 | `PageHeader` | icon + title + description | +| 카드 | `Card` | 전체 부서 카운트 | +| 입력창 | `Input` | 검색, 부서명 | +| 버튼 | `Button` | variant: default, outline, ghost, destructive | +| 체크박스 | `Checkbox` | 전체/개별 선택 | +| 팝업 | `Dialog` | 추가/수정 | +| 확인창 | `AlertDialog` | 삭제 확인 | + +### 5.3 아이콘 (lucide-react) +- `Building2` - 페이지 아이콘 +- `Search` - 검색 +- `Plus` - 추가 +- `Trash2` - 삭제 +- `Pencil` - 수정 +- `ChevronRight` / `ChevronDown` - 트리 펼침/접힘 +- `Minus` - 트리 접힘 버튼 + +### 5.4 인터랙션 +- **행 호버**: `hover:bg-gray-50 transition-colors` +- **작업 버튼**: 호버 시에만 표시 (`opacity-0 group-hover:opacity-100`) + +--- + +## 6. 목업 데이터 + +```typescript +/** + * 무제한 깊이 트리 구조 목업 데이터 + * - depth 0: 회사 + * - depth 1: 부서 + * - depth 2: 팀 + * - depth 3+: 파트, 셀, ... (무제한) + */ +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: [] }, + ] + }, + ] + } +]; + +/** + * 전체 부서 수 계산 유틸리티 (재귀) + */ +const countAllDepartments = (departments: Department[]): number => { + return departments.reduce((count, dept) => { + return count + 1 + (dept.children ? countAllDepartments(dept.children) : 0); + }, 0); +}; + +/** + * 모든 부서 ID 추출 유틸리티 (재귀 - 전체 선택용) + */ +const getAllDepartmentIds = (departments: Department[]): number[] => { + return departments.flatMap(dept => [ + dept.id, + ...(dept.children ? getAllDepartmentIds(dept.children) : []) + ]); +}; +``` + +--- + +## 7. 참고 파일 + +| 파일 | 참고 내용 | +|------|-----------| +| `src/components/items/ItemMasterDataManagement.tsx` | 전체 구조 패턴 | +| `src/components/organisms/PageHeader.tsx` | 헤더 컴포넌트 | +| `src/components/organisms/PageLayout.tsx` | 레이아웃 컴포넌트 | +| `src/components/ui/dialog.tsx` | 다이얼로그 사용법 | +| `src/components/ui/alert-dialog.tsx` | 확인 다이얼로그 | +| `src/components/ui/checkbox.tsx` | 체크박스 | + +--- + +## 8. 진행 로그 + +| 날짜 | 작업 내용 | 상태 | +|------|-----------|------| +| 2025-12-05 | 계획서 작성 | 완료 | +| 2025-12-05 | Phase 1: 기본 구조 (types, page, index) | 완료 | +| 2025-12-05 | Phase 2: 상단 영역 (Stats, Toolbar) | 완료 | +| 2025-12-05 | Phase 3: 트리 테이블 (Tree, TreeItem) | 완료 | +| 2025-12-05 | Phase 4: 다이얼로그 (Dialog, AlertDialog) | 완료 | +| 2025-12-05 | Phase 5: 상태 관리 (선택, 펼침, 추가/수정/삭제) | 완료 | +| 2025-12-05 | 빌드 테스트 | 성공 | + +--- + +## 9. 이슈 및 메모 + +- API 연동 없이 화면만 먼저 구현 (목업 데이터 사용) +- 권한 체크 로직은 추후 API 연동 시 구현 예정 +- 삭제 시 인원 이동 로직은 백엔드 API 의존 \ No newline at end of file diff --git a/claudedocs/hr/[IMPL-2025-12-05] employee-management-checklist.md b/claudedocs/hr/[IMPL-2025-12-05] employee-management-checklist.md new file mode 100644 index 00000000..4852d61e --- /dev/null +++ b/claudedocs/hr/[IMPL-2025-12-05] employee-management-checklist.md @@ -0,0 +1,143 @@ +# 사원관리 (Employee Management) 구현 체크리스트 + +## 개요 +- **위치**: 인사관리 > 사원관리 +- **경로**: `/[locale]/(protected)/hr/employee-management` +- **생성일**: 2025-12-05 +- **상태**: 🔄 진행중 + +## 화면 구성 분석 (스크린샷 기반) + +### 1. 메인 목록 화면 +- 통계 카드: 재직(55명), 휴직(5명), 퇴직(1명), 평균근속년수(5.3년) +- 날짜 필터 (기간 선택) +- 액션 버튼: CSV 일괄등록, 사원등록, 사용자 초대 +- 검색 및 필터 (전체/사용자 아이디 보유 등) +- 정렬 옵션 +- 테이블 컬럼: No, 사원코드, 부서, 직책, 이름, 직급, 휴대폰, 이메일, 입사일, 상태, 사용자아이디, 권한, 작업 + +### 2. 사원 등록/수정 다이얼로그 +- **기본 사원정보**: 이름*, 주민등록번호, 휴대폰, 이메일, 연봉, 급여계좌(은행, 계좌, 예금주) +- **선택적 필드** (설정에 따라 표시): 프로필사진, 사원코드, 성별, 주소(우편번호 찾기) +- **인사정보**: 입사일, 고용형태, 직급, 상태, 부서/직책 (복수 가능) +- **사용자 정보**: 아이디*, 비밀번호*, 권한, 상태 + +### 3. 필드 설정 팝업 +- 사원 상세 필드 ON/OFF: 프로필 사진, 사원코드, 성별, 주소 +- 인사 정보 필드 ON/OFF: 입사일, 고용 형태, 직급, 상태, 부서, 직책 + +### 4. CSV 일괄등록 +- 테이블 형태로 데이터 미리보기 표시 +- 유효성 검사 결과 표시 + +--- + +## 구현 체크리스트 + +### Phase 1: 기본 구조 설정 +- [x] 1.1 types.ts 생성 - Employee, EmployeeFormData 인터페이스 정의 +- [x] 1.2 page.tsx 생성 - 라우트 페이지 (/hr/employee-management) +- [x] 1.3 index.tsx 생성 - 메인 컴포넌트 (상태 관리) +- [x] 1.4 mock 데이터 생성 - 테스트용 사원 데이터 (61명: 55 재직, 5 휴직, 1 퇴직) + +### Phase 2: 통계 및 목록 화면 +- [x] 2.1 EmployeeStats - 통계 카드 (공통 StatCards 활용) +- [x] 2.2 EmployeeToolbar.tsx - 날짜필터, 액션 버튼 영역 +- [x] 2.3 메인 목록 구현 - 공통 DataTable 활용 +- [x] 2.4 테이블 컬럼 정의 - 모든 필수 컬럼 구현 + +### Phase 3: 사원 등록/수정 다이얼로그 +- [x] 3.1 EmployeeDialog.tsx - 다이얼로그 컨테이너 (create/edit/view 모드 통합) +- [x] 3.2 기본 사원정보 폼 섹션 (EmployeeDialog 내 통합) +- [x] 3.3 선택적 필드 섹션 - 프로필, 사원코드, 성별, 주소 (필드설정 연동) +- [x] 3.4 인사정보 섹션 - 부서/직책 복수 추가/삭제 기능 구현 +- [x] 3.5 사용자 정보 섹션 - Switch로 계정 생성 토글 + +### Phase 4: 필드 설정 기능 +- [x] 4.1 FieldSettingsDialog.tsx - 필드 ON/OFF 설정 다이얼로그 +- [x] 4.2 필드 설정 상태 관리 (useState 기반) +- [x] 4.3 설정에 따른 폼 필드 동적 렌더링 + +### Phase 5: CSV 일괄등록 +- [x] 5.1 CSVUploadDialog.tsx - CSV 업로드 다이얼로그 +- [x] 5.2 CSV 파싱 유틸리티 (내장) +- [x] 5.3 미리보기 테이블 구현 +- [x] 5.4 유효성 검사 및 에러 표시 + +### Phase 6: 추가 기능 +- [x] 6.1 사용자 초대 기능 - UI 버튼 준비 (실제 로직은 API 연동 시) +- [x] 6.2 삭제 확인 AlertDialog +- [x] 6.3 필터/검색 기능 완성 (DataTable 내장) +- [x] 6.4 정렬 기능 완성 (DataTable 내장) + +### Phase 7: 테스트 및 검증 +- [x] 7.1 빌드 테스트 ✅ 성공 +- [ ] 7.2 UI 렌더링 확인 (사용자 테스트 필요) +- [ ] 7.3 반응형 디자인 확인 (모바일 카드뷰) + +--- + +## 공통 컴포넌트 활용 계획 + +| 컴포넌트 | 용도 | +|----------|------| +| DataTable | 사원 목록 테이블 | +| StatCards | 통계 카드 (재직, 휴직, 퇴직, 평균근속년수) | +| SearchFilter | 검색 및 필터 | +| Pagination | 페이지네이션 | +| TabFilter | 상태별 탭 필터 | +| Dialog | 등록/수정 다이얼로그 | +| AlertDialog | 삭제 확인 | +| Switch | 필드 설정 ON/OFF | +| Select | 드롭다운 (은행, 권한 등) | +| Input | 텍스트 입력 필드 | +| Button | 모든 버튼 | +| Badge | 상태 표시 (재직/휴직/퇴직) | + +--- + +## 참고사항 + +### 외부 이메일 로직 (스크린샷 7) +- 사용자 초대 시 외부 이메일 발송 +- 현재는 UI만 구현, 실제 이메일 발송은 API 연동 시 처리 + +### 부서/직책 복수 선택 +- 한 사원이 여러 부서에 소속 가능 +- 부서별 직책 개별 설정 +- 추가/삭제 버튼으로 관리 + +### 필드 설정 저장 +- 필드 표시 설정은 세션 또는 localStorage에 저장 +- 사용자별로 다른 설정 유지 가능 + +--- + +## 진행 상황 업데이트 + +| 날짜 | 완료 항목 | 메모 | +|------|-----------|------| +| 2025-12-05 | 체크리스트 작성 | 스크린샷 분석 완료 | +| 2025-12-05 | Phase 1-6 구현 완료 | types.ts, page.tsx, index.tsx, EmployeeToolbar, EmployeeDialog, FieldSettingsDialog, CSVUploadDialog | +| 2025-12-05 | 빌드 테스트 성공 | 17.1 kB First Load JS | + +## 생성된 파일 목록 + +``` +src/components/hr/EmployeeManagement/ +├── types.ts # 타입 정의 (Employee, EmployeeFormData, FieldSettings 등) +├── index.tsx # 메인 컴포넌트 (상태관리, DataTable 활용) +├── EmployeeToolbar.tsx # 툴바 (날짜필터, 액션버튼) +├── EmployeeDialog.tsx # 등록/수정/상세 다이얼로그 +├── FieldSettingsDialog.tsx # 필드 설정 다이얼로그 +└── CSVUploadDialog.tsx # CSV 일괄등록 다이얼로그 + +src/app/[locale]/(protected)/hr/employee-management/ +└── page.tsx # 라우트 페이지 +``` + +## 접속 URL + +- 한국어: `http://localhost:3000/ko/hr/employee-management` +- 영어: `http://localhost:3000/en/hr/employee-management` +- 일본어: `http://localhost:3000/ja/hr/employee-management` \ No newline at end of file diff --git a/claudedocs/item-master/[IMPL-2025-12-05] item-file-upload-checklist.md b/claudedocs/item-master/[IMPL-2025-12-05] item-file-upload-checklist.md new file mode 100644 index 00000000..d85ff4e1 --- /dev/null +++ b/claudedocs/item-master/[IMPL-2025-12-05] item-file-upload-checklist.md @@ -0,0 +1,162 @@ +# 품목 파일 업로드 기능 구현 체크리스트 + +**생성일**: 2025-12-05 +**상태**: 🔄 진행중 + +--- + +## 📋 개요 + +품목 등록/수정 시 파일 업로드 기능이 누락되어 있음. 백엔드 API는 구현되어 있으나 프론트엔드에서 호출하지 않음. + +### 대상 파일 타입 + +| 타입 | API type 값 | 대상 품목 | 허용 확장자 | 최대 크기 | +|------|------------|----------|------------|----------| +| 절곡/조립 전개도 | `bending_diagram` | PT (BENDING, ASSEMBLY) | jpg,jpeg,png,gif,bmp,svg,webp | 10MB | +| 시방서 | `specification` | FG | pdf,doc,docx,xls,xlsx,hwp | 20MB | +| 인정서 | `certification` | FG | pdf,doc,docx,xls,xlsx,hwp | 20MB | + +### 백엔드 API + +- **업로드**: `POST /api/v1/items/{id}/files` (multipart/form-data) +- **삭제**: `DELETE /api/v1/items/{id}/files/{type}` + +### DB 컬럼 (Product 모델) + +``` +bending_diagram, bending_details +specification_file, specification_file_name +certification_file, certification_file_name +certification_number, certification_start_date, certification_end_date +``` + +--- + +## Phase 1: 프록시 API 수정 + +- [x] **1.1** `/api/proxy/[...path]/route.ts` multipart/form-data 지원 추가 ✅ + - `executeBackendRequest` 함수에 `isFormData` 파라미터 추가 + - multipart/form-data일 때 FormData 그대로 전달 + - Content-Type 헤더 생략하여 boundary 자동 설정 + +--- + +## Phase 2: 파일 업로드 API 함수 생성 + +- [x] **2.1** `src/lib/api/items.ts`에 파일 업로드/삭제 함수 추가 ✅ + - `uploadItemFile(itemId, file, type, options?)` - 프록시 경유, ID 기반 + - `deleteItemFile(itemId, type)` - 프록시 경유, ID 기반 + - `UploadFileOptions` 인터페이스: certification 필드, bendingDetails 지원 + - 레거시 함수 deprecated 처리 + +--- + +## Phase 3: DynamicItemForm 수정 (등록 모드) + +- [x] **3.1** `handleFormSubmit` 수정 - 품목 생성 후 파일 업로드 로직 추가 ✅ + - 품목 생성 → ID 반환 → 파일 업로드 + - `types.ts`에 `ItemSaveResult` 인터페이스 추가 + - `create/page.tsx`에서 ID 반환하도록 수정 +- [x] **3.2** PT (절곡/조립): `bendingDiagramFile` 업로드 연동 ✅ + - `uploadItemFile(itemId, bendingDiagramFile, 'bending_diagram')` 호출 + - bendingDetails 옵션 지원 +- [x] **3.3** FG (제품): `specificationFile`, `certificationFile` 업로드 연동 ✅ + - 시방서: `uploadItemFile(itemId, specificationFile, 'specification')` + - 인정서: `uploadItemFile(itemId, certificationFile, 'certification', { certificationNumber, ... })` +- [x] **3.4** 에러 처리: 품목은 생성됐는데 파일 업로드 실패 시 처리 ✅ + - 파일 업로드 실패 시 경고 alert 표시 + - 품목 저장은 완료되므로 리다이렉트 진행 + +--- + +## Phase 4: DynamicItemForm 수정 (수정 모드) + +- [x] **4.1** 기존 파일 정보 로드 및 표시 ✅ + - `edit/page.tsx` `mapApiResponseToFormData`에 파일 필드 추가 + - `bending_diagram`, `specification_file`, `certification_file` 등 매핑 +- [x] **4.2** 파일 업로드 (새 파일로 교체) ✅ + - `edit/page.tsx`에서 `itemId` prop 전달 + - 동일한 파일 업로드 로직 적용 (등록/수정 공용) +- [ ] **4.3** 파일 삭제 기능 추가 (DELETE API 호출) - Phase 5로 이동 +- [x] **4.4** edit/page.tsx에서 파일 관련 initialData 매핑 ✅ + +--- + +## Phase 5: 기존 파일 표시 및 UI 개선 + +### 5-A. 상세 페이지 파일 표시 + +- [x] **5.1** `/items/[id]/page.tsx` - `mapApiResponseToItemMaster`에 파일 필드 추가 ✅ + - `bendingDiagram` (전개도 이미지 URL) + - `specificationFile`, `specificationFileName` (시방서) + - `certificationFile`, `certificationFileName` (인정서) + - `certificationNumber`, `certificationStartDate`, `certificationEndDate` (인정 정보) + +- [x] **5.2** `ItemDetailClient.tsx` - 파일 표시 UI 추가 ✅ + - PT(절곡/조립): 전개도 이미지 표시 (`` 태그) + 절곡 상세정보 테이블 + - FG(제품): 시방서/인정서 파일명 표시 (Badge + 다운로드 링크) + - FG(제품): 인정 정보 표시 (인정번호, 유효기간) + +### 5-B. 수정 페이지 기존 파일 표시 + +- [x] **5.3** `DynamicItemForm/index.tsx` - 기존 파일 표시 UI 추가 ✅ + - FG(제품): 기존 시방서/인정서 파일명 표시 + - initialData에서 파일 정보 로드 (useEffect) + - `getStorageUrl` 헬퍼 함수로 URL 변환 + +- [x] **5.4** 파일 삭제 버튼 추가 ✅ + - `deleteItemFile` API 호출 + - 삭제 확인 다이얼로그 (confirm) + - 삭제 중 로딩 상태 표시 + +### 5-C. 추가 개선 (선택) + +- [ ] **5.5** 업로드 진행 상태 표시 (로딩 스피너) +- [ ] **5.6** 파일 크기/확장자 validation (프론트엔드) +- [x] **5.7** 파일 다운로드 링크 추가 ✅ (상세/수정 페이지 모두) + +--- + +## Phase 6: 테스트 + +- [ ] **6.1** 등록 모드: PT 절곡품 이미지 업로드 테스트 +- [ ] **6.2** 등록 모드: PT 조립품 이미지 업로드 테스트 +- [ ] **6.3** 등록 모드: FG 시방서/인정서 업로드 테스트 +- [ ] **6.4** 수정 모드: 기존 파일 표시 테스트 +- [ ] **6.5** 수정 모드: 파일 교체 테스트 +- [ ] **6.6** 수정 모드: 파일 삭제 테스트 + +--- + +## 참고 파일 + +### 프론트엔드 +- `/src/app/api/proxy/[...path]/route.ts` - 프록시 API +- `/src/lib/api/items.ts` - API 함수 +- `/src/components/items/DynamicItemForm/index.tsx` - 메인 폼 +- `/src/components/items/ItemForm/BendingDiagramSection.tsx` - 전개도 섹션 +- `/src/app/[locale]/(protected)/items/create/page.tsx` - 등록 페이지 +- `/src/app/[locale]/(protected)/items/[id]/edit/page.tsx` - 수정 페이지 + +### 백엔드 +- `/app/Http/Controllers/Api/V1/ItemsFileController.php` - 파일 업로드 컨트롤러 +- `/app/Http/Requests/ItemsFileUploadRequest.php` - 요청 검증 +- `/app/Models/Products/Product.php` - Product 모델 + +--- + +## 작업 로그 + +| 날짜 | 작업 내용 | 완료 | +|------|----------|:----:| +| 2025-12-05 | 체크리스트 생성, 현황 분석 완료 | ✅ | +| 2025-12-05 | Phase 1: 프록시 API multipart/form-data 지원 추가 | ✅ | +| 2025-12-05 | Phase 2: 파일 업로드/삭제 API 함수 생성 | ✅ | +| 2025-12-05 | Phase 3: 등록 모드 파일 업로드 연동 완료 | ✅ | +| 2025-12-05 | Phase 4: 수정 모드 파일 업로드 연동 완료 | ✅ | +| 2025-12-05 | 빌드 테스트 통과 | ✅ | +| 2025-12-05 | Phase 5: 기존 파일 표시 UI 구현 완료 | ✅ | +| | - 상세 페이지: 전개도 이미지, 시방서/인정서 표시 | | +| | - 수정 페이지: 기존 파일 미리보기 + 삭제 기능 | | +| | | | diff --git a/claudedocs/sales/[IMPL-2025-12-05] pricing-management-migration.md b/claudedocs/sales/[IMPL-2025-12-05] pricing-management-migration.md new file mode 100644 index 00000000..503fb178 --- /dev/null +++ b/claudedocs/sales/[IMPL-2025-12-05] pricing-management-migration.md @@ -0,0 +1,328 @@ +# [IMPL-2025-12-05] 단가관리 페이지 마이그레이션 계획서 + +## 개요 +- **소스**: sam-design 프로젝트 (`PricingManagement*.tsx`, `PricingWrite.tsx`) +- **대상**: sam-react-prod (Next.js App Router) +- **경로**: `/sales/pricing-management` +- **기존 공통 테이블**: `@/components/common/DataTable/DataTable.tsx` 사용 + +--- + +## 스크린샷 분석 결과 + +### 1. 단가 목록 페이지 +- 통계 카드 4개 (전체 품목, 단가 등록, 미등록, 확정) +- 검색창 (품목 코드, 품목명, 규격 검색) +- 탭 필터 (전체/제품/부품/부자재/원자재/소모품/절곡물) +- 데이터 테이블 컬럼: 번호, 품목유형, 품목코드, 품목명, 규격, 단위, 매입단가, 가공비, 판매단가, 마진율, 적용일, 상태, 작업 + +### 2. 단가 등록 페이지 (미등록 → + 버튼) +- 품목 정보 섹션 (읽기전용): 품목코드, 품목명, 품목유형, 단위, 규격 +- 단가 정보 입력: + - 적용일 (필수) + - 공급업체, 입고일, 작성자 + - 입고가 (필수), 단위, LOSS(%), 가공비 + - 원가 계산 섹션 (입고가 + 가공비 + LOSS = LOSS 적용 원가) + - 반올림 규칙, 반올림 단위 + - 마진율(%), 판매단가 (필수) + - 마진 계산 섹션 (LOSS 적용 원가 vs 판매단가 = 마진) + - 비고 + +### 3. 단가 수정 페이지 (활성 상태) +- 등록 페이지와 동일 구조 +- 상태 배지 표시 (활성/확정/수정차수) +- 추가 버튼: 이력 조회, 최종 확정, 저장 +- 다이얼로그: 이력 조회, 수정 이력 생성, 최종 확정 + +--- + +## 파일 구조 + +``` +src/ +├── app/[locale]/(protected)/sales/pricing-management/ +│ ├── page.tsx # 단가 목록 페이지 +│ ├── create/ +│ │ └── page.tsx # 단가 등록 페이지 (품목 선택 후) +│ └── [id]/ +│ └── edit/ +│ └── page.tsx # 단가 수정 페이지 +├── components/ +│ └── pricing/ +│ ├── PricingListClient.tsx # 목록 클라이언트 컴포넌트 +│ ├── PricingFormClient.tsx # 등록/수정 폼 컴포넌트 +│ ├── PricingStatsCards.tsx # 통계 카드 컴포넌트 +│ ├── PricingHistoryDialog.tsx # 이력 조회 다이얼로그 +│ ├── PricingRevisionDialog.tsx # 수정 이력 생성 다이얼로그 +│ ├── PricingFinalizeDialog.tsx # 최종 확정 다이얼로그 +│ └── types.ts # 타입 정의 +└── lib/api/ + └── pricing.ts # API 함수 +``` + +--- + +## 체크리스트 + +### Phase 1: 기본 설정 및 타입 정의 ✅ +- [x] 1.1 `src/components/pricing/types.ts` 타입 정의 파일 생성 + - [x] `PricingData` 인터페이스 + - [x] `PricingRevision` 인터페이스 + - [x] `PricingFormData` 인터페이스 + - [x] `PricingStats` 인터페이스 +- [ ] 1.2 `src/lib/api/pricing.ts` API 함수 파일 생성 (API 연동 시 구현 예정) + - [ ] `fetchPricingList` - 단가 목록 조회 + - [ ] `fetchPricingById` - 단가 상세 조회 + - [ ] `createPricing` - 단가 등록 + - [ ] `updatePricing` - 단가 수정 + - [ ] `finalizePricing` - 단가 최종 확정 + - [ ] `fetchPricingHistory` - 단가 이력 조회 + +### Phase 2: 단가 목록 페이지 ✅ +- [x] 2.1 통계 카드 (공통 StatCards 컴포넌트 활용) + - [x] 전체 품목 카드 (Package 아이콘, 파랑) + - [x] 단가 등록 카드 (DollarSign 아이콘, 초록) + - [x] 미등록 카드 (AlertCircle 아이콘, 주황) + - [x] 확정 카드 (CheckCircle2 아이콘, 보라) +- [x] 2.2 `src/components/pricing/PricingListClient.tsx` 생성 + - [x] 공통 DataTable 컴포넌트 연동 + - [x] 탭 필터 구성 (전체/제품/부품/부자재/원자재/소모품/절곡물) + - [x] 검색 기능 (품목코드, 품목명, 규격) + - [x] 테이블 컬럼 정의 + - [x] 번호 컬럼 + - [x] 품목유형 컬럼 (Badge) + - [x] 품목코드 컬럼 + - [x] 품목명 컬럼 + - [x] 규격 컬럼 + - [x] 단위 컬럼 + - [x] 매입단가 컬럼 (금액 포맷) + - [x] 가공비 컬럼 (금액 포맷) + - [x] 판매단가 컬럼 (금액 포맷, 굵게) + - [x] 마진율 컬럼 (Badge, 색상 구분) + - [x] 적용일 컬럼 (날짜 포맷) + - [x] 상태 컬럼 (미등록/활성/확정 Badge) + - [x] 작업 컬럼 (등록/수정/이력 버튼) + - [x] 품목 마스터 동기화 버튼 +- [x] 2.3 `src/app/[locale]/(protected)/sales/pricing-management/page.tsx` 생성 + - [x] 서버 컴포넌트 구성 + - [x] PricingListClient 연동 + +### Phase 3: 단가 등록/수정 폼 컴포넌트 ✅ +- [x] 3.1 `src/components/pricing/PricingFormClient.tsx` 생성 + - [x] 품목 정보 섹션 (읽기전용 카드) + - [x] 품목 코드 + - [x] 품목명 + - [x] 품목 유형 (Badge) + - [x] 단위 + - [x] 규격 + - [x] 단가 정보 섹션 + - [x] 적용일 입력 (필수, DatePicker) + - [x] 공급업체 입력 + - [x] 입고일 입력 (DatePicker) + - [x] 작성자 입력 + - [x] 입고가 입력 (필수, 숫자) + - [x] 단위 선택 (Select) + - [x] LOSS(%) 입력 (숫자) + - [x] 가공비 입력 (숫자) + - [x] 원가 계산 섹션 (자동 계산 표시) + - [x] 입고가 표시 + - [x] 가공비 표시 + - [x] 소계 표시 + - [x] LOSS 적용 금액 표시 + - [x] LOSS 적용 원가 표시 (최종) + - [x] 반올림 설정 + - [x] 반올림 규칙 선택 (반올림/올림/내림) + - [x] 반올림 단위 선택 (1/10/100/1000/10000원) + - [x] 마진율/판매단가 입력 + - [x] 마진율(%) 입력 → 판매단가 자동 계산 + - [x] 판매단가 입력 → 마진율 자동 계산 (필수) + - [x] 마진 계산 섹션 (자동 계산 표시) + - [x] LOSS 적용 원가 표시 + - [x] 판매단가 표시 + - [x] 마진 금액 및 % 표시 + - [x] 비고 입력 (Textarea) + - [x] 수정 모드 상태 표시 + - [x] 최종 확정 배지 + - [x] 수정 차수 배지 + - [x] 활성 상태 배지 + - [x] 버튼 영역 + - [x] 취소 버튼 + - [x] 이력 조회 버튼 (수정 모드, 이력 있을 때) + - [x] 최종 확정 버튼 (수정 모드, 미확정일 때) + - [x] 저장 버튼 + +### Phase 4: 다이얼로그 컴포넌트 ✅ +- [x] 4.1 `src/components/pricing/PricingHistoryDialog.tsx` 생성 + - [x] 현재 버전 표시 (파란 배경) + - [x] 이전 버전 목록 (리비전 번호, 날짜, 수정자, 수정 사유) + - [x] 각 버전별 단가 정보 표시 (매입단가, 가공비, 판매단가, 마진율) + - [x] 최초 버전 표시 (회색 배경) +- [x] 4.2 `src/components/pricing/PricingRevisionDialog.tsx` 생성 + - [x] 수정 사유 입력 (필수, Textarea) + - [x] 취소/수정 이력 저장 버튼 +- [x] 4.3 `src/components/pricing/PricingFinalizeDialog.tsx` 생성 + - [x] 확정 대상 정보 요약 (품목명, 매입단가, 판매단가, 마진율) + - [x] 경고 메시지 (확정 후 수정 불가) + - [x] 취소/최종 확정 버튼 + +### Phase 5: 페이지 라우트 구성 ✅ +- [x] 5.1 `src/app/[locale]/(protected)/sales/pricing-management/create/page.tsx` 생성 + - [x] 품목 정보 쿼리 파라미터로 전달받기 + - [x] PricingFormClient 연동 (신규 모드) +- [x] 5.2 `src/app/[locale]/(protected)/sales/pricing-management/[id]/edit/page.tsx` 생성 + - [x] 기존 단가 데이터 조회 + - [x] PricingFormClient 연동 (수정 모드) + +### Phase 6: 네비게이션 연동 ✅ +- [x] 6.1 사이드바 메뉴 추가 + - [x] 판매관리 > 단가관리 메뉴 항목 추가 + - [x] 아이콘: DollarSign + - [x] 경로: `/sales/pricing-management` + +### Phase 7: 테스트 및 검증 ⏳ +- [ ] 7.1 기능 테스트 + - [ ] 목록 페이지 로딩 + - [ ] 검색 기능 + - [ ] 탭 필터 기능 + - [ ] 페이지네이션 + - [ ] 신규 등록 플로우 + - [ ] 수정 플로우 + - [ ] 이력 조회 + - [ ] 최종 확정 +- [ ] 7.2 반응형 테스트 + - [ ] 데스크톱 레이아웃 + - [ ] 모바일 레이아웃 +- [ ] 7.3 스타일 검증 + - [ ] sam-design 스크린샷과 비교 + +--- + +## 데이터 타입 참조 + +```typescript +// PricingData 인터페이스 (sam-design 기준) +interface PricingData { + id: string; + itemId: string; + itemCode: string; + itemName: string; + itemType: string; + specification?: string; + unit: string; + effectiveDate: string; // 적용일 + receiveDate?: string; // 입고일 + author?: string; // 작성자 + purchasePrice?: number; // 매입단가 (입고가) + processingCost?: number; // 가공비 + loss?: number; // LOSS(%) + roundingRule?: 'round' | 'ceil' | 'floor'; // 반올림 규칙 + roundingUnit?: number; // 반올림 단위 + marginRate?: number; // 마진율(%) + salesPrice?: number; // 판매단가 + supplier?: string; // 공급업체 + note?: string; // 비고 + + currentRevision: number; // 현재 리비전 + isFinal: boolean; // 최종 확정 여부 + revisions?: PricingRevision[]; + finalizedDate?: string; + finalizedBy?: string; + status: 'draft' | 'active' | 'inactive' | 'finalized'; + + createdAt: string; + createdBy: string; + updatedAt?: string; + updatedBy?: string; +} + +interface PricingRevision { + revisionNumber: number; + revisionDate: string; + revisionBy: string; + revisionReason?: string; + previousData: PricingData; +} +``` + +--- + +## 원가/마진 계산 로직 + +```typescript +// LOSS 적용 원가 계산 +const calculateCostWithLoss = () => { + const basePrice = (purchasePrice || 0) + (processingCost || 0); + const lossRate = (loss || 0) / 100; + return Math.round(basePrice * (1 + lossRate)); +}; + +// 반올림 적용 +const applyRounding = (value: number, rule: 'round' | 'ceil' | 'floor', unit: number) => { + switch (rule) { + case 'ceil': return Math.ceil(value / unit) * unit; + case 'floor': return Math.floor(value / unit) * unit; + default: return Math.round(value / unit) * unit; + } +}; + +// 마진율 → 판매단가 계산 +const calculateSalesPrice = (marginRate: number) => { + const costWithLoss = calculateCostWithLoss(); + const price = costWithLoss * (1 + marginRate / 100); + return Math.round(applyRounding(price, roundingRule, roundingUnit)); +}; + +// 판매단가 → 마진율 계산 +const calculateMarginRate = (salesPrice: number) => { + const costWithLoss = calculateCostWithLoss(); + if (costWithLoss > 0) { + return ((salesPrice - costWithLoss) / costWithLoss) * 100; + } + return 0; +}; +``` + +--- + +## 참고 사항 + +1. **공통 DataTable 사용**: `/components/common/DataTable/DataTable.tsx` 활용 +2. **탭 필터**: DataTable의 `tabFilter` prop 활용 +3. **검색**: DataTable의 `search` prop 활용 +4. **페이지네이션**: DataTable 기본 제공 +5. **API 프록시**: HttpOnly 쿠키 사용 시 `/api/proxy/` 경로 활용 +6. **라우팅**: Next.js App Router 패턴 준수 + +--- + +## 진행 상태 + +- **시작일**: 2025-12-05 +- **현재 단계**: Phase 6 완료, API 연동 대기 +- **완료율**: 85% (UI/컴포넌트 마이그레이션 완료, API 연동 및 테스트 대기) + +--- + +## 생성된 파일 목록 + +| 파일 경로 | 설명 | +|-----------|------| +| `src/components/pricing/types.ts` | 타입 정의 | +| `src/components/pricing/PricingListClient.tsx` | 목록 클라이언트 컴포넌트 | +| `src/components/pricing/PricingFormClient.tsx` | 등록/수정 폼 컴포넌트 | +| `src/components/pricing/PricingHistoryDialog.tsx` | 이력 조회 다이얼로그 | +| `src/components/pricing/PricingRevisionDialog.tsx` | 수정 이력 생성 다이얼로그 | +| `src/components/pricing/PricingFinalizeDialog.tsx` | 최종 확정 다이얼로그 | +| `src/components/pricing/index.ts` | Barrel export | +| `src/app/[locale]/(protected)/sales/pricing-management/page.tsx` | 목록 페이지 | +| `src/app/[locale]/(protected)/sales/pricing-management/create/page.tsx` | 등록 페이지 | +| `src/app/[locale]/(protected)/sales/pricing-management/[id]/edit/page.tsx` | 수정 페이지 | + +--- + +## 변경 이력 + +| 날짜 | 내용 | 작성자 | +|------|------|--------| +| 2025-12-05 | 최초 계획서 작성 | Claude | +| 2025-12-05 | Phase 1-6 완료 (UI/컴포넌트 마이그레이션) | Claude | diff --git a/src/app/[locale]/(protected)/hr/attendance-management/page.tsx b/src/app/[locale]/(protected)/hr/attendance-management/page.tsx new file mode 100644 index 00000000..12b83764 --- /dev/null +++ b/src/app/[locale]/(protected)/hr/attendance-management/page.tsx @@ -0,0 +1,38 @@ +/** + * 근태관리 페이지 (Attendance Management) + * + * 직원 출퇴근 및 근태 정보를 관리하는 시스템 + * - 근태 목록 조회/검색/필터 + * - 근태 등록/수정 + * - 사유 등록 (출장, 휴가, 외근 등) + * - 엑셀 다운로드 + */ + +import { Suspense } from 'react'; +import { AttendanceManagement } from '@/components/hr/AttendanceManagement'; +import type { Metadata } from 'next'; + +/** + * 메타데이터 설정 + */ +export const metadata: Metadata = { + title: '근태관리', + description: '직원 출퇴근 및 근태 정보를 관리합니다', +}; + +export default function AttendanceManagementPage() { + return ( +
+ +
+
+

로딩 중...

+
+
+ }> + + + + ); +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/hr/department-management/page.tsx b/src/app/[locale]/(protected)/hr/department-management/page.tsx new file mode 100644 index 00000000..e3cca530 --- /dev/null +++ b/src/app/[locale]/(protected)/hr/department-management/page.tsx @@ -0,0 +1,35 @@ +/** + * 부서관리 페이지 (Department Management) + * + * 부서 정보를 관리하는 시스템 + * 무제한 깊이 트리 구조 지원 + */ + +import { Suspense } from 'react'; +import { DepartmentManagement } from '@/components/hr/DepartmentManagement'; +import type { Metadata } from 'next'; + +/** + * 메타데이터 설정 + */ +export const metadata: Metadata = { + title: '부서관리', + description: '부서 정보를 관리합니다', +}; + +export default function DepartmentManagementPage() { + return ( +
+ +
+
+

로딩 중...

+
+
+ }> + + + + ); +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/hr/employee-management/[id]/edit/page.tsx b/src/app/[locale]/(protected)/hr/employee-management/[id]/edit/page.tsx new file mode 100644 index 00000000..9309802a --- /dev/null +++ b/src/app/[locale]/(protected)/hr/employee-management/[id]/edit/page.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { useRouter, useParams } from 'next/navigation'; +import { useState, useEffect } from 'react'; +import { EmployeeForm } from '@/components/hr/EmployeeManagement/EmployeeForm'; +import type { Employee, EmployeeFormData } from '@/components/hr/EmployeeManagement/types'; + +// TODO: 실제 API에서 데이터 가져오기 +const mockEmployee: Employee = { + id: '1', + name: '김철수', + employeeCode: 'EMP001', + phone: '010-1234-5678', + email: 'kimcs@company.com', + status: 'active', + hireDate: '2020-03-15', + employmentType: 'regular', + rank: '과장', + gender: 'male', + salary: 50000000, + bankAccount: { + bankName: '국민은행', + accountNumber: '123-456-789012', + accountHolder: '김철수', + }, + address: { + zipCode: '12345', + address1: '서울시 강남구 테헤란로 123', + address2: '101호', + }, + departmentPositions: [ + { id: '1', departmentId: 'd1', departmentName: '개발본부', positionId: 'p1', positionName: '팀장' } + ], + userInfo: { userId: 'kimcs', role: 'manager', accountStatus: 'active' }, + createdAt: '2020-03-15T00:00:00Z', + updatedAt: '2024-01-15T00:00:00Z', +}; + +export default function EmployeeEditPage() { + const router = useRouter(); + const params = useParams(); + const [employee, setEmployee] = useState(null); + + useEffect(() => { + // TODO: API 연동 + // const id = params.id; + setEmployee(mockEmployee); + }, [params.id]); + + const handleSave = (data: EmployeeFormData) => { + // TODO: API 연동 + console.log('Update employee:', params.id, data); + router.push(`/ko/hr/employee-management/${params.id}`); + }; + + if (!employee) { + return ( +
+
+
+

로딩 중...

+
+
+ ); + } + + return ; +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/hr/employee-management/[id]/page.tsx b/src/app/[locale]/(protected)/hr/employee-management/[id]/page.tsx new file mode 100644 index 00000000..6c5ec002 --- /dev/null +++ b/src/app/[locale]/(protected)/hr/employee-management/[id]/page.tsx @@ -0,0 +1,119 @@ +'use client'; + +import { useRouter, useParams } from 'next/navigation'; +import { useState, useEffect } from 'react'; +import { EmployeeDetail } from '@/components/hr/EmployeeManagement/EmployeeDetail'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import type { Employee } from '@/components/hr/EmployeeManagement/types'; + +// TODO: 실제 API에서 데이터 가져오기 +const mockEmployee: Employee = { + id: '1', + name: '김철수', + employeeCode: 'EMP001', + phone: '010-1234-5678', + email: 'kimcs@company.com', + status: 'active', + hireDate: '2020-03-15', + employmentType: 'regular', + rank: '과장', + gender: 'male', + salary: 50000000, + bankAccount: { + bankName: '국민은행', + accountNumber: '123-456-789012', + accountHolder: '김철수', + }, + address: { + zipCode: '12345', + address1: '서울시 강남구 테헤란로 123', + address2: '101호', + }, + departmentPositions: [ + { id: '1', departmentId: 'd1', departmentName: '개발본부', positionId: 'p1', positionName: '팀장' } + ], + userInfo: { userId: 'kimcs', role: 'manager', accountStatus: 'active' }, + createdAt: '2020-03-15T00:00:00Z', + updatedAt: '2024-01-15T00:00:00Z', +}; + +export default function EmployeeDetailPage() { + const router = useRouter(); + const params = useParams(); + const [employee, setEmployee] = useState(null); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + useEffect(() => { + // TODO: API 연동 + // const id = params.id; + setEmployee(mockEmployee); + }, [params.id]); + + const handleEdit = () => { + router.push(`/ko/hr/employee-management/${params.id}/edit`); + }; + + const handleDelete = () => { + setDeleteDialogOpen(true); + }; + + const confirmDelete = () => { + // TODO: API 연동 + console.log('Delete employee:', params.id); + router.push('/ko/hr/employee-management'); + }; + + if (!employee) { + return ( +
+
+
+

로딩 중...

+
+
+ ); + } + + return ( + <> + + + + + + 사원 삭제 + + "{employee.name}" 사원을 삭제하시겠습니까? +
+ + 삭제된 사원 정보는 복구할 수 없습니다. + +
+
+ + 취소 + + 삭제 + + +
+
+ + ); +} diff --git a/src/app/[locale]/(protected)/hr/employee-management/csv-upload/page.tsx b/src/app/[locale]/(protected)/hr/employee-management/csv-upload/page.tsx new file mode 100644 index 00000000..c5d83887 --- /dev/null +++ b/src/app/[locale]/(protected)/hr/employee-management/csv-upload/page.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { CSVUploadPage } from '@/components/hr/EmployeeManagement/CSVUploadPage'; +import type { Employee } from '@/components/hr/EmployeeManagement/types'; + +export default function EmployeeCSVUploadPage() { + const handleUpload = (employees: Employee[]) => { + // TODO: API 연동 + console.log('Upload employees:', employees); + }; + + return ; +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/hr/employee-management/new/page.tsx b/src/app/[locale]/(protected)/hr/employee-management/new/page.tsx new file mode 100644 index 00000000..0ca466ef --- /dev/null +++ b/src/app/[locale]/(protected)/hr/employee-management/new/page.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { EmployeeForm } from '@/components/hr/EmployeeManagement/EmployeeForm'; +import type { EmployeeFormData } from '@/components/hr/EmployeeManagement/types'; + +export default function EmployeeNewPage() { + const router = useRouter(); + + const handleSave = (data: EmployeeFormData) => { + // TODO: API 연동 + console.log('Save new employee:', data); + router.push('/ko/hr/employee-management'); + }; + + return ; +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/hr/employee-management/page.tsx b/src/app/[locale]/(protected)/hr/employee-management/page.tsx new file mode 100644 index 00000000..b35a7112 --- /dev/null +++ b/src/app/[locale]/(protected)/hr/employee-management/page.tsx @@ -0,0 +1,38 @@ +/** + * 사원관리 페이지 (Employee Management) + * + * 사원 정보를 관리하는 시스템 + * - 사원 목록 조회/검색/필터 + * - 사원 등록/수정/삭제 + * - CSV 일괄 등록 + * - 사용자 초대 + */ + +import { Suspense } from 'react'; +import { EmployeeManagement } from '@/components/hr/EmployeeManagement'; +import type { Metadata } from 'next'; + +/** + * 메타데이터 설정 + */ +export const metadata: Metadata = { + title: '사원관리', + description: '사원 정보를 관리합니다', +}; + +export default function EmployeeManagementPage() { + return ( +
+ +
+
+

로딩 중...

+
+
+ }> + + + + ); +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/items/[id]/edit/page.tsx b/src/app/[locale]/(protected)/items/[id]/edit/page.tsx index 09b6e818..1534e209 100644 --- a/src/app/[locale]/(protected)/items/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/items/[id]/edit/page.tsx @@ -14,9 +14,12 @@ import DynamicItemForm from '@/components/items/DynamicItemForm'; import type { DynamicFormData } from '@/components/items/DynamicItemForm/types'; import type { ItemType } from '@/types/item'; import { Loader2 } from 'lucide-react'; - -// Materials 타입 (SM, RM, CS는 Material 테이블 사용) -const MATERIAL_TYPES = ['SM', 'RM', 'CS']; +import { + MATERIAL_TYPES, + isMaterialType, + transformMaterialDataForSave, + convertOptionsToStandardFields, +} from '@/lib/utils/materialTransform'; /** * API 응답 타입 (백엔드 Product 모델 기준) @@ -38,6 +41,9 @@ interface ItemApiResponse { is_active?: boolean; description?: string; note?: string; + remarks?: string; // Material 모델은 remarks 사용 + material_code?: string; // Material 모델 코드 필드 + material_type?: string; // Material 모델 타입 필드 part_type?: string; part_usage?: string; material?: string; @@ -69,12 +75,15 @@ function mapApiResponseToFormData(data: ItemApiResponse): DynamicFormData { // 프론트엔드 폼 필드: item_name, item_code 등 (snake_case) // 기본 필드 (백엔드 name → 폼 item_name) - const itemName = data.name || data.item_name; + // Material의 경우 item_name 필드 사용, Product는 name 필드 사용 + const itemName = data.item_name || data.name; if (itemName) formData['item_name'] = itemName; if (data.unit) formData['unit'] = data.unit; if (data.specification) formData['specification'] = data.specification; if (data.description) formData['description'] = data.description; + // Material은 'remarks', Product는 'note' 사용 → 프론트엔드 폼은 'note' 기대 if (data.note) formData['note'] = data.note; + if (data.remarks) formData['note'] = data.remarks; // Material remarks → note 매핑 formData['is_active'] = data.is_active ?? true; // 부품 관련 필드 (PT) @@ -100,12 +109,32 @@ function mapApiResponseToFormData(data: ItemApiResponse): DynamicFormData { if (data.certification_start_date) formData['certification_start_date'] = data.certification_start_date; if (data.certification_end_date) formData['certification_end_date'] = data.certification_end_date; + // 파일 관련 필드 (edit 모드에서 기존 파일 표시용) + if (data.bending_diagram) formData['bending_diagram'] = data.bending_diagram; + if (data.specification_file) formData['specification_file'] = data.specification_file; + if (data.specification_file_name) formData['specification_file_name'] = data.specification_file_name; + if (data.certification_file) formData['certification_file'] = data.certification_file; + if (data.certification_file_name) formData['certification_file_name'] = data.certification_file_name; + + // Material(SM, RM, CS) options 필드 매핑 + // 백엔드에서 options: [{label: "standard_1", value: "옵션값"}, ...] 형태로 저장됨 + // 프론트엔드 폼에서는 standard_1: "옵션값" 형태로 사용 + // 2025-12-05: Edit 모드에서 Select 옵션 값 불러오기 위해 추가 + if (data.options && Array.isArray(data.options)) { + (data.options as Array<{ label: string; value: string }>).forEach((opt) => { + if (opt.label && opt.value) { + formData[opt.label] = opt.value; + } + }); + } + // 기타 동적 필드들 (API에서 받은 모든 필드를 포함) Object.entries(data).forEach(([key, value]) => { // 이미 처리한 특수 필드들 제외 (백엔드 필드명 + 기존 필드명) const excludeKeys = [ 'id', 'code', 'name', 'product_type', // 백엔드 Product 모델 필드 'item_code', 'item_name', 'item_type', // 기존 호환 필드 + 'material_code', 'material_type', 'remarks', // Material 모델 필드 (remarks → note 변환됨) 'created_at', 'updated_at', 'deleted_at', 'bom', 'tenant_id', 'category_id', 'category', 'component_lines', ]; @@ -177,6 +206,12 @@ export default function EditItemPage() { if (result.success && result.data) { const apiData = result.data as ItemApiResponse; + // console.log('========== [EditItem] API 원본 데이터 =========='); + // console.log('is_active:', apiData.is_active); + // console.log('specification:', apiData.specification); + // console.log('unit:', apiData.unit); + // console.log('전체 데이터:', JSON.stringify(apiData, null, 2)); + // console.log('================================================'); // ID, 품목 유형 저장 // Product: product_type, Material: material_type 또는 type_code @@ -187,7 +222,12 @@ export default function EditItemPage() { // 폼 데이터로 변환 const formData = mapApiResponseToFormData(apiData); - // console.log('[EditItem] Mapped form data:', formData); + // console.log('========== [EditItem] Mapped form data =========='); + // console.log('is_active:', formData['is_active']); + // console.log('specification:', formData['specification']); + // console.log('unit:', formData['unit']); + // console.log('전체 매핑 데이터:', JSON.stringify(formData, null, 2)); + // console.log('================================================='); setInitialData(formData); } else { setError(result.message || '품목 정보를 불러올 수 없습니다.'); @@ -222,6 +262,12 @@ export default function EditItemPage() { // Materials (SM, RM, CS)는 /products/materials 엔드포인트 + PATCH 메서드 사용 // Products (FG, PT)는 /items 엔드포인트 + PUT 메서드 사용 const isMaterial = itemType ? MATERIAL_TYPES.includes(itemType) : false; + + // 디버깅: material_code 생성 관련 변수 확인 (필요 시 주석 해제) + // console.log('========== [EditItem] handleSubmit 디버깅 =========='); + // console.log('itemType:', itemType, 'isMaterial:', isMaterial); + // console.log('data:', JSON.stringify(data, null, 2)); + // console.log('===================================================='); const updateUrl = isMaterial ? `/api/proxy/products/materials/${itemId}` : `/api/proxy/items/${itemId}`; @@ -229,18 +275,25 @@ export default function EditItemPage() { // console.log('[EditItem] Update URL:', updateUrl, '(method:', method, ', isMaterial:', isMaterial, ')'); - // 수정 시 code/material_code는 변경하지 않음 (UNIQUE 제약조건 위반 방지) - // DynamicItemForm에서 자동생성되는 code를 제외해야 함 + // 품목코드 자동생성 처리 + // - FG(제품): 품목코드 = 품목명 + // - PT(부품): DynamicItemForm에서 자동계산한 code 사용 (조립/절곡/구매 각각 다른 규칙) + // - Material(SM, RM, CS): material_code = 품목명-규격 let submitData = { ...data }; - // FG(제품)의 경우: 품목코드 = 품목명이므로, name 변경 시 code도 함께 변경 - // 다른 타입: code 제외 (UNIQUE 제약조건) if (itemType === 'FG') { // FG는 품목명이 품목코드가 되므로 name 값으로 code 설정 submitData.code = submitData.name; - } else { - delete submitData.code; + } else if (itemType === 'PT') { + // PT는 DynamicItemForm에서 자동계산한 code를 그대로 사용 + // (조립: GR-001, 절곡: RM30, 구매: 전동개폐기150KG380V) + // code가 없으면 기본값으로 name 사용 + if (!submitData.code) { + submitData.code = submitData.name; + } } + // Material(SM, RM, CS)은 아래 isMaterial 블록에서 submitData.code를 material_code로 변환 + // 2025-12-05: delete submitData.code 제거 - DynamicItemForm에서 조합된 code 값을 사용해야 함 // 공통: spec → specification 필드명 변환 (백엔드 API 규격) if (submitData.spec !== undefined) { @@ -249,29 +302,21 @@ export default function EditItemPage() { } if (isMaterial) { - // Materials의 경우 추가 필드명 변환 - // DynamicItemForm에서 오는 데이터: name, product_type 등 - // Material API가 기대하는 데이터: name, material_type 등 - submitData = { - ...submitData, - // Material API 필드명 매핑 - material_type: submitData.product_type || itemType, - // 불필요한 필드 제거 - product_type: undefined, - material_code: undefined, // 수정 시 코드 변경 불가 - }; - // console.log('[EditItem] Material submitData:', submitData); + // Material(SM, RM, CS) 데이터 변환: standard_* → options 배열, specification 생성 + // 2025-12-05: 공통 유틸 함수 사용 + submitData = transformMaterialDataForSave(submitData, itemType || 'RM'); + // console.log('[EditItem] Material 변환 데이터:', submitData); } else { // Products (FG, PT)의 경우 // console.log('[EditItem] Product submitData:', submitData); } // API 호출 - console.log('========== [EditItem] PUT 요청 데이터 =========='); - console.log('URL:', updateUrl); - console.log('Method:', method); - console.log('전송 데이터:', JSON.stringify(submitData, null, 2)); - console.log('================================================'); + // console.log('========== [EditItem] PUT 요청 데이터 =========='); + // console.log('URL:', updateUrl); + // console.log('Method:', method); + // console.log('전송 데이터:', JSON.stringify(submitData, null, 2)); + // console.log('================================================'); const response = await fetch(updateUrl, { method, @@ -282,16 +327,16 @@ export default function EditItemPage() { }); const result = await response.json(); - console.log('========== [EditItem] PUT 응답 =========='); - console.log('Response:', JSON.stringify(result, null, 2)); - console.log('=========================================='); + // console.log('========== [EditItem] PUT 응답 =========='); + // console.log('Response:', JSON.stringify(result, null, 2)); + // console.log('=========================================='); if (!response.ok || !result.success) { throw new Error(result.message || '품목 수정에 실패했습니다.'); } - // 성공 메시지만 표시 (리다이렉트는 DynamicItemForm에서 처리) - // alert 제거 - DynamicItemForm에서 router.push('/items')로 이동 + // 성공 시 품목 ID 반환 (파일 업로드용) + return { id: itemId, ...result.data }; }; // 로딩 상태 @@ -339,6 +384,7 @@ export default function EditItemPage() { diff --git a/src/app/[locale]/(protected)/items/[id]/page.tsx b/src/app/[locale]/(protected)/items/[id]/page.tsx index a0a20a93..4d03049b 100644 --- a/src/app/[locale]/(protected)/items/[id]/page.tsx +++ b/src/app/[locale]/(protected)/items/[id]/page.tsx @@ -73,6 +73,17 @@ function mapApiResponseToItemMaster(data: Record): ItemMaster { quantityFormula: bomItem.quantity_formula ? String(bomItem.quantity_formula) : undefined, isBending: Boolean(bomItem.is_bending ?? false), })) : undefined, + // 파일 관련 필드 (PT - 절곡/조립 부품) + bendingDiagram: data.bending_diagram ? String(data.bending_diagram) : undefined, + bendingDetails: Array.isArray(data.bending_details) ? data.bending_details : undefined, + // 파일 관련 필드 (FG - 제품) + specificationFile: data.specification_file ? String(data.specification_file) : undefined, + specificationFileName: data.specification_file_name ? String(data.specification_file_name) : undefined, + certificationFile: data.certification_file ? String(data.certification_file) : undefined, + certificationFileName: data.certification_file_name ? String(data.certification_file_name) : undefined, + certificationNumber: data.certification_number ? String(data.certification_number) : undefined, + certificationStartDate: data.certification_start_date ? String(data.certification_start_date) : undefined, + certificationEndDate: data.certification_end_date ? String(data.certification_end_date) : undefined, }; } diff --git a/src/app/[locale]/(protected)/items/create/page.tsx b/src/app/[locale]/(protected)/items/create/page.tsx index 731da73e..15a43426 100644 --- a/src/app/[locale]/(protected)/items/create/page.tsx +++ b/src/app/[locale]/(protected)/items/create/page.tsx @@ -9,6 +9,7 @@ import { useState } from 'react'; import DynamicItemForm from '@/components/items/DynamicItemForm'; import type { DynamicFormData } from '@/components/items/DynamicItemForm/types'; +import { isMaterialType, transformMaterialDataForSave } from '@/lib/utils/materialTransform'; // 기존 ItemForm (주석처리 - 롤백 시 사용) // import ItemForm from '@/components/items/ItemForm'; @@ -21,13 +22,24 @@ export default function CreateItemPage() { setSubmitError(null); try { + // 필드명 변환: spec → specification (백엔드 API 규격) + const submitData = { ...data }; + if (submitData.spec !== undefined) { + submitData.specification = submitData.spec; + delete submitData.spec; + } + + // Material(SM, RM, CS)인 경우 수정 페이지와 동일하게 transformMaterialDataForSave 사용 + const itemType = submitData.product_type as string; + // API 호출: POST /api/proxy/items + // 백엔드에서 product_type에 따라 Product/Material 분기 처리 const response = await fetch('/api/proxy/items', { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify(data), + body: JSON.stringify(submitData), }); const result = await response.json(); @@ -37,7 +49,10 @@ export default function CreateItemPage() { } // 성공 시 DynamicItemForm 내부에서 /items로 리다이렉트 처리됨 - console.log('[CreateItemPage] 품목 등록 성공:', result.data); + // console.log('[CreateItemPage] 품목 등록 성공:', result.data); + + // 생성된 품목 ID를 포함한 데이터 반환 (파일 업로드용) + return { id: result.data.id, ...result.data }; } catch (error) { console.error('[CreateItemPage] 품목 등록 실패:', error); setSubmitError(error instanceof Error ? error.message : '품목 등록에 실패했습니다.'); diff --git a/src/app/[locale]/(protected)/sales/pricing-management/[id]/edit/page.tsx b/src/app/[locale]/(protected)/sales/pricing-management/[id]/edit/page.tsx new file mode 100644 index 00000000..7114b54b --- /dev/null +++ b/src/app/[locale]/(protected)/sales/pricing-management/[id]/edit/page.tsx @@ -0,0 +1,233 @@ +/** + * 단가 수정 페이지 + * + * 경로: /sales/pricing-management/[id]/edit + */ + +import { PricingFormClient } from '@/components/pricing'; +import type { PricingData } from '@/components/pricing'; + +interface EditPricingPageProps { + params: Promise<{ + id: string; + }>; +} + +// TODO: API 연동 시 실제 단가 조회로 교체 +async function getPricingById(id: string): Promise { + // 임시 목(Mock) 데이터 + const mockPricings: Record = { + 'pricing-4': { + id: 'pricing-4', + itemId: 'item-4', + itemCode: 'GR-001', + itemName: '가이드레일 130×80', + itemType: 'PT', + specification: '130×80×2438', + unit: 'EA', + effectiveDate: '2025-11-24', + purchasePrice: 45000, + processingCost: 5000, + loss: 0, + roundingRule: 'round', + roundingUnit: 1, + marginRate: 20, + salesPrice: 60000, + supplier: '가이드레일 공급사', + note: '스크린용 가이드레일', + currentRevision: 1, + isFinal: false, + revisions: [ + { + revisionNumber: 1, + revisionDate: '2025-11-20T10:00:00Z', + revisionBy: '관리자', + revisionReason: '초기 가격 조정', + previousData: { + id: 'pricing-4', + itemId: 'item-4', + itemCode: 'GR-001', + itemName: '가이드레일 130×80', + itemType: 'PT', + specification: '130×80×2438', + unit: 'EA', + effectiveDate: '2025-11-15', + purchasePrice: 40000, + processingCost: 5000, + marginRate: 15, + salesPrice: 51750, + currentRevision: 0, + isFinal: false, + status: 'draft', + createdAt: '2025-11-15T09:00:00Z', + createdBy: '관리자', + }, + }, + ], + status: 'active', + createdAt: '2025-11-15T09:00:00Z', + createdBy: '관리자', + updatedAt: '2025-11-24T14:30:00Z', + updatedBy: '관리자', + }, + 'pricing-5': { + id: 'pricing-5', + itemId: 'item-5', + itemCode: 'CASE-001', + itemName: '케이스 철재', + itemType: 'PT', + specification: '표준형', + unit: 'EA', + effectiveDate: '2025-11-20', + purchasePrice: 35000, + processingCost: 10000, + loss: 0, + roundingRule: 'round', + roundingUnit: 10, + marginRate: 25, + salesPrice: 56250, + currentRevision: 0, + isFinal: false, + revisions: [], + status: 'active', + createdAt: '2025-11-20T10:00:00Z', + createdBy: '관리자', + }, + 'pricing-6': { + id: 'pricing-6', + itemId: 'item-6', + itemCode: 'MOTOR-001', + itemName: '모터 0.4KW', + itemType: 'PT', + specification: '0.4KW', + unit: 'EA', + effectiveDate: '2025-11-15', + purchasePrice: 120000, + processingCost: 10000, + loss: 0, + roundingRule: 'round', + roundingUnit: 100, + marginRate: 15, + salesPrice: 149500, + currentRevision: 2, + isFinal: false, + revisions: [ + { + revisionNumber: 2, + revisionDate: '2025-11-12T10:00:00Z', + revisionBy: '관리자', + revisionReason: '공급가 변동', + previousData: { + id: 'pricing-6', + itemId: 'item-6', + itemCode: 'MOTOR-001', + itemName: '모터 0.4KW', + itemType: 'PT', + specification: '0.4KW', + unit: 'EA', + effectiveDate: '2025-11-10', + purchasePrice: 115000, + processingCost: 10000, + marginRate: 15, + salesPrice: 143750, + currentRevision: 1, + isFinal: false, + status: 'active', + createdAt: '2025-11-05T09:00:00Z', + createdBy: '관리자', + }, + }, + { + revisionNumber: 1, + revisionDate: '2025-11-10T10:00:00Z', + revisionBy: '관리자', + revisionReason: '초기 등록', + previousData: { + id: 'pricing-6', + itemId: 'item-6', + itemCode: 'MOTOR-001', + itemName: '모터 0.4KW', + itemType: 'PT', + specification: '0.4KW', + unit: 'EA', + effectiveDate: '2025-11-05', + purchasePrice: 110000, + processingCost: 10000, + marginRate: 15, + salesPrice: 138000, + currentRevision: 0, + isFinal: false, + status: 'draft', + createdAt: '2025-11-05T09:00:00Z', + createdBy: '관리자', + }, + }, + ], + status: 'active', + createdAt: '2025-11-05T09:00:00Z', + createdBy: '관리자', + updatedAt: '2025-11-15T11:00:00Z', + updatedBy: '관리자', + }, + 'pricing-7': { + id: 'pricing-7', + itemId: 'item-7', + itemCode: 'CTL-001', + itemName: '제어기 기본형', + itemType: 'PT', + specification: '기본형', + unit: 'EA', + effectiveDate: '2025-11-10', + purchasePrice: 80000, + processingCost: 5000, + loss: 0, + roundingRule: 'round', + roundingUnit: 1000, + marginRate: 20, + salesPrice: 102000, + currentRevision: 3, + isFinal: true, + finalizedDate: '2025-11-25T10:00:00Z', + finalizedBy: '관리자', + revisions: [], + status: 'finalized', + createdAt: '2025-11-01T09:00:00Z', + createdBy: '관리자', + updatedAt: '2025-11-25T10:00:00Z', + updatedBy: '관리자', + }, + }; + + return mockPricings[id] || null; +} + +export default async function EditPricingPage({ params }: EditPricingPageProps) { + const { id } = await params; + + const pricingData = await getPricingById(id); + + if (!pricingData) { + return ( +
+
+

단가 정보를 찾을 수 없습니다

+

+ 올바른 단가 정보로 다시 시도해주세요. +

+
+
+ ); + } + + return ( + { + 'use server'; + // TODO: API 연동 시 실제 수정 로직으로 교체 + console.log('단가 수정:', data, isRevision, revisionReason); + }} + /> + ); +} diff --git a/src/app/[locale]/(protected)/sales/pricing-management/create/page.tsx b/src/app/[locale]/(protected)/sales/pricing-management/create/page.tsx new file mode 100644 index 00000000..dee1438b --- /dev/null +++ b/src/app/[locale]/(protected)/sales/pricing-management/create/page.tsx @@ -0,0 +1,81 @@ +/** + * 단가 등록 페이지 + * + * 경로: /sales/pricing-management/create?itemId=xxx&itemCode=xxx + */ + +import { PricingFormClient } from '@/components/pricing'; +import type { ItemInfo } from '@/components/pricing'; + +interface CreatePricingPageProps { + searchParams: Promise<{ + itemId?: string; + itemCode?: string; + }>; +} + +// TODO: API 연동 시 실제 품목 조회로 교체 +async function getItemInfo(itemId: string, itemCode: string): Promise { + // 임시 목(Mock) 데이터 + const mockItems: Record = { + 'item-1': { + id: 'item-1', + itemCode: 'SCREEN-001', + itemName: '스크린 셔터 기본형', + itemType: 'FG', + specification: '표준형', + unit: 'SET', + }, + 'item-2': { + id: 'item-2', + itemCode: 'SCREEN-002', + itemName: '스크린 셔터 오픈형', + itemType: 'FG', + specification: '오픈형', + unit: 'SET', + }, + 'item-3': { + id: 'item-3', + itemCode: 'STEEL-001', + itemName: '철재 셔터 기본형', + itemType: 'FG', + specification: '표준형', + unit: 'SET', + }, + }; + + return mockItems[itemId] || null; +} + +export default async function CreatePricingPage({ searchParams }: CreatePricingPageProps) { + const params = await searchParams; + const itemId = params.itemId || ''; + const itemCode = params.itemCode || ''; + + const itemInfo = await getItemInfo(itemId, itemCode); + + if (!itemInfo) { + return ( +
+
+

품목 정보를 찾을 수 없습니다

+

+ 올바른 품목 정보로 다시 시도해주세요. +

+
+
+ ); + } + + return ( + { + 'use server'; + // TODO: API 연동 시 실제 저장 로직으로 교체 + console.log('단가 등록:', data); + }} + /> + ); +} diff --git a/src/app/[locale]/(protected)/sales/pricing-management/page.tsx b/src/app/[locale]/(protected)/sales/pricing-management/page.tsx new file mode 100644 index 00000000..81ed3ea0 --- /dev/null +++ b/src/app/[locale]/(protected)/sales/pricing-management/page.tsx @@ -0,0 +1,178 @@ +/** + * 단가 목록 페이지 + * + * 경로: /sales/pricing-management + */ + +import { PricingListClient } from '@/components/pricing'; +import type { PricingListItem } from '@/components/pricing'; + +// TODO: API 연동 시 실제 데이터 fetching으로 교체 +async function getPricingList(): Promise { + // 임시 목(Mock) 데이터 + return [ + { + id: 'pricing-1', + itemId: 'item-1', + itemCode: 'SCREEN-001', + itemName: '스크린 셔터 기본형', + itemType: 'FG', + specification: '표준형', + unit: 'SET', + purchasePrice: undefined, + processingCost: undefined, + salesPrice: undefined, + marginRate: undefined, + effectiveDate: undefined, + status: 'not_registered', + currentRevision: 0, + isFinal: false, + }, + { + id: 'pricing-2', + itemId: 'item-2', + itemCode: 'SCREEN-002', + itemName: '스크린 셔터 오픈형', + itemType: 'FG', + specification: '오픈형', + unit: 'SET', + purchasePrice: undefined, + processingCost: undefined, + salesPrice: undefined, + marginRate: undefined, + effectiveDate: undefined, + status: 'not_registered', + currentRevision: 0, + isFinal: false, + }, + { + id: 'pricing-3', + itemId: 'item-3', + itemCode: 'STEEL-001', + itemName: '철재 셔터 기본형', + itemType: 'FG', + specification: '표준형', + unit: 'SET', + purchasePrice: undefined, + processingCost: undefined, + salesPrice: undefined, + marginRate: undefined, + effectiveDate: undefined, + status: 'not_registered', + currentRevision: 0, + isFinal: false, + }, + { + id: 'pricing-4', + itemId: 'item-4', + itemCode: 'GR-001', + itemName: '가이드레일 130×80', + itemType: 'PT', + specification: '130×80×2438', + unit: 'EA', + purchasePrice: 45000, + processingCost: 5000, + salesPrice: 60000, + marginRate: 20, + effectiveDate: '2025-11-24', + status: 'active', + currentRevision: 1, + isFinal: false, + }, + { + id: 'pricing-5', + itemId: 'item-5', + itemCode: 'CASE-001', + itemName: '케이스 철재', + itemType: 'PT', + specification: '표준형', + unit: 'EA', + purchasePrice: 35000, + processingCost: 10000, + salesPrice: 56250, + marginRate: 25, + effectiveDate: '2025-11-20', + status: 'active', + currentRevision: 0, + isFinal: false, + }, + { + id: 'pricing-6', + itemId: 'item-6', + itemCode: 'MOTOR-001', + itemName: '모터 0.4KW', + itemType: 'PT', + specification: '0.4KW', + unit: 'EA', + purchasePrice: 120000, + processingCost: 10000, + salesPrice: 149500, + marginRate: 15, + effectiveDate: '2025-11-15', + status: 'active', + currentRevision: 2, + isFinal: false, + }, + { + id: 'pricing-7', + itemId: 'item-7', + itemCode: 'CTL-001', + itemName: '제어기 기본형', + itemType: 'PT', + specification: '기본형', + unit: 'EA', + purchasePrice: 80000, + processingCost: 5000, + salesPrice: 102000, + marginRate: 20, + effectiveDate: '2025-11-10', + status: 'finalized', + currentRevision: 3, + isFinal: true, + }, + { + id: 'pricing-8', + itemId: 'item-8', + itemCode: '가이드레일wall12*30*12', + itemName: '가이드레일', + itemType: 'PT', + specification: '가이드레일', + unit: 'M', + purchasePrice: undefined, + processingCost: undefined, + salesPrice: undefined, + marginRate: undefined, + effectiveDate: undefined, + status: 'not_registered', + currentRevision: 0, + isFinal: false, + }, + { + id: 'pricing-9', + itemId: 'item-9', + itemCode: '소모품 테스트-소모품 규격 테스트', + itemName: '소모품 테스트', + itemType: 'CS', + specification: '소모품 규격 테스트', + unit: 'M', + purchasePrice: undefined, + processingCost: undefined, + salesPrice: undefined, + marginRate: undefined, + effectiveDate: undefined, + status: 'not_registered', + currentRevision: 0, + isFinal: false, + }, + ]; +} + +export default async function PricingManagementPage() { + const pricingList = await getPricingList(); + + return ( +
+ +
+ ); +} diff --git a/src/app/api/proxy/[...path]/route.ts b/src/app/api/proxy/[...path]/route.ts index 5180633f..072ab0dd 100644 --- a/src/app/api/proxy/[...path]/route.ts +++ b/src/app/api/proxy/[...path]/route.ts @@ -74,22 +74,32 @@ async function refreshAccessToken(refreshToken: string): Promise<{ /** * 백엔드 API 요청 실행 함수 + * + * @param isFormData - true인 경우 Content-Type 헤더를 생략 (브라우저가 boundary 자동 설정) */ async function executeBackendRequest( url: URL, method: string, token: string | undefined, - body: string | undefined, - contentType: string + body: string | FormData | undefined, + contentType: string, + isFormData: boolean = false ): Promise { + // FormData인 경우 Content-Type을 생략해야 브라우저가 boundary를 자동 설정 + const headers: Record = { + 'Accept': 'application/json', + 'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '', + 'Authorization': token ? `Bearer ${token}` : '', + }; + + // FormData가 아닌 경우에만 Content-Type 설정 + if (!isFormData) { + headers['Content-Type'] = contentType; + } + return fetch(url.toString(), { method, - headers: { - 'Content-Type': contentType, - 'Accept': 'application/json', - 'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '', - 'Authorization': token ? `Bearer ${token}` : '', - }, + headers, body, }); } @@ -162,8 +172,9 @@ async function proxyRequest( }); // 3. 요청 바디 읽기 (POST, PUT, DELETE, PATCH) - let body: string | undefined; + let body: string | FormData | undefined; const contentType = request.headers.get('content-type') || 'application/json'; + let isFormData = false; if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) { if (contentType.includes('application/json')) { @@ -171,16 +182,38 @@ async function proxyRequest( console.log('🔵 [PROXY] Request:', method, url.toString()); console.log('🔵 [PROXY] Request Body:', body); // 디버깅용 } else if (contentType.includes('multipart/form-data')) { - // multipart는 formData로 처리해야 하지만, 현재는 지원하지 않음 - console.warn('🟡 [PROXY] multipart/form-data is not fully supported'); - body = await request.text(); + // multipart/form-data 처리: FormData를 그대로 전달 + console.log('📎 [PROXY] Processing multipart/form-data request'); + isFormData = true; + + // 원본 요청의 FormData 읽기 + const originalFormData = await request.formData(); + + // 새 FormData 생성 (백엔드 전송용) + const newFormData = new FormData(); + + // 모든 필드 복사 + for (const [key, value] of originalFormData.entries()) { + if (value instanceof File) { + // File 객체는 그대로 추가 + newFormData.append(key, value, value.name); + console.log(`📎 [PROXY] File field: ${key} = ${value.name} (${value.size} bytes)`); + } else { + // 일반 필드 + newFormData.append(key, value); + console.log(`📎 [PROXY] Form field: ${key} = ${value}`); + } + } + + body = newFormData; + console.log('🔵 [PROXY] Request:', method, url.toString()); } } else { console.log('🔵 [PROXY] Request:', method, url.toString()); } // 4. 백엔드로 프록시 요청 - let backendResponse = await executeBackendRequest(url, method, token, body, contentType); + let backendResponse = await executeBackendRequest(url, method, token, body, contentType, isFormData); let newTokens: { accessToken?: string; refreshToken?: string; expiresIn?: number } | null = null; // 5. 🔄 401 응답 시 토큰 갱신 후 재시도 @@ -195,7 +228,7 @@ async function proxyRequest( // 새 토큰으로 원래 요청 재시도 token = refreshResult.accessToken; newTokens = refreshResult; - backendResponse = await executeBackendRequest(url, method, token, body, contentType); + backendResponse = await executeBackendRequest(url, method, token, body, contentType, isFormData); console.log('🔵 [PROXY] Retry response status:', backendResponse.status); } else { diff --git a/src/components/hr/AttendanceManagement/AttendanceInfoDialog.tsx b/src/components/hr/AttendanceManagement/AttendanceInfoDialog.tsx new file mode 100644 index 00000000..99f1cbdc --- /dev/null +++ b/src/components/hr/AttendanceManagement/AttendanceInfoDialog.tsx @@ -0,0 +1,337 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Calendar } from '@/components/ui/calendar'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { CalendarIcon } from 'lucide-react'; +import { format } from 'date-fns'; +import { ko } from 'date-fns/locale'; +import { cn } from '@/lib/utils'; +import type { + AttendanceInfoDialogProps, + AttendanceFormData, +} from './types'; +import { + HOUR_OPTIONS, + MINUTE_OPTIONS, + OVERTIME_HOUR_OPTIONS, +} from './types'; + +const initialFormData: AttendanceFormData = { + employeeId: '', + baseDate: format(new Date(), 'yyyy-MM-dd'), + checkInHour: '9', + checkInMinute: '0', + checkOutHour: '18', + checkOutMinute: '0', + nightOvertimeHours: '0', + nightOvertimeMinutes: '0', + weekendOvertimeHours: '0', + weekendOvertimeMinutes: '0', +}; + +export function AttendanceInfoDialog({ + open, + onOpenChange, + mode, + attendance, + employees, + onSave, +}: AttendanceInfoDialogProps) { + const [formData, setFormData] = useState(initialFormData); + const [selectedDate, setSelectedDate] = useState(new Date()); + + // 모드별 타이틀 + const title = mode === 'create' ? '근태 정보' : '근태 정보'; + + // 데이터 초기화 + useEffect(() => { + if (open && attendance && mode === 'edit') { + const [checkInHour, checkInMinute] = (attendance.checkIn || '09:00').split(':'); + const [checkOutHour, checkOutMinute] = (attendance.checkOut || '18:00').split(':'); + + setFormData({ + employeeId: attendance.employeeId, + baseDate: attendance.baseDate, + checkInHour: checkInHour || '9', + checkInMinute: checkInMinute === '30' ? '30' : '0', + checkOutHour: checkOutHour || '18', + checkOutMinute: checkOutMinute === '30' ? '30' : '0', + nightOvertimeHours: '0', + nightOvertimeMinutes: '0', + weekendOvertimeHours: '0', + weekendOvertimeMinutes: '0', + }); + setSelectedDate(new Date(attendance.baseDate)); + } else if (open && mode === 'create') { + setFormData(initialFormData); + setSelectedDate(new Date()); + } + }, [open, attendance, mode]); + + // 입력 변경 핸들러 + const handleChange = (field: keyof AttendanceFormData, value: string) => { + setFormData(prev => ({ ...prev, [field]: value })); + }; + + // 날짜 변경 핸들러 + const handleDateChange = (date: Date | undefined) => { + setSelectedDate(date); + if (date) { + setFormData(prev => ({ ...prev, baseDate: format(date, 'yyyy-MM-dd') })); + } + }; + + // 저장 + const handleSubmit = () => { + onSave(formData); + onOpenChange(false); + }; + + // 선택된 사원 정보 + const selectedEmployee = employees.find(e => e.id === formData.employeeId); + + return ( + + + + {title} + + +
+ {/* 대상 선택 */} +
+ + +
+ + {/* 기준일 */} +
+ + + + + + + + + +
+ + {/* 출근 시간 */} +
+ +
+ + +
+
+ + {/* 퇴근 시간 */} +
+ +
+ + +
+
+ + {/* 야간 연장 시간 */} +
+ +
+ + +
+
+ + {/* 주말 연장 시간 */} +
+ +
+ + +
+
+
+ + + + + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/hr/AttendanceManagement/ReasonInfoDialog.tsx b/src/components/hr/AttendanceManagement/ReasonInfoDialog.tsx new file mode 100644 index 00000000..cce6bce6 --- /dev/null +++ b/src/components/hr/AttendanceManagement/ReasonInfoDialog.tsx @@ -0,0 +1,174 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Calendar } from '@/components/ui/calendar'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { CalendarIcon } from 'lucide-react'; +import { format } from 'date-fns'; +import { ko } from 'date-fns/locale'; +import { cn } from '@/lib/utils'; +import type { + ReasonInfoDialogProps, + ReasonFormData, + ReasonType, +} from './types'; +import { REASON_TYPE_LABELS } from './types'; + +const initialFormData: ReasonFormData = { + employeeId: '', + baseDate: format(new Date(), 'yyyy-MM-dd'), + reasonType: '', +}; + +export function ReasonInfoDialog({ + open, + onOpenChange, + employees, + onSubmit, +}: ReasonInfoDialogProps) { + const [formData, setFormData] = useState(initialFormData); + const [selectedDate, setSelectedDate] = useState(new Date()); + + // 데이터 초기화 + useEffect(() => { + if (open) { + setFormData(initialFormData); + setSelectedDate(new Date()); + } + }, [open]); + + // 입력 변경 핸들러 + const handleChange = (field: keyof ReasonFormData, value: string) => { + setFormData(prev => ({ ...prev, [field]: value })); + }; + + // 날짜 변경 핸들러 + const handleDateChange = (date: Date | undefined) => { + setSelectedDate(date); + if (date) { + setFormData(prev => ({ ...prev, baseDate: format(date, 'yyyy-MM-dd') })); + } + }; + + // 등록 (문서 작성 화면으로 이동) + const handleSubmit = () => { + onSubmit(formData); + onOpenChange(false); + }; + + return ( + + + + 사유 정보 + + +
+ {/* 대상 선택 */} +
+ + +
+ + {/* 기준일 */} +
+ + + + + + + + + +
+ + {/* 유형 선택 */} +
+ + +
+
+ + + + + +
+
+ ); +} diff --git a/src/components/hr/AttendanceManagement/index.tsx b/src/components/hr/AttendanceManagement/index.tsx new file mode 100644 index 00000000..640baa9a --- /dev/null +++ b/src/components/hr/AttendanceManagement/index.tsx @@ -0,0 +1,535 @@ +'use client'; + +import { useState, useMemo, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { + Clock, + UserCheck, + AlertCircle, + Calendar, + Download, + Plus, + FileText, + Edit, +} from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { TableRow, TableCell } from '@/components/ui/table'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { Calendar as CalendarComponent } from '@/components/ui/calendar'; +import { CalendarIcon } from 'lucide-react'; +import { format, addDays } from 'date-fns'; +import { ko } from 'date-fns/locale'; +import { cn } from '@/lib/utils'; +import { + IntegratedListTemplateV2, + type TableColumn, + type StatCard, + type TabOption, +} from '@/components/templates/IntegratedListTemplateV2'; +import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard'; +import { AttendanceInfoDialog } from './AttendanceInfoDialog'; +import { ReasonInfoDialog } from './ReasonInfoDialog'; +import type { + AttendanceRecord, + AttendanceStatus, + SortOption, + AttendanceFormData, + ReasonFormData, +} from './types'; +import { + ATTENDANCE_STATUS_LABELS, + ATTENDANCE_STATUS_COLORS, + SORT_OPTIONS, + REASON_TYPE_LABELS, +} from './types'; + +/** + * Mock 데이터 - 실제 API 연동 전 테스트용 + */ +const mockEmployees = [ + { id: '1', name: '이름', department: '부서명', position: '부서장팀장', rank: '부장' }, + { id: '2', name: '이름', department: '부서명', position: '팀장', rank: '부장' }, + { id: '3', name: '이름', department: '부서명', position: '부서장팀장', rank: '부장' }, + { id: '4', name: '이름', department: '부서명', position: '팀장', rank: '부장' }, + { id: '5', name: '이름', department: '부서명', position: '부서장팀장', rank: '부장' }, + { id: '6', name: '이름', department: '부서명', position: '팀장', rank: '부장' }, + { id: '7', name: '이름', department: '부서명', position: '부서장팀장', rank: '부장' }, +]; + +// Mock 근태 기록 생성 +const generateMockAttendanceRecords = (): AttendanceRecord[] => { + const records: AttendanceRecord[] = []; + const statuses: AttendanceStatus[] = ['onTime', 'onTime', 'onTime', 'late', 'absent', 'vacation', 'onTime']; + const reasons = [ + null, + null, + null, + null, + { type: 'businessTripRequest' as const, label: '출장기안', documentId: 'doc1' }, + { type: 'fieldWorkRequest' as const, label: '외근승인', documentId: 'doc2' }, + null, + ]; + + mockEmployees.forEach((employee, index) => { + records.push({ + id: String(index + 1), + employeeId: employee.id, + employeeName: employee.name, + department: employee.department, + position: employee.position, + rank: employee.rank, + baseDate: '2025-09-03', + checkIn: index === 4 ? null : '08:40', + checkOut: index === 4 ? null : '21:40', + breakTime: '1시간', + overtimeHours: index % 2 === 0 ? '3시간 30분' : '1시간', + reason: reasons[index], + status: statuses[index], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }); + + return records; +}; + +export function AttendanceManagement() { + const router = useRouter(); + + // 근태 데이터 상태 + const [attendanceRecords, setAttendanceRecords] = useState(generateMockAttendanceRecords); + + // 검색 및 필터 상태 + const [searchValue, setSearchValue] = useState(''); + const [activeTab, setActiveTab] = useState('all'); + const [sortOption, setSortOption] = useState('rank'); + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 20; + + // 날짜 범위 상태 + const [dateRange, setDateRange] = useState<{ from: Date; to: Date }>({ + from: new Date('2025-09-01'), + to: new Date('2025-09-03'), + }); + + // 다이얼로그 상태 + const [attendanceDialogOpen, setAttendanceDialogOpen] = useState(false); + const [attendanceDialogMode, setAttendanceDialogMode] = useState<'create' | 'edit'>('create'); + const [selectedAttendance, setSelectedAttendance] = useState(null); + const [reasonDialogOpen, setReasonDialogOpen] = useState(false); + const [selectedItems, setSelectedItems] = useState>(new Set()); + + // 필터링된 데이터 + const filteredRecords = useMemo(() => { + let filtered = attendanceRecords; + + // 탭(상태) 필터 + if (activeTab !== 'all') { + filtered = filtered.filter(r => r.status === activeTab); + } + + // 검색 필터 + if (searchValue) { + const search = searchValue.toLowerCase(); + filtered = filtered.filter(r => + r.employeeName.toLowerCase().includes(search) || + r.department.toLowerCase().includes(search) + ); + } + + // 정렬 + filtered = [...filtered].sort((a, b) => { + switch (sortOption) { + case 'rank': + return a.rank.localeCompare(b.rank, 'ko'); + case 'deptAsc': + return a.department.localeCompare(b.department, 'ko'); + case 'deptDesc': + return b.department.localeCompare(a.department, 'ko'); + case 'nameAsc': + return a.employeeName.localeCompare(b.employeeName, 'ko'); + case 'nameDesc': + return b.employeeName.localeCompare(a.employeeName, 'ko'); + default: + return 0; + } + }); + + return filtered; + }, [attendanceRecords, activeTab, searchValue, sortOption]); + + // 페이지네이션된 데이터 + const paginatedData = useMemo(() => { + const startIndex = (currentPage - 1) * itemsPerPage; + return filteredRecords.slice(startIndex, startIndex + itemsPerPage); + }, [filteredRecords, currentPage, itemsPerPage]); + + // 통계 계산 + const stats = useMemo(() => { + const onTimeCount = attendanceRecords.filter(r => r.status === 'onTime').length; + const lateCount = attendanceRecords.filter(r => r.status === 'late').length; + const absentCount = attendanceRecords.filter(r => r.status === 'absent').length; + const vacationCount = attendanceRecords.filter(r => r.status === 'vacation').length; + + return { onTimeCount, lateCount, absentCount, vacationCount }; + }, [attendanceRecords]); + + // StatCards 데이터 + const statCards: StatCard[] = useMemo(() => [ + { + label: '정시 출근', + value: `${stats.onTimeCount}명`, + icon: UserCheck, + iconColor: 'text-green-500', + }, + { + label: '지각', + value: `${stats.lateCount}명`, + icon: Clock, + iconColor: 'text-yellow-500', + }, + { + label: '결근', + value: `${stats.absentCount}명`, + icon: AlertCircle, + iconColor: 'text-red-500', + }, + { + label: '휴가', + value: `${stats.vacationCount}명`, + icon: Calendar, + iconColor: 'text-blue-500', + }, + ], [stats]); + + // 탭 옵션 + const tabs: TabOption[] = useMemo(() => [ + { value: 'all', label: '전체', count: attendanceRecords.length, color: 'gray' }, + { value: 'onTime', label: '정시 출근', count: stats.onTimeCount, color: 'green' }, + { value: 'late', label: '지각', count: stats.lateCount, color: 'yellow' }, + { value: 'absent', label: '결근', count: stats.absentCount, color: 'red' }, + { value: 'vacation', label: '휴가', count: stats.vacationCount, color: 'blue' }, + { value: 'businessTrip', label: '출장', count: attendanceRecords.filter(r => r.status === 'businessTrip').length, color: 'purple' }, + { value: 'fieldWork', label: '외근', count: attendanceRecords.filter(r => r.status === 'fieldWork').length, color: 'orange' }, + { value: 'overtime', label: '연장근무', count: attendanceRecords.filter(r => r.status === 'overtime').length, color: 'indigo' }, + ], [attendanceRecords.length, stats]); + + // 테이블 컬럼 정의 + const tableColumns: TableColumn[] = useMemo(() => [ + { key: 'department', label: '부서', className: 'min-w-[80px]' }, + { key: 'position', label: '직책', className: 'min-w-[100px]' }, + { key: 'name', label: '이름', className: 'min-w-[60px]' }, + { key: 'rank', label: '직급', className: 'min-w-[60px]' }, + { key: 'baseDate', label: '기준일', className: 'min-w-[100px]' }, + { key: 'checkIn', label: '출근', className: 'min-w-[60px]' }, + { key: 'checkOut', label: '퇴근', className: 'min-w-[60px]' }, + { key: 'breakTime', label: '휴게', className: 'min-w-[60px]' }, + { key: 'overtime', label: '연장근무', className: 'min-w-[80px]' }, + { key: 'reason', label: '사유', className: 'min-w-[80px]' }, + { key: 'actions', label: '작업', className: 'w-[60px] text-center' }, + ], []); + + // 체크박스 토글 + const toggleSelection = useCallback((id: string) => { + setSelectedItems(prev => { + const newSet = new Set(prev); + if (newSet.has(id)) { + newSet.delete(id); + } else { + newSet.add(id); + } + return newSet; + }); + }, []); + + // 전체 선택/해제 + const toggleSelectAll = useCallback(() => { + if (selectedItems.size === paginatedData.length && paginatedData.length > 0) { + setSelectedItems(new Set()); + } else { + const allIds = new Set(paginatedData.map((item) => item.id)); + setSelectedItems(allIds); + } + }, [selectedItems.size, paginatedData]); + + // 핸들러 + const handleAddAttendance = useCallback(() => { + setAttendanceDialogMode('create'); + setSelectedAttendance(null); + setAttendanceDialogOpen(true); + }, []); + + const handleAddReason = useCallback(() => { + setReasonDialogOpen(true); + }, []); + + const handleEditAttendance = useCallback((record: AttendanceRecord) => { + setAttendanceDialogMode('edit'); + setSelectedAttendance(record); + setAttendanceDialogOpen(true); + }, []); + + const handleSaveAttendance = useCallback((data: AttendanceFormData) => { + console.log('Save attendance:', data); + // TODO: API 연동 + setAttendanceDialogOpen(false); + }, []); + + const handleSubmitReason = useCallback((data: ReasonFormData) => { + console.log('Submit reason:', data); + // TODO: 문서 작성 화면으로 이동 + router.push(`/ko/hr/documents/new?type=${data.reasonType}`); + }, [router]); + + const handleExcelDownload = useCallback(() => { + console.log('Excel download'); + // TODO: 엑셀 다운로드 기능 구현 + }, []); + + const handleReasonClick = useCallback((record: AttendanceRecord) => { + if (record.reason?.documentId) { + router.push(`/ko/hr/documents/${record.reason.documentId}`); + } + }, [router]); + + // 테이블 행 렌더링 + const renderTableRow = useCallback((item: AttendanceRecord, index: number, globalIndex: number) => { + const isSelected = selectedItems.has(item.id); + + return ( + + e.stopPropagation()}> + toggleSelection(item.id)} + /> + + {item.department} + {item.position} + {item.employeeName} + {item.rank} + + {item.baseDate ? format(new Date(item.baseDate), 'yyyy-MM-dd (E)', { locale: ko }) : '-'} + + {item.checkIn || '-'} + {item.checkOut || '-'} + {item.breakTime || '-'} + {item.overtimeHours || '-'} + + {item.reason ? ( + + ) : '-'} + + + + + + ); + }, [selectedItems, toggleSelection, handleEditAttendance, handleReasonClick]); + + // 모바일 카드 렌더링 + const renderMobileCard = useCallback(( + item: AttendanceRecord, + index: number, + globalIndex: number, + isSelected: boolean, + onToggle: () => void + ) => { + return ( + + + {item.department} + + + {item.rank} + + + } + statusBadge={ + + {ATTENDANCE_STATUS_LABELS[item.status]} + + } + isSelected={isSelected} + onToggleSelection={onToggle} + infoGrid={ +
+ + + + + + + {item.reason && ( + + )} +
+ } + actions={ +
+ +
+ } + /> + ); + }, [handleEditAttendance]); + + // 헤더 액션 (날짜 범위 + 버튼들) + const headerActions = ( +
+ {/* 날짜 범위 선택 */} + + + + + + { + if (range?.from && range?.to) { + setDateRange({ from: range.from, to: range.to }); + } + }} + locale={ko} + numberOfMonths={2} + /> + + + + + +
+ ); + + // 검색 옆 추가 필터 (사유 등록 버튼 + 정렬 셀렉트) + const extraFilters = ( +
+ + +
+ ); + + // 페이지네이션 설정 + const totalPages = Math.ceil(filteredRecords.length / itemsPerPage); + + return ( + <> + + title="근태관리" + description="직원 출퇴근 및 근태 정보를 관리합니다" + icon={Clock} + headerActions={headerActions} + stats={statCards} + searchValue={searchValue} + onSearchChange={setSearchValue} + searchPlaceholder="이름, 부서 검색..." + extraFilters={extraFilters} + tabs={tabs} + activeTab={activeTab} + onTabChange={setActiveTab} + tableColumns={tableColumns} + data={paginatedData} + totalCount={filteredRecords.length} + allData={filteredRecords} + selectedItems={selectedItems} + onToggleSelection={toggleSelection} + onToggleSelectAll={toggleSelectAll} + getItemId={(item) => item.id} + renderTableRow={renderTableRow} + renderMobileCard={renderMobileCard} + pagination={{ + currentPage, + totalPages, + totalItems: filteredRecords.length, + itemsPerPage, + onPageChange: setCurrentPage, + }} + /> + + {/* 근태 정보 다이얼로그 */} + + + {/* 사유 정보 다이얼로그 */} + + + ); +} \ No newline at end of file diff --git a/src/components/hr/AttendanceManagement/types.ts b/src/components/hr/AttendanceManagement/types.ts new file mode 100644 index 00000000..71cc2f05 --- /dev/null +++ b/src/components/hr/AttendanceManagement/types.ts @@ -0,0 +1,128 @@ +/** + * 근태관리 (AttendanceManagement) 타입 정의 + */ + +// 근태 상태 타입 +export type AttendanceStatus = 'onTime' | 'late' | 'absent' | 'vacation' | 'businessTrip' | 'fieldWork' | 'overtime'; + +// 근태 상태 라벨 +export const ATTENDANCE_STATUS_LABELS: Record = { + onTime: '정시 출근', + late: '지각', + absent: '결근', + vacation: '휴가', + businessTrip: '출장', + fieldWork: '외근', + overtime: '연장근무', +}; + +// 근태 상태 색상 +export const ATTENDANCE_STATUS_COLORS: Record = { + onTime: 'bg-green-100 text-green-700', + late: 'bg-yellow-100 text-yellow-700', + absent: 'bg-red-100 text-red-700', + vacation: 'bg-blue-100 text-blue-700', + businessTrip: 'bg-purple-100 text-purple-700', + fieldWork: 'bg-orange-100 text-orange-700', + overtime: 'bg-indigo-100 text-indigo-700', +}; + +// 정렬 옵션 +export type SortOption = 'rank' | 'deptAsc' | 'deptDesc' | 'nameAsc' | 'nameDesc'; + +export const SORT_OPTIONS: Record = { + rank: '직급순', + deptAsc: '부서 오름차순', + deptDesc: '부서 내림차순', + nameAsc: '이름 오름차순', + nameDesc: '이름 내림차순', +}; + +// 사유 유형 (문서 유형) +export type ReasonType = 'businessTripRequest' | 'vacationRequest' | 'fieldWorkRequest' | 'overtimeRequest'; + +export const REASON_TYPE_LABELS: Record = { + businessTripRequest: '출장신청서', + vacationRequest: '휴가신청서', + fieldWorkRequest: '외근신청서', + overtimeRequest: '연장근무신청서', +}; + +// 근태 기록 인터페이스 +export interface AttendanceRecord { + id: string; + employeeId: string; + employeeName: string; + department: string; + position: string; + rank: string; + baseDate: string; + checkIn: string | null; + checkOut: string | null; + breakTime: string | null; + overtimeHours: string | null; + reason: { + type: ReasonType; + label: string; + documentId?: string; + } | null; + status: AttendanceStatus; + createdAt: string; + updatedAt: string; +} + +// 근태 등록/수정 폼 데이터 +export interface AttendanceFormData { + employeeId: string; + baseDate: string; + checkInHour: string; + checkInMinute: string; + checkOutHour: string; + checkOutMinute: string; + nightOvertimeHours: string; + nightOvertimeMinutes: string; + weekendOvertimeHours: string; + weekendOvertimeMinutes: string; +} + +// 사유 등록 폼 데이터 +export interface ReasonFormData { + employeeId: string; + baseDate: string; + reasonType: ReasonType | ''; +} + +// 근태 정보 다이얼로그 Props +export interface AttendanceInfoDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + mode: 'create' | 'edit'; + attendance?: AttendanceRecord | null; + employees: { id: string; name: string; department: string; position: string; rank: string }[]; + onSave: (data: AttendanceFormData) => void; +} + +// 사유 정보 다이얼로그 Props +export interface ReasonInfoDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + employees: { id: string; name: string; department: string; position: string; rank: string }[]; + onSubmit: (data: ReasonFormData) => void; +} + +// 시간 옵션 생성 헬퍼 +export const HOUR_OPTIONS = Array.from({ length: 24 }, (_, i) => ({ + value: String(i), + label: `${i}시`, +})); + +export const MINUTE_OPTIONS = [ + { value: '0', label: '0분' }, + { value: '30', label: '30분' }, +]; + +// 연장 시간 옵션 (0-12시간) +export const OVERTIME_HOUR_OPTIONS = Array.from({ length: 13 }, (_, i) => ({ + value: String(i), + label: `${i}시간`, +})); \ No newline at end of file diff --git a/src/components/hr/DepartmentManagement/DepartmentDialog.tsx b/src/components/hr/DepartmentManagement/DepartmentDialog.tsx new file mode 100644 index 00000000..1d52664e --- /dev/null +++ b/src/components/hr/DepartmentManagement/DepartmentDialog.tsx @@ -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 ( + + + + {title} + + +
+
+ {/* 부모 부서 표시 (추가 모드일 때) */} + {mode === 'add' && parentDepartment && ( +
+ 상위 부서: {parentDepartment.name} +
+ )} + + {/* 부서명 입력 */} +
+ + setName(e.target.value)} + placeholder="부서명을 입력하세요" + autoFocus + /> +
+
+ + + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/hr/DepartmentManagement/DepartmentStats.tsx b/src/components/hr/DepartmentManagement/DepartmentStats.tsx new file mode 100644 index 00000000..95ad38f6 --- /dev/null +++ b/src/components/hr/DepartmentManagement/DepartmentStats.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { Card, CardContent } from '@/components/ui/card'; +import type { DepartmentStatsProps } from './types'; + +/** + * 전체 부서 카운트 카드 + */ +export function DepartmentStats({ totalCount }: DepartmentStatsProps) { + return ( + + +
전체 부서
+
{totalCount}개
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/hr/DepartmentManagement/DepartmentToolbar.tsx b/src/components/hr/DepartmentManagement/DepartmentToolbar.tsx new file mode 100644 index 00000000..adeda702 --- /dev/null +++ b/src/components/hr/DepartmentManagement/DepartmentToolbar.tsx @@ -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 ( +
+ {/* 검색창 */} +
+ + onSearchChange(e.target.value)} + className="pl-9" + /> +
+ + {/* 선택 카운트 + 버튼 */} +
+ + 총 {totalCount}건 {selectedCount > 0 && `| ${selectedCount}건 선택`} + + + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/hr/DepartmentManagement/DepartmentTree.tsx b/src/components/hr/DepartmentManagement/DepartmentTree.tsx new file mode 100644 index 00000000..78d7bbde --- /dev/null +++ b/src/components/hr/DepartmentManagement/DepartmentTree.tsx @@ -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 ( + + + {/* 테이블 헤더 */} +
+
+ + 부서명 +
+
작업
+
+ + {/* 트리 아이템 목록 */} +
+ {departments.map(department => ( + + ))} +
+ + {/* 빈 상태 */} + {departments.length === 0 && ( +
+ 등록된 부서가 없습니다 +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/hr/DepartmentManagement/DepartmentTreeItem.tsx b/src/components/hr/DepartmentManagement/DepartmentTreeItem.tsx new file mode 100644 index 00000000..25c53336 --- /dev/null +++ b/src/components/hr/DepartmentManagement/DepartmentTreeItem.tsx @@ -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 ( + <> + {/* 현재 행 */} +
+
+ {/* 펼침/접힘 버튼 */} + + + {/* 체크박스 */} + onToggleSelect(department.id)} + aria-label={`${department.name} 선택`} + /> + + {/* 부서명 */} + {department.name} +
+ + {/* 작업 버튼 (호버 시 표시) */} +
+ + + +
+
+ + {/* 하위 부서 (재귀) */} + {hasChildren && isExpanded && ( + <> + {department.children!.map(child => ( + + ))} + + )} + + ); +} \ No newline at end of file diff --git a/src/components/hr/DepartmentManagement/index.tsx b/src/components/hr/DepartmentManagement/index.tsx new file mode 100644 index 00000000..3f3144b7 --- /dev/null +++ b/src/components/hr/DepartmentManagement/index.tsx @@ -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(mockDepartments); + + // 선택 상태 + const [selectedIds, setSelectedIds] = useState>(new Set()); + + // 펼침 상태 (기본: 최상위만 펼침) + const [expandedIds, setExpandedIds] = useState>(new Set([1])); + + // 검색어 + const [searchQuery, setSearchQuery] = useState(''); + + // 다이얼로그 상태 + const [dialogOpen, setDialogOpen] = useState(false); + const [dialogMode, setDialogMode] = useState<'add' | 'edit'>('add'); + const [selectedDepartment, setSelectedDepartment] = useState(); + const [parentDepartment, setParentDepartment] = useState(); + + // 삭제 확인 다이얼로그 + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [departmentToDelete, setDepartmentToDelete] = useState(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): 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 ( + + + +
+ {/* 전체 부서 카운트 */} + + + {/* 검색 + 추가/삭제 버튼 */} + + + {/* 트리 테이블 */} + +
+ + {/* 추가/수정 다이얼로그 */} + + + {/* 삭제 확인 다이얼로그 */} + + + + 부서 삭제 + + {isBulkDelete + ? `선택한 부서 ${selectedIds.size}개를 삭제하시겠습니까?` + : `"${departmentToDelete?.name}" 부서를 삭제하시겠습니까?` + } +
+ + 삭제된 부서의 인원은 회사(기본) 인원으로 변경됩니다. + +
+
+ + 취소 + + 삭제 + + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/hr/DepartmentManagement/types.ts b/src/components/hr/DepartmentManagement/types.ts new file mode 100644 index 00000000..1ce17a86 --- /dev/null +++ b/src/components/hr/DepartmentManagement/types.ts @@ -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; + selectedIds: Set; + 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; + selectedIds: Set; + 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; +}; diff --git a/src/components/hr/EmployeeManagement/CSVUploadDialog.tsx b/src/components/hr/EmployeeManagement/CSVUploadDialog.tsx new file mode 100644 index 00000000..3c53267e --- /dev/null +++ b/src/components/hr/EmployeeManagement/CSVUploadDialog.tsx @@ -0,0 +1,277 @@ +'use client'; + +import { useState, useCallback, useRef } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Badge } from '@/components/ui/badge'; +import { Upload, FileSpreadsheet, AlertCircle, CheckCircle } from 'lucide-react'; +import type { Employee, CSVEmployeeRow, CSVValidationResult } from './types'; + +interface CSVUploadDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onUpload: (employees: Employee[]) => void; +} + +export function CSVUploadDialog({ + open, + onOpenChange, + onUpload, +}: CSVUploadDialogProps) { + const [file, setFile] = useState(null); + const [validationResults, setValidationResults] = useState([]); + const [isProcessing, setIsProcessing] = useState(false); + const fileInputRef = useRef(null); + + // 파일 선택 + const handleFileSelect = useCallback((e: React.ChangeEvent) => { + const selectedFile = e.target.files?.[0]; + if (selectedFile && selectedFile.type === 'text/csv') { + setFile(selectedFile); + processCSV(selectedFile); + } + }, []); + + // CSV 파싱 및 유효성 검사 + const processCSV = async (csvFile: File) => { + setIsProcessing(true); + try { + const text = await csvFile.text(); + const lines = text.split('\n').map(line => line.trim()).filter(line => line); + + if (lines.length < 2) { + setValidationResults([]); + return; + } + + // 헤더 파싱 + const headers = lines[0].split(',').map(h => h.trim()); + + // 데이터 파싱 및 유효성 검사 + const results: CSVValidationResult[] = lines.slice(1).map((line, index) => { + const values = line.split(',').map(v => v.trim()); + const data: CSVEmployeeRow = { + name: values[headers.indexOf('이름')] || values[headers.indexOf('name')] || '', + phone: values[headers.indexOf('휴대폰')] || values[headers.indexOf('phone')] || undefined, + email: values[headers.indexOf('이메일')] || values[headers.indexOf('email')] || undefined, + departmentName: values[headers.indexOf('부서')] || values[headers.indexOf('department')] || undefined, + positionName: values[headers.indexOf('직책')] || values[headers.indexOf('position')] || undefined, + hireDate: values[headers.indexOf('입사일')] || values[headers.indexOf('hireDate')] || undefined, + status: values[headers.indexOf('상태')] || values[headers.indexOf('status')] || undefined, + }; + + // 유효성 검사 + const errors: string[] = []; + if (!data.name) { + errors.push('이름은 필수입니다'); + } + if (data.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) { + errors.push('이메일 형식이 올바르지 않습니다'); + } + if (data.phone && !/^\d{3}-\d{4}-\d{4}$/.test(data.phone)) { + errors.push('휴대폰 형식이 올바르지 않습니다 (000-0000-0000)'); + } + + return { + row: index + 2, // 1-indexed, 헤더 제외 + data, + isValid: errors.length === 0, + errors, + }; + }); + + setValidationResults(results); + } catch { + console.error('CSV 파싱 오류'); + } finally { + setIsProcessing(false); + } + }; + + // 업로드 실행 + const handleUpload = () => { + const validRows = validationResults.filter(r => r.isValid); + const employees: Employee[] = validRows.map((r, index) => ({ + id: String(Date.now() + index), + name: r.data.name, + phone: r.data.phone, + email: r.data.email, + status: (r.data.status === '재직' || r.data.status === 'active') ? 'active' : + (r.data.status === '휴직' || r.data.status === 'leave') ? 'leave' : + (r.data.status === '퇴직' || r.data.status === 'resigned') ? 'resigned' : 'active', + hireDate: r.data.hireDate, + departmentPositions: r.data.departmentName ? [{ + id: String(Date.now() + index), + departmentId: '', + departmentName: r.data.departmentName, + positionId: '', + positionName: r.data.positionName || '', + }] : [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + })); + + onUpload(employees); + handleReset(); + }; + + // 초기화 + const handleReset = () => { + setFile(null); + setValidationResults([]); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const validCount = validationResults.filter(r => r.isValid).length; + const invalidCount = validationResults.filter(r => !r.isValid).length; + + return ( + { if (!open) handleReset(); onOpenChange(open); }}> + + + CSV 일괄등록 + + CSV 파일을 업로드하여 사원을 일괄 등록합니다 + + + +
+ {/* 파일 업로드 영역 */} + {!file && ( + + +
+ +
+

+ CSV 파일을 드래그하거나 클릭하여 업로드 +

+

+ 필수 컬럼: 이름 | 선택 컬럼: 휴대폰, 이메일, 부서, 직책, 입사일, 상태 +

+
+ + +
+
+
+ )} + + {/* 파일 정보 및 미리보기 */} + {file && ( + <> +
+
+ + {file.name} +
+
+
+ + 유효: {validCount}건 +
+ {invalidCount > 0 && ( +
+ + 오류: {invalidCount}건 +
+ )} + +
+
+ + {/* 미리보기 테이블 */} + {validationResults.length > 0 && ( +
+ + + + + 상태 + 이름 + 휴대폰 + 이메일 + 부서 + 직책 + 오류 + + + + {validationResults.map((result) => ( + + {result.row} + + {result.isValid ? ( + 유효 + ) : ( + 오류 + )} + + {result.data.name || '-'} + {result.data.phone || '-'} + {result.data.email || '-'} + {result.data.departmentName || '-'} + {result.data.positionName || '-'} + + {result.errors.join(', ')} + + + ))} + +
+
+ )} + + )} +
+ + + + + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/hr/EmployeeManagement/CSVUploadPage.tsx b/src/components/hr/EmployeeManagement/CSVUploadPage.tsx new file mode 100644 index 00000000..1010e0b0 --- /dev/null +++ b/src/components/hr/EmployeeManagement/CSVUploadPage.tsx @@ -0,0 +1,354 @@ +'use client'; + +import { useState, useCallback, useRef } from 'react'; +import { useRouter } from 'next/navigation'; +import { PageLayout } from '@/components/organisms/PageLayout'; +import { PageHeader } from '@/components/organisms/PageHeader'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { X, Users, Download } from 'lucide-react'; +import type { Employee, CSVEmployeeRow, CSVValidationResult } from './types'; + +interface CSVUploadPageProps { + onUpload: (employees: Employee[]) => void; +} + +export function CSVUploadPage({ onUpload }: CSVUploadPageProps) { + const router = useRouter(); + const [file, setFile] = useState(null); + const [validationResults, setValidationResults] = useState([]); + const [isProcessing, setIsProcessing] = useState(false); + const [selectedRows, setSelectedRows] = useState>(new Set()); + const fileInputRef = useRef(null); + + // 파일 선택 + const handleFileSelect = useCallback((e: React.ChangeEvent) => { + const selectedFile = e.target.files?.[0]; + if (selectedFile) { + setFile(selectedFile); + setValidationResults([]); + setSelectedRows(new Set()); + } + }, []); + + // 파일 제거 + const handleRemoveFile = useCallback(() => { + setFile(null); + setValidationResults([]); + setSelectedRows(new Set()); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }, []); + + // 파일변환 (CSV 파싱) + const handleConvert = async () => { + if (!file) return; + + setIsProcessing(true); + try { + const text = await file.text(); + const lines = text.split('\n').map(line => line.trim()).filter(line => line); + + if (lines.length < 2) { + setValidationResults([]); + return; + } + + // 헤더 파싱 + const headers = lines[0].split(',').map(h => h.trim()); + + // 데이터 파싱 및 유효성 검사 + const results: CSVValidationResult[] = lines.slice(1).map((line, index) => { + const values = line.split(',').map(v => v.trim()); + const data: CSVEmployeeRow = { + name: values[headers.indexOf('이름')] || values[headers.indexOf('name')] || '', + phone: values[headers.indexOf('휴대폰')] || values[headers.indexOf('phone')] || undefined, + email: values[headers.indexOf('이메일')] || values[headers.indexOf('email')] || undefined, + departmentName: values[headers.indexOf('부서')] || values[headers.indexOf('department')] || undefined, + positionName: values[headers.indexOf('직책')] || values[headers.indexOf('position')] || undefined, + hireDate: values[headers.indexOf('입사일')] || values[headers.indexOf('hireDate')] || undefined, + status: values[headers.indexOf('상태')] || values[headers.indexOf('status')] || undefined, + }; + + // 유효성 검사 + const errors: string[] = []; + if (!data.name) { + errors.push('이름은 필수입니다'); + } + if (data.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) { + errors.push('이메일 형식이 올바르지 않습니다'); + } + if (data.phone && !/^\d{3}-\d{4}-\d{4}$/.test(data.phone)) { + errors.push('휴대폰 형식이 올바르지 않습니다 (000-0000-0000)'); + } + + return { + row: index + 2, + data, + isValid: errors.length === 0, + errors, + }; + }); + + setValidationResults(results); + } catch { + console.error('CSV 파싱 오류'); + } finally { + setIsProcessing(false); + } + }; + + // 행 선택 + const handleSelectRow = useCallback((rowIndex: number, checked: boolean) => { + setSelectedRows(prev => { + const newSet = new Set(prev); + if (checked) { + newSet.add(rowIndex); + } else { + newSet.delete(rowIndex); + } + return newSet; + }); + }, []); + + // 전체 선택 + const handleSelectAll = useCallback((checked: boolean) => { + if (checked) { + const validIndices = validationResults + .filter(r => r.isValid) + .map((_, index) => index); + setSelectedRows(new Set(validIndices)); + } else { + setSelectedRows(new Set()); + } + }, [validationResults]); + + // 양식 다운로드 + const handleDownloadTemplate = () => { + const headers = ['이름', '휴대폰', '이메일', '부서', '직책', '입사일', '상태']; + const sampleData = ['홍길동', '010-1234-5678', 'hong@company.com', '개발팀', '팀원', '2024-01-01', '재직']; + const csv = [headers.join(','), sampleData.join(',')].join('\n'); + + const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = '사원등록_양식.csv'; + link.click(); + }; + + // 업로드 실행 + const handleUpload = () => { + const selectedResults = validationResults.filter((_, index) => selectedRows.has(index)); + const employees: Employee[] = selectedResults.map((r, index) => ({ + id: String(Date.now() + index), + name: r.data.name, + phone: r.data.phone, + email: r.data.email, + status: (r.data.status === '재직' || r.data.status === 'active') ? 'active' : + (r.data.status === '휴직' || r.data.status === 'leave') ? 'leave' : + (r.data.status === '퇴직' || r.data.status === 'resigned') ? 'resigned' : 'active', + hireDate: r.data.hireDate, + departmentPositions: r.data.departmentName ? [{ + id: String(Date.now() + index), + departmentId: '', + departmentName: r.data.departmentName, + positionId: '', + positionName: r.data.positionName || '', + }] : [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + })); + + onUpload(employees); + router.push('/ko/hr/employee-management'); + }; + + const validCount = validationResults.filter(r => r.isValid).length; + const isAllSelected = validCount > 0 && selectedRows.size === validCount; + + return ( + + + +
+ {/* 일괄 등록 카드 */} + + + 일괄 등록 + + + {/* CSV 파일 선택 영역 */} +
+
+ CSV 파일 + CSV 파일 50MB 이하 가능 +
+
+ +
+ {/* 찾기 버튼 */} + + + + {/* 파일명 표시 */} + {file && ( +
+ {file.name} + +
+ )} + + {/* 양식 다운로드 버튼 */} + +
+ + {/* 파일변환 버튼 */} + +
+
+ + {/* 테이블 상단 정보 */} + {validationResults.length > 0 && ( +
+ + 총 {validationResults.length}건 + + {selectedRows.size > 0 && ( + + {selectedRows.size}건 선택 + + )} +
+ )} + + {/* 데이터 테이블 */} + + +
+ + + + + + + 번호 + 이름 + 휴대폰 + 이메일 + 부서 + 직책 + 입사일 + 상태 + 오류 + + + + {validationResults.length === 0 ? ( + + + 파일 선택 및 파일 변환이 필요합니다. + + + ) : ( + validationResults.map((result, index) => ( + + + handleSelectRow(index, !!checked)} + disabled={!result.isValid} + /> + + + {validationResults.length - index} + + {result.data.name || '-'} + {result.data.phone || '-'} + {result.data.email || '-'} + {result.data.departmentName || '-'} + {result.data.positionName || '-'} + {result.data.hireDate || '-'} + {result.data.status || '-'} + + {result.errors.length > 0 ? ( + + {result.errors.join(', ')} + + ) : ( + 유효 + )} + + + )) + )} + +
+
+
+
+ + {/* 등록 버튼 */} + +
+
+ ); +} diff --git a/src/components/hr/EmployeeManagement/EmployeeDetail.tsx b/src/components/hr/EmployeeManagement/EmployeeDetail.tsx new file mode 100644 index 00000000..593121bf --- /dev/null +++ b/src/components/hr/EmployeeManagement/EmployeeDetail.tsx @@ -0,0 +1,203 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { PageLayout } from '@/components/organisms/PageLayout'; +import { PageHeader } from '@/components/organisms/PageHeader'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; +import { Users, ArrowLeft, Edit, Trash2 } from 'lucide-react'; +import type { Employee } from './types'; +import { + EMPLOYEE_STATUS_LABELS, + EMPLOYEE_STATUS_COLORS, + EMPLOYMENT_TYPE_LABELS, + GENDER_LABELS, + USER_ROLE_LABELS, + USER_ACCOUNT_STATUS_LABELS, +} from './types'; + +interface EmployeeDetailProps { + employee: Employee; + onEdit: () => void; + onDelete: () => void; +} + +export function EmployeeDetail({ employee, onEdit, onDelete }: EmployeeDetailProps) { + const router = useRouter(); + + const handleBack = () => { + router.push('/ko/hr/employee-management'); + }; + + return ( + + + +
+ {/* 기본 정보 */} + + + 기본 정보 + + {EMPLOYEE_STATUS_LABELS[employee.status]} + + + +
+
+
이름
+
{employee.name}
+
+ {employee.employeeCode && ( +
+
사원코드
+
{employee.employeeCode}
+
+ )} + {employee.residentNumber && ( +
+
주민등록번호
+
{employee.residentNumber}
+
+ )} + {employee.gender && ( +
+
성별
+
{GENDER_LABELS[employee.gender]}
+
+ )} + {employee.phone && ( +
+
휴대폰
+
{employee.phone}
+
+ )} + {employee.email && ( +
+
이메일
+
{employee.email}
+
+ )} + {employee.salary && ( +
+
연봉
+
{employee.salary.toLocaleString()}원
+
+ )} + {employee.bankAccount && ( +
+
급여계좌
+
+ {employee.bankAccount.bankName} {employee.bankAccount.accountNumber} ({employee.bankAccount.accountHolder}) +
+
+ )} + {employee.address && ( +
+
주소
+
+ ({employee.address.zipCode}) {employee.address.address1} {employee.address.address2} +
+
+ )} +
+
+
+ + {/* 인사 정보 */} + + + 인사 정보 + + +
+ {employee.hireDate && ( +
+
입사일
+
{new Date(employee.hireDate).toLocaleDateString('ko-KR')}
+
+ )} + {employee.employmentType && ( +
+
고용형태
+
{EMPLOYMENT_TYPE_LABELS[employee.employmentType]}
+
+ )} + {employee.rank && ( +
+
직급
+
{employee.rank}
+
+ )} + {employee.departmentPositions.length > 0 && ( +
+
부서/직책
+
+
+ {employee.departmentPositions.map((dp) => ( +
+ {dp.departmentName} + - + {dp.positionName} +
+ ))} +
+
+
+ )} +
+
+
+ + {/* 사용자 정보 */} + {employee.userInfo && ( + + + 사용자 정보 + + +
+
+
아이디
+
{employee.userInfo.userId}
+
+
+
권한
+
{USER_ROLE_LABELS[employee.userInfo.role]}
+
+
+
계정상태
+
{USER_ACCOUNT_STATUS_LABELS[employee.userInfo.accountStatus]}
+
+
+
+
+ )} + + {/* 버튼 영역 */} +
+ +
+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/hr/EmployeeManagement/EmployeeDialog.tsx b/src/components/hr/EmployeeManagement/EmployeeDialog.tsx new file mode 100644 index 00000000..489a23d3 --- /dev/null +++ b/src/components/hr/EmployeeManagement/EmployeeDialog.tsx @@ -0,0 +1,573 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Separator } from '@/components/ui/separator'; +import { Switch } from '@/components/ui/switch'; +import { Plus, Trash2 } from 'lucide-react'; +import type { + EmployeeDialogProps, + EmployeeFormData, + DepartmentPosition, +} from './types'; +import { + EMPLOYMENT_TYPE_LABELS, + GENDER_LABELS, + USER_ROLE_LABELS, + USER_ACCOUNT_STATUS_LABELS, + EMPLOYEE_STATUS_LABELS, +} from './types'; + +const initialFormData: EmployeeFormData = { + name: '', + residentNumber: '', + phone: '', + email: '', + salary: '', + bankAccount: { bankName: '', accountNumber: '', accountHolder: '' }, + profileImage: '', + employeeCode: '', + gender: '', + address: { zipCode: '', address1: '', address2: '' }, + hireDate: '', + employmentType: '', + rank: '', + status: 'active', + departmentPositions: [], + hasUserAccount: false, + userId: '', + password: '', + confirmPassword: '', + role: 'user', + accountStatus: 'active', +}; + +export function EmployeeDialog({ + open, + onOpenChange, + mode, + employee, + onSave, + fieldSettings, +}: EmployeeDialogProps) { + const [formData, setFormData] = useState(initialFormData); + + // 모드별 타이틀 + const title = { + create: '사원 등록', + edit: '사원 수정', + view: '사원 상세', + }[mode]; + + const isViewMode = mode === 'view'; + + // 데이터 초기화 + useEffect(() => { + if (open && employee && mode !== 'create') { + setFormData({ + name: employee.name, + residentNumber: employee.residentNumber || '', + phone: employee.phone || '', + email: employee.email || '', + salary: employee.salary?.toString() || '', + bankAccount: employee.bankAccount || { bankName: '', accountNumber: '', accountHolder: '' }, + profileImage: employee.profileImage || '', + employeeCode: employee.employeeCode || '', + gender: employee.gender || '', + address: employee.address || { zipCode: '', address1: '', address2: '' }, + hireDate: employee.hireDate || '', + employmentType: employee.employmentType || '', + rank: employee.rank || '', + status: employee.status, + departmentPositions: employee.departmentPositions || [], + hasUserAccount: !!employee.userInfo, + userId: employee.userInfo?.userId || '', + password: '', + confirmPassword: '', + role: employee.userInfo?.role || 'user', + accountStatus: employee.userInfo?.accountStatus || 'active', + }); + } else if (open && mode === 'create') { + setFormData(initialFormData); + } + }, [open, employee, mode]); + + // 입력 변경 핸들러 + const handleChange = (field: keyof EmployeeFormData, value: unknown) => { + setFormData(prev => ({ ...prev, [field]: value })); + }; + + // 부서/직책 추가 + const handleAddDepartmentPosition = () => { + const newDP: DepartmentPosition = { + id: String(Date.now()), + departmentId: '', + departmentName: '', + positionId: '', + positionName: '', + }; + setFormData(prev => ({ + ...prev, + departmentPositions: [...prev.departmentPositions, newDP], + })); + }; + + // 부서/직책 삭제 + const handleRemoveDepartmentPosition = (id: string) => { + setFormData(prev => ({ + ...prev, + departmentPositions: prev.departmentPositions.filter(dp => dp.id !== id), + })); + }; + + // 부서/직책 변경 + const handleDepartmentPositionChange = (id: string, field: keyof DepartmentPosition, value: string) => { + setFormData(prev => ({ + ...prev, + departmentPositions: prev.departmentPositions.map(dp => + dp.id === id ? { ...dp, [field]: value } : dp + ), + })); + }; + + // 저장 + const handleSubmit = () => { + onSave(formData); + }; + + return ( + + + + {title} + + {mode === 'create' ? '새로운 사원 정보를 입력합니다' : '사원 정보를 확인/수정합니다'} + + + +
+ {/* 기본 사원정보 */} +
+

기본 정보

+ +
+
+ + handleChange('name', e.target.value)} + disabled={isViewMode} + placeholder="이름을 입력하세요" + /> +
+ +
+ + handleChange('residentNumber', e.target.value)} + disabled={isViewMode} + placeholder="000000-0000000" + /> +
+ +
+ + handleChange('phone', e.target.value)} + disabled={isViewMode} + placeholder="010-0000-0000" + /> +
+ +
+ + handleChange('email', e.target.value)} + disabled={isViewMode} + placeholder="email@company.com" + /> +
+ +
+ + handleChange('salary', e.target.value)} + disabled={isViewMode} + placeholder="연봉 (원)" + /> +
+
+ + {/* 급여 계좌 */} +
+ +
+ handleChange('bankAccount', { ...formData.bankAccount, bankName: e.target.value })} + disabled={isViewMode} + placeholder="은행명" + /> + handleChange('bankAccount', { ...formData.bankAccount, accountNumber: e.target.value })} + disabled={isViewMode} + placeholder="계좌번호" + /> + handleChange('bankAccount', { ...formData.bankAccount, accountHolder: e.target.value })} + disabled={isViewMode} + placeholder="예금주" + /> +
+
+
+ + + + {/* 선택적 필드 (설정에 따라 표시) */} + {(fieldSettings.showEmployeeCode || fieldSettings.showGender || fieldSettings.showAddress) && ( + <> +
+

추가 정보

+ +
+ {fieldSettings.showEmployeeCode && ( +
+ + handleChange('employeeCode', e.target.value)} + disabled={isViewMode} + placeholder="자동생성 또는 직접입력" + /> +
+ )} + + {fieldSettings.showGender && ( +
+ + +
+ )} +
+ + {fieldSettings.showAddress && ( +
+ +
+ handleChange('address', { ...formData.address, zipCode: e.target.value })} + disabled={isViewMode} + placeholder="우편번호" + className="w-32" + /> + +
+ handleChange('address', { ...formData.address, address1: e.target.value })} + disabled={isViewMode} + placeholder="기본주소" + /> + handleChange('address', { ...formData.address, address2: e.target.value })} + disabled={isViewMode} + placeholder="상세주소" + /> +
+ )} +
+ + + + )} + + {/* 인사 정보 */} +
+

인사 정보

+ +
+ {fieldSettings.showHireDate && ( +
+ + handleChange('hireDate', e.target.value)} + disabled={isViewMode} + /> +
+ )} + + {fieldSettings.showEmploymentType && ( +
+ + +
+ )} + + {fieldSettings.showRank && ( +
+ + handleChange('rank', e.target.value)} + disabled={isViewMode} + placeholder="직급 입력" + /> +
+ )} + + {fieldSettings.showStatus && ( +
+ + +
+ )} +
+ + {/* 부서/직책 (복수 가능) */} + {(fieldSettings.showDepartment || fieldSettings.showPosition) && ( +
+
+ + {!isViewMode && ( + + )} +
+ + {formData.departmentPositions.length === 0 ? ( +

부서/직책을 추가해주세요

+ ) : ( +
+ {formData.departmentPositions.map((dp) => ( +
+ handleDepartmentPositionChange(dp.id, 'departmentName', e.target.value)} + disabled={isViewMode} + placeholder="부서명" + className="flex-1" + /> + handleDepartmentPositionChange(dp.id, 'positionName', e.target.value)} + disabled={isViewMode} + placeholder="직책" + className="flex-1" + /> + {!isViewMode && ( + + )} +
+ ))} +
+ )} +
+ )} +
+ + + + {/* 사용자 정보 */} +
+
+

사용자 정보

+ {!isViewMode && ( +
+ handleChange('hasUserAccount', checked)} + /> + +
+ )} +
+ + {(formData.hasUserAccount || (isViewMode && employee?.userInfo)) && ( +
+
+ + handleChange('userId', e.target.value)} + disabled={isViewMode} + placeholder="사용자 아이디" + /> +
+ + {!isViewMode && mode === 'create' && ( + <> +
+ + handleChange('password', e.target.value)} + placeholder="비밀번호" + /> +
+ +
+ + handleChange('confirmPassword', e.target.value)} + placeholder="비밀번호 확인" + /> +
+ + )} + +
+ + +
+ +
+ + +
+
+ )} +
+
+ + + + {!isViewMode && ( + + )} + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/hr/EmployeeManagement/EmployeeForm.tsx b/src/components/hr/EmployeeManagement/EmployeeForm.tsx new file mode 100644 index 00000000..4b28bc53 --- /dev/null +++ b/src/components/hr/EmployeeManagement/EmployeeForm.tsx @@ -0,0 +1,628 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { useRouter } from 'next/navigation'; +import Image from 'next/image'; +import { PageLayout } from '@/components/organisms/PageLayout'; +import { PageHeader } from '@/components/organisms/PageHeader'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import { Users, Plus, Trash2, ArrowLeft, Save, Camera, User } from 'lucide-react'; +import type { + Employee, + EmployeeFormData, + DepartmentPosition, + FieldSettings, +} from './types'; +import { + EMPLOYMENT_TYPE_LABELS, + GENDER_LABELS, + USER_ROLE_LABELS, + USER_ACCOUNT_STATUS_LABELS, + EMPLOYEE_STATUS_LABELS, + DEFAULT_FIELD_SETTINGS, +} from './types'; + +interface EmployeeFormProps { + mode: 'create' | 'edit'; + employee?: Employee; + onSave: (data: EmployeeFormData) => void; + fieldSettings?: FieldSettings; +} + +const initialFormData: EmployeeFormData = { + name: '', + residentNumber: '', + phone: '', + email: '', + salary: '', + bankAccount: { bankName: '', accountNumber: '', accountHolder: '' }, + profileImage: '', + employeeCode: '', + gender: '', + address: { zipCode: '', address1: '', address2: '' }, + hireDate: '', + employmentType: '', + rank: '', + status: 'active', + departmentPositions: [], + hasUserAccount: false, + userId: '', + password: '', + confirmPassword: '', + role: 'user', + accountStatus: 'active', +}; + +export function EmployeeForm({ + mode, + employee, + onSave, + fieldSettings = DEFAULT_FIELD_SETTINGS, +}: EmployeeFormProps) { + const router = useRouter(); + const [formData, setFormData] = useState(initialFormData); + const [previewImage, setPreviewImage] = useState(null); + const fileInputRef = useRef(null); + + const title = mode === 'create' ? '사원 등록' : '사원 수정'; + + // 데이터 초기화 + useEffect(() => { + if (employee && mode === 'edit') { + setFormData({ + name: employee.name, + residentNumber: employee.residentNumber || '', + phone: employee.phone || '', + email: employee.email || '', + salary: employee.salary?.toString() || '', + bankAccount: employee.bankAccount || { bankName: '', accountNumber: '', accountHolder: '' }, + profileImage: employee.profileImage || '', + employeeCode: employee.employeeCode || '', + gender: employee.gender || '', + address: employee.address || { zipCode: '', address1: '', address2: '' }, + hireDate: employee.hireDate || '', + employmentType: employee.employmentType || '', + rank: employee.rank || '', + status: employee.status, + departmentPositions: employee.departmentPositions || [], + hasUserAccount: !!employee.userInfo, + userId: employee.userInfo?.userId || '', + password: '', + confirmPassword: '', + role: employee.userInfo?.role || 'user', + accountStatus: employee.userInfo?.accountStatus || 'active', + }); + } + }, [employee, mode]); + + // 입력 변경 핸들러 + const handleChange = (field: keyof EmployeeFormData, value: unknown) => { + setFormData(prev => ({ ...prev, [field]: value })); + }; + + // 부서/직책 추가 + const handleAddDepartmentPosition = () => { + const newDP: DepartmentPosition = { + id: String(Date.now()), + departmentId: '', + departmentName: '', + positionId: '', + positionName: '', + }; + setFormData(prev => ({ + ...prev, + departmentPositions: [...prev.departmentPositions, newDP], + })); + }; + + // 부서/직책 삭제 + const handleRemoveDepartmentPosition = (id: string) => { + setFormData(prev => ({ + ...prev, + departmentPositions: prev.departmentPositions.filter(dp => dp.id !== id), + })); + }; + + // 부서/직책 변경 + const handleDepartmentPositionChange = (id: string, field: keyof DepartmentPosition, value: string) => { + setFormData(prev => ({ + ...prev, + departmentPositions: prev.departmentPositions.map(dp => + dp.id === id ? { ...dp, [field]: value } : dp + ), + })); + }; + + // 저장 + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSave(formData); + }; + + // 취소 + const handleCancel = () => { + router.back(); + }; + + // 프로필 이미지 업로드 핸들러 + const handleImageUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onloadend = () => { + setPreviewImage(reader.result as string); + handleChange('profileImage', reader.result as string); + }; + reader.readAsDataURL(file); + } + }; + + // 프로필 이미지 삭제 핸들러 + const handleRemoveImage = () => { + setPreviewImage(null); + handleChange('profileImage', ''); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + return ( + + + +
+ {/* 사원 정보 - 프로필 사진 + 기본 정보 */} + + + 사원 정보 + + +
+ {/* 프로필 사진 영역 */} + {fieldSettings.showProfileImage && ( +
+
+ {previewImage || formData.profileImage ? ( + 프로필 사진 + ) : ( + + )} +
+
+ + + {(previewImage || formData.profileImage) && ( + + )} +
+
+ )} + + {/* 기본 정보 필드들 */} +
+
+ + handleChange('name', e.target.value)} + placeholder="이름을 입력하세요" + required + /> +
+ +
+ + handleChange('residentNumber', e.target.value)} + placeholder="000000-0000000" + /> +
+ +
+ + handleChange('phone', e.target.value)} + placeholder="010-0000-0000" + /> +
+ +
+ + handleChange('email', e.target.value)} + placeholder="email@company.com" + /> +
+ +
+ + handleChange('salary', e.target.value)} + placeholder="연봉 (원)" + /> +
+
+
+ + {/* 급여 계좌 */} +
+ +
+ handleChange('bankAccount', { ...formData.bankAccount, bankName: e.target.value })} + placeholder="은행명" + /> + handleChange('bankAccount', { ...formData.bankAccount, accountNumber: e.target.value })} + placeholder="계좌번호" + /> + handleChange('bankAccount', { ...formData.bankAccount, accountHolder: e.target.value })} + placeholder="예금주" + /> +
+
+
+
+ + {/* 선택 정보 (사원 상세) */} + {(fieldSettings.showEmployeeCode || fieldSettings.showGender || fieldSettings.showAddress) && ( + + + 선택 정보 + + +
+ {fieldSettings.showEmployeeCode && ( +
+ + handleChange('employeeCode', e.target.value)} + placeholder="자동생성 또는 직접입력" + /> +
+ )} + + {fieldSettings.showGender && ( +
+ + +
+ )} +
+ + {fieldSettings.showAddress && ( +
+ +
+ handleChange('address', { ...formData.address, zipCode: e.target.value })} + placeholder="우편번호" + className="w-32" + /> + +
+ handleChange('address', { ...formData.address, address1: e.target.value })} + placeholder="기본주소" + /> + handleChange('address', { ...formData.address, address2: e.target.value })} + placeholder="상세주소" + /> +
+ )} +
+
+ )} + + {/* 인사 정보 */} + + + 인사 정보 + + +
+ {fieldSettings.showHireDate && ( +
+ + handleChange('hireDate', e.target.value)} + /> +
+ )} + + {fieldSettings.showEmploymentType && ( +
+ + +
+ )} + + {fieldSettings.showRank && ( +
+ + handleChange('rank', e.target.value)} + placeholder="직급 입력" + /> +
+ )} + + {fieldSettings.showStatus && ( +
+ + +
+ )} +
+ + {/* 부서/직책 */} + {(fieldSettings.showDepartment || fieldSettings.showPosition) && ( +
+
+ + +
+ + {formData.departmentPositions.length === 0 ? ( +

+ 부서/직책을 추가해주세요 +

+ ) : ( +
+ {formData.departmentPositions.map((dp) => ( +
+ handleDepartmentPositionChange(dp.id, 'departmentName', e.target.value)} + placeholder="부서명" + className="flex-1" + /> + handleDepartmentPositionChange(dp.id, 'positionName', e.target.value)} + placeholder="직책" + className="flex-1" + /> + +
+ ))} +
+ )} +
+ )} +
+
+ + {/* 사용자 정보 */} + + +
+ 사용자 정보 +
+ handleChange('hasUserAccount', checked)} + className="data-[state=checked]:bg-white data-[state=checked]:text-black" + /> + +
+
+
+ {formData.hasUserAccount && ( + +
+
+ + handleChange('userId', e.target.value)} + placeholder="사용자 아이디" + required={formData.hasUserAccount} + /> +
+ + {mode === 'create' && ( + <> +
+ + handleChange('password', e.target.value)} + placeholder="비밀번호" + required={formData.hasUserAccount} + /> +
+ +
+ + handleChange('confirmPassword', e.target.value)} + placeholder="비밀번호 확인" + /> +
+ + )} + +
+ + +
+ +
+ + +
+
+
+ )} +
+ + {/* 버튼 영역 */} +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/hr/EmployeeManagement/EmployeeToolbar.tsx b/src/components/hr/EmployeeManagement/EmployeeToolbar.tsx new file mode 100644 index 00000000..e229e5c9 --- /dev/null +++ b/src/components/hr/EmployeeManagement/EmployeeToolbar.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { Calendar, FileSpreadsheet, UserPlus, Mail, Settings } from 'lucide-react'; + +interface EmployeeToolbarProps { + dateRange: { from?: Date; to?: Date }; + onDateRangeChange: (range: { from?: Date; to?: Date }) => void; + onAddEmployee: () => void; + onCSVUpload: () => void; + onUserInvite: () => void; + onFieldSettings: () => void; +} + +export function EmployeeToolbar({ + dateRange, + onDateRangeChange, + onAddEmployee, + onCSVUpload, + onUserInvite, + onFieldSettings, +}: EmployeeToolbarProps) { + return ( + + +
+ {/* 날짜 필터 */} +
+ + 기간: + +
+ + {/* 액션 버튼들 */} +
+ + + + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/hr/EmployeeManagement/FieldSettingsDialog.tsx b/src/components/hr/EmployeeManagement/FieldSettingsDialog.tsx new file mode 100644 index 00000000..9388b46f --- /dev/null +++ b/src/components/hr/EmployeeManagement/FieldSettingsDialog.tsx @@ -0,0 +1,223 @@ +'use client'; + +import { useState, useEffect, useMemo } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Card } from '@/components/ui/card'; +import type { FieldSettings } from './types'; + +interface FieldSettingsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + settings: FieldSettings; + onSave: (settings: FieldSettings) => void; +} + +export function FieldSettingsDialog({ + open, + onOpenChange, + settings, + onSave, +}: FieldSettingsDialogProps) { + const [localSettings, setLocalSettings] = useState(settings); + + useEffect(() => { + if (open) { + setLocalSettings(settings); + } + }, [open, settings]); + + const handleToggle = (key: keyof FieldSettings) => { + setLocalSettings(prev => ({ ...prev, [key]: !prev[key] })); + }; + + // 사원 상세 전체 토글 + const employeeDetailFields: (keyof FieldSettings)[] = [ + 'showProfileImage', 'showEmployeeCode', 'showGender', 'showAddress' + ]; + const isAllEmployeeDetailOn = useMemo(() => + employeeDetailFields.every(key => localSettings[key]), + [localSettings] + ); + const handleToggleAllEmployeeDetail = (checked: boolean) => { + setLocalSettings(prev => { + const updated = { ...prev }; + employeeDetailFields.forEach(key => { + updated[key] = checked; + }); + return updated; + }); + }; + + // 인사 정보 전체 토글 + const hrInfoFields: (keyof FieldSettings)[] = [ + 'showHireDate', 'showEmploymentType', 'showRank', 'showStatus', 'showDepartment', 'showPosition' + ]; + const isAllHrInfoOn = useMemo(() => + hrInfoFields.every(key => localSettings[key]), + [localSettings] + ); + const handleToggleAllHrInfo = (checked: boolean) => { + setLocalSettings(prev => { + const updated = { ...prev }; + hrInfoFields.forEach(key => { + updated[key] = checked; + }); + return updated; + }); + }; + + const handleSave = () => { + onSave(localSettings); + onOpenChange(false); + }; + + return ( + + + + 항목 설정 + + +
+ {/* 사원 상세 섹션 */} + +
+ {/* 전체 토글 */} +
+ + +
+ + {/* 개별 항목들 */} +
+ + handleToggle('showProfileImage')} + /> +
+ +
+ + handleToggle('showEmployeeCode')} + /> +
+ +
+ + handleToggle('showGender')} + /> +
+ +
+ + handleToggle('showAddress')} + /> +
+
+
+ + {/* 인사 정보 섹션 */} + +
+ {/* 전체 토글 */} +
+ + +
+ + {/* 개별 항목들 */} +
+ + handleToggle('showHireDate')} + /> +
+ +
+ + handleToggle('showEmploymentType')} + /> +
+ +
+ + handleToggle('showRank')} + /> +
+ +
+ + handleToggle('showStatus')} + /> +
+ +
+ + handleToggle('showDepartment')} + /> +
+ +
+ + handleToggle('showPosition')} + /> +
+
+
+
+ + {/* 버튼 */} +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/hr/EmployeeManagement/UserInviteDialog.tsx b/src/components/hr/EmployeeManagement/UserInviteDialog.tsx new file mode 100644 index 00000000..a3d1042b --- /dev/null +++ b/src/components/hr/EmployeeManagement/UserInviteDialog.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import type { UserRole } from './types'; +import { USER_ROLE_LABELS } from './types'; + +interface UserInviteDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onInvite: (data: { email: string; role: UserRole; message?: string }) => void; +} + +export function UserInviteDialog({ open, onOpenChange, onInvite }: UserInviteDialogProps) { + const [email, setEmail] = useState(''); + const [role, setRole] = useState('user'); + const [message, setMessage] = useState(''); + + const handleSubmit = () => { + if (!email) return; + onInvite({ email, role, message: message || undefined }); + // Reset form + setEmail(''); + setRole('user'); + setMessage(''); + }; + + const handleCancel = () => { + setEmail(''); + setRole('user'); + setMessage(''); + onOpenChange(false); + }; + + return ( + + + + 사용자 초대 + + +
+ {/* 이메일 주소 */} +
+ + setEmail(e.target.value)} + /> +
+ + {/* 권한 */} +
+ + +
+ + {/* 초대 메시지 (선택) */} +
+ +