docs: 문서 인덱스 및 가이드 업데이트
- INDEX.md 문서 구조 업데이트 - front/item-master-guide.md 프론트엔드 API 가이드 업데이트 - plans/hr-api-react-sync-plan.md 삭제 (폴더 재구성)
This commit is contained in:
36
INDEX.md
36
INDEX.md
@@ -77,7 +77,10 @@ docs/
|
||||
|------|------|--------------|
|
||||
| [database-schema.md](specs/database-schema.md) | DB 구조 및 관계도 | DB 변경 전 |
|
||||
| [board-system-spec.md](specs/board-system-spec.md) | 게시판 시스템 설계 | 게시판 작업 전 |
|
||||
| [item-master-integration.md](specs/item-master-integration.md) | 품목관리 통합 스펙 | 품목 관련 작업 전 |
|
||||
| [**ITEM-MASTER-INDEX.md**](specs/ITEM-MASTER-INDEX.md) | **품목관리 문서 인덱스 (개발 현황)** | **품목 작업 전 필수** |
|
||||
| [item-master-integration.md](specs/item-master-integration.md) | 품목관리 연동 설계 | 품목 연동 구현 시 |
|
||||
| [item-master-field-key-validation.md](specs/item-master-field-key-validation.md) | ItemMaster 필드 키 검증 | 품목 필드 작업 전 |
|
||||
| [item-master-field-integration.md](specs/item-master-field-integration.md) | ItemMaster 필드 통합 계획 | 품목 필드 통합 시 |
|
||||
| [docker-setup.md](specs/docker-setup.md) | Docker 환경 구성 | 환경 설정 시 |
|
||||
| [remote-work-setup.md](specs/remote-work-setup.md) | 원격 개발 설정 | 원격 작업 시 |
|
||||
|
||||
@@ -106,12 +109,22 @@ docs/
|
||||
|------|------|
|
||||
| [item-master-guide.md](front/item-master-guide.md) | 품목기준관리 페이지-섹션-필드 구조 |
|
||||
|
||||
> 날짜별 API 요청 문서는 `history/2025-11/front-requests/`로 이동됨
|
||||
|
||||
### data/ - 데이터 분석
|
||||
> 시스템 분석, 데이터 모델링
|
||||
|
||||
| 문서 | 설명 |
|
||||
|------|------|
|
||||
| [analysis/item-db-analysis.md](data/analysis/item-db-analysis.md) | Item DB/API 분석 최종본 |
|
||||
|
||||
### features/ - 기능별 문서
|
||||
|
||||
| 문서 | 설명 |
|
||||
|------|------|
|
||||
| [boards/README.md](features/boards/README.md) | 게시판 시스템 구현 |
|
||||
| [boards/mng-implementation.md](features/boards/mng-implementation.md) | MNG 게시판 구현 상세 |
|
||||
| [hr/hr-api-analysis.md](features/hr/hr-api-analysis.md) | HR API 분석 (근태/직원/부서) |
|
||||
|
||||
### projects/ - 프로젝트별 문서
|
||||
|
||||
@@ -125,7 +138,7 @@ docs/
|
||||
|
||||
| 기간 | 문서 |
|
||||
|------|------|
|
||||
| **2025-11** | [item-master-gap-analysis.md](history/2025-11/item-master-gap-analysis.md), [item-master-spec.md](history/2025-11/item-master-spec.md) |
|
||||
| **2025-11** | [item-master-gap-analysis.md](history/2025-11/item-master-gap-analysis.md), [item-master-spec.md](history/2025-11/item-master-spec.md), [front-requests/](history/2025-11/front-requests/), [item-master-archived/](history/2025-11/item-master-archived/) |
|
||||
| **2025-09** | [checkpoint.md](history/2025-09/checkpoint.md), [database-schema.md](history/2025-09/database-schema.md) |
|
||||
| **Roadmaps** | [december-2025.md](history/roadmaps/december-2025.md) |
|
||||
|
||||
@@ -176,6 +189,25 @@ API Flow Tester에서 생성되는 JSON 파일 저장 경로
|
||||
|
||||
## 🔄 문서 구조 변경 이력
|
||||
|
||||
- **2025-12-09**: Item Master 문서 정리 및 인덱스 생성
|
||||
- `specs/ITEM-MASTER-INDEX.md` 생성 (개발 현황/필요 항목 정리)
|
||||
- `history/2025-11/item-master-archived/` 생성 (구버전 문서 아카이브)
|
||||
- 중복 문서 정리 (front-requests → history 이동)
|
||||
|
||||
- **2025-12-09**: 문서 정리 및 통합
|
||||
- 중복 분석 문서 삭제 (v2, DB_Modeling)
|
||||
- `SAM_Item_DB_API_Analysis_v3_FINAL.md` → `item-db-analysis.md` 리네임
|
||||
- `ITEM_MASTER_FIELD_INTEGRATION_PLAN.md` → `item-master-field-integration.md` 리네임
|
||||
- `HR_API_ANALYSIS.md` → `features/hr/hr-api-analysis.md` 이동
|
||||
- 날짜 접두사 front 문서 → `history/2025-11/front-requests/` 이동
|
||||
- api/docs에서 프로젝트 문서 분리 (swagger, api-flows만 유지)
|
||||
|
||||
- **2025-12-09**: api/docs 문서 통합
|
||||
- `api/docs/analysis/` → `docs/data/analysis/` 이동
|
||||
- `api/docs/front/` → `docs/front/` 병합
|
||||
- `api/docs/specs/` → `docs/specs/` 병합
|
||||
- api/docs에는 API 구성/설정 문서만 유지 (swagger, api-flows)
|
||||
|
||||
- **2025-12-09**: `plans/` 폴더 추가
|
||||
- 개발 계획 문서용 임시 폴더
|
||||
- 작업 완료 후 정리 → 삭제 워크플로우
|
||||
|
||||
@@ -1,70 +1,246 @@
|
||||
# 품목기준관리(ItemMaster) 프론트엔드 가이드
|
||||
# 품목기준관리(ItemMaster) API 가이드
|
||||
|
||||
> 📌 **품목설정 시스템의 구조, API, 잠금 기능에 대한 프론트엔드 개발 가이드**
|
||||
> 품목 입력 화면을 구성하는 **페이지-섹션-필드** 구조 관리 시스템
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요
|
||||
|
||||
품목기준관리(ItemMaster)는 제품의 입력 화면을 구성하는 **페이지-섹션-필드** 구조를 관리하는 시스템입니다.
|
||||
|
||||
### 1.1 핵심 개념
|
||||
|
||||
| 엔티티 | 설명 | 예시 |
|
||||
|--------|------|------|
|
||||
| **Page** | 품목 유형별 화면 (FG, PT, SM, RM, CS) | "완제품 기본정보", "부품 상세" |
|
||||
| **Section** | 페이지 내 논리적 영역 | "제품 상세", "BOM 정보" |
|
||||
| **Field** | 섹션 내 입력 항목 | "제품명", "규격", "단가" |
|
||||
| **BomItem** | BOM 섹션 내 부품 항목 | "부품A x 2개" |
|
||||
| 엔티티 | 테이블 | 설명 |
|
||||
|--------|--------|------|
|
||||
| **Page** | `item_pages` | 품목 유형별 화면 (FG, PT, SM, RM, CS) |
|
||||
| **Section** | `item_sections` | 페이지 내 논리적 영역 |
|
||||
| **Field** | `item_fields` | 섹션 내 입력 항목 |
|
||||
| **BomItem** | `item_bom_items` | BOM 섹션 내 부품 항목 |
|
||||
| **CustomTab** | `custom_tabs` | 커스텀 탭 설정 |
|
||||
| **UnitOption** | `unit_options` | 단위 옵션 |
|
||||
|
||||
### 1.2 아키텍처 특징
|
||||
### 1.2 아키텍처
|
||||
|
||||
- **독립 엔티티 구조**: 섹션, 필드, BOM은 독립적으로 존재하며 재사용 가능
|
||||
- **링크 테이블**: `entity_relationships`로 관계 관리
|
||||
- **연결 잠금**: 중요한 구조는 잠금으로 보호 가능
|
||||
|
||||
```
|
||||
ItemPage (item_type: FG, PT, SM, RM, CS)
|
||||
│
|
||||
│ entity_relationships (is_locked)
|
||||
▼
|
||||
ItemSection (type: default, bom, custom)
|
||||
│
|
||||
├─ entity_relationships → ItemField
|
||||
└─ entity_relationships → ItemBomItem
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 데이터 구조
|
||||
## 2. API 엔드포인트
|
||||
|
||||
### 2.1 엔티티 관계도
|
||||
### 2.1 초기화
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ItemPage │
|
||||
│ (id, page_name, item_type, is_active) │
|
||||
└─────────────┬──────────────────────────────────────────────────┘
|
||||
│ entity_relationships (is_locked)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ItemSection │
|
||||
│ (id, title, type, is_template, is_default) │
|
||||
└─────────────┬───────────────────────────────────┬──────────────┘
|
||||
│ entity_relationships │ entity_relationships
|
||||
│ (is_locked) │ (is_locked)
|
||||
▼ ▼
|
||||
┌─────────────────────────────┐ ┌─────────────────────────────┐
|
||||
│ ItemField │ │ ItemBomItem │
|
||||
│ (id, field_name, │ │ (id, item_code, │
|
||||
│ field_type, is_required) │ │ item_name, quantity) │
|
||||
└─────────────────────────────┘ └─────────────────────────────┘
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/v1/item-master/init` | 전체 데이터 로드 |
|
||||
|
||||
**응답 구조:**
|
||||
```json
|
||||
{
|
||||
"pages": [], // 페이지 + 연결된 섹션/필드
|
||||
"sections": [], // 모든 독립 섹션
|
||||
"fields": [], // 모든 독립 필드
|
||||
"customTabs": [], // 커스텀 탭
|
||||
"unitOptions": [] // 단위 옵션
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 entity_relationships 테이블
|
||||
### 2.2 페이지
|
||||
|
||||
모든 엔티티 간 관계는 `entity_relationships` 테이블로 관리됩니다.
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/v1/item-master/pages` | 페이지 목록 |
|
||||
| POST | `/api/v1/item-master/pages` | 페이지 생성 |
|
||||
| PUT | `/api/v1/item-master/pages/{id}` | 페이지 수정 |
|
||||
| DELETE | `/api/v1/item-master/pages/{id}` | 페이지 삭제 |
|
||||
|
||||
### 2.3 섹션
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/v1/item-master/sections` | 독립 섹션 목록 |
|
||||
| POST | `/api/v1/item-master/sections` | 독립 섹션 생성 |
|
||||
| POST | `/api/v1/item-master/pages/{pageId}/sections` | 섹션 생성 + 페이지 연결 |
|
||||
| PUT | `/api/v1/item-master/sections/{id}` | 섹션 수정 |
|
||||
| DELETE | `/api/v1/item-master/sections/{id}` | 섹션 삭제 |
|
||||
| POST | `/api/v1/item-master/sections/{id}/clone` | 섹션 복제 |
|
||||
| GET | `/api/v1/item-master/sections/{id}/usage` | 사용처 조회 |
|
||||
| PUT | `/api/v1/item-master/pages/{pageId}/sections/reorder` | 섹션 순서 변경 |
|
||||
|
||||
### 2.4 필드
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/v1/item-master/fields` | 독립 필드 목록 |
|
||||
| POST | `/api/v1/item-master/fields` | 독립 필드 생성 |
|
||||
| POST | `/api/v1/item-master/sections/{sectionId}/fields` | 필드 생성 + 섹션 연결 |
|
||||
| PUT | `/api/v1/item-master/fields/{id}` | 필드 수정 |
|
||||
| DELETE | `/api/v1/item-master/fields/{id}` | 필드 삭제 |
|
||||
| POST | `/api/v1/item-master/fields/{id}/clone` | 필드 복제 |
|
||||
| GET | `/api/v1/item-master/fields/{id}/usage` | 사용처 조회 |
|
||||
| PUT | `/api/v1/item-master/sections/{sectionId}/fields/reorder` | 필드 순서 변경 |
|
||||
|
||||
### 2.5 BOM
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/v1/item-master/bom-items` | 독립 BOM 목록 |
|
||||
| POST | `/api/v1/item-master/bom-items` | 독립 BOM 생성 |
|
||||
| POST | `/api/v1/item-master/sections/{sectionId}/bom-items` | BOM 생성 + 섹션 연결 |
|
||||
| PUT | `/api/v1/item-master/bom-items/{id}` | BOM 수정 |
|
||||
| DELETE | `/api/v1/item-master/bom-items/{id}` | BOM 삭제 |
|
||||
|
||||
### 2.6 섹션 템플릿
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/v1/item-master/section-templates` | 템플릿 목록 |
|
||||
| POST | `/api/v1/item-master/section-templates` | 템플릿 생성 |
|
||||
| PUT | `/api/v1/item-master/section-templates/{id}` | 템플릿 수정 |
|
||||
| DELETE | `/api/v1/item-master/section-templates/{id}` | 템플릿 삭제 |
|
||||
|
||||
### 2.7 커스텀 탭
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/v1/item-master/custom-tabs` | 탭 목록 |
|
||||
| POST | `/api/v1/item-master/custom-tabs` | 탭 생성 |
|
||||
| PUT | `/api/v1/item-master/custom-tabs/{id}` | 탭 수정 |
|
||||
| DELETE | `/api/v1/item-master/custom-tabs/{id}` | 탭 삭제 |
|
||||
| PUT | `/api/v1/item-master/custom-tabs/reorder` | 탭 순서 변경 |
|
||||
|
||||
### 2.8 단위 옵션
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/v1/item-master/unit-options` | 단위 목록 |
|
||||
| POST | `/api/v1/item-master/unit-options` | 단위 생성 |
|
||||
| DELETE | `/api/v1/item-master/unit-options/{id}` | 단위 삭제 |
|
||||
|
||||
### 2.9 엔티티 관계 (Link/Unlink)
|
||||
|
||||
**페이지-섹션 연결:**
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| POST | `/api/v1/item-master/pages/{pageId}/link-section` | 섹션 연결 |
|
||||
| DELETE | `/api/v1/item-master/pages/{pageId}/unlink-section/{sectionId}` | 섹션 연결 해제 |
|
||||
|
||||
**페이지-필드 연결:**
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| POST | `/api/v1/item-master/pages/{pageId}/link-field` | 필드 연결 |
|
||||
| DELETE | `/api/v1/item-master/pages/{pageId}/unlink-field/{fieldId}` | 필드 연결 해제 |
|
||||
|
||||
**섹션-필드 연결:**
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| POST | `/api/v1/item-master/sections/{sectionId}/link-field` | 필드 연결 |
|
||||
| DELETE | `/api/v1/item-master/sections/{sectionId}/unlink-field/{fieldId}` | 필드 연결 해제 |
|
||||
|
||||
**섹션-BOM 연결:**
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| POST | `/api/v1/item-master/sections/{sectionId}/link-bom` | BOM 연결 |
|
||||
| DELETE | `/api/v1/item-master/sections/{sectionId}/unlink-bom/{bomId}` | BOM 연결 해제 |
|
||||
|
||||
**관계 조회/정렬:**
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/v1/item-master/pages/{pageId}/relationships` | 페이지 관계 조회 |
|
||||
| GET | `/api/v1/item-master/pages/{pageId}/structure` | 페이지 구조 조회 |
|
||||
| GET | `/api/v1/item-master/sections/{sectionId}/relationships` | 섹션 관계 조회 |
|
||||
| POST | `/api/v1/item-master/relationships/reorder` | 관계 순서 변경 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 데이터 구조
|
||||
|
||||
### 3.1 ItemPage
|
||||
|
||||
```typescript
|
||||
interface ItemPage {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
group_id: number;
|
||||
page_name: string;
|
||||
item_type: 'FG' | 'PT' | 'SM' | 'RM' | 'CS';
|
||||
source_table: 'products' | 'materials';
|
||||
absolute_path?: string;
|
||||
is_active: boolean;
|
||||
sections: ItemSection[]; // init 응답에 포함
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 ItemSection
|
||||
|
||||
```typescript
|
||||
interface ItemSection {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
group_id: number;
|
||||
title: string;
|
||||
type: string;
|
||||
order_no: number;
|
||||
is_template: boolean;
|
||||
is_default: boolean;
|
||||
is_locked?: boolean; // 연결 잠금 상태
|
||||
description?: string;
|
||||
fields?: ItemField[];
|
||||
bom_items?: ItemBomItem[];
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 ItemField
|
||||
|
||||
```typescript
|
||||
interface ItemField {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
group_id: number;
|
||||
field_name: string;
|
||||
field_key: string; // 저장 시 사용할 키
|
||||
field_type: FieldType;
|
||||
order_no: number;
|
||||
is_required: boolean;
|
||||
is_common: boolean;
|
||||
is_active: boolean;
|
||||
is_locked: boolean;
|
||||
default_value?: string;
|
||||
placeholder?: string;
|
||||
display_condition?: object; // 조건부 표시
|
||||
validation_rules?: object; // 유효성 검사 규칙
|
||||
options?: object; // dropdown 옵션 등
|
||||
properties?: object; // 추가 설정
|
||||
category?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
type FieldType = 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
|
||||
```
|
||||
|
||||
### 3.4 EntityRelationship
|
||||
|
||||
```typescript
|
||||
interface EntityRelationship {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
group_id: number; // 1: ItemMaster
|
||||
group_id: number;
|
||||
parent_type: 'page' | 'section';
|
||||
parent_id: number;
|
||||
child_type: 'section' | 'field' | 'bom';
|
||||
child_id: number;
|
||||
order_no: number;
|
||||
is_locked: boolean; // ⭐ 잠금 여부
|
||||
is_locked: boolean;
|
||||
locked_by?: number;
|
||||
locked_at?: string;
|
||||
metadata?: object;
|
||||
@@ -73,9 +249,9 @@ interface EntityRelationship {
|
||||
|
||||
---
|
||||
|
||||
## 3. 잠금(Lock) 기능
|
||||
## 4. 잠금(Lock) 기능
|
||||
|
||||
### 3.1 잠금의 의미
|
||||
### 4.1 잠금의 의미
|
||||
|
||||
**연결이 잠기면:**
|
||||
- 해당 연결(관계)를 해제할 수 없음
|
||||
@@ -90,175 +266,40 @@ interface EntityRelationship {
|
||||
└─ ✅ 섹션에 새 필드 추가 가능
|
||||
```
|
||||
|
||||
### 3.2 잠금 상태 확인
|
||||
### 4.2 잠금 상태 확인
|
||||
|
||||
init API 응답에 `is_locked` 필드가 포함됩니다.
|
||||
|
||||
```typescript
|
||||
// GET /api/v1/item-master/init 응답
|
||||
```json
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"id": 1,
|
||||
"page_name": "완제품 기본정보",
|
||||
"sections": [
|
||||
{
|
||||
"id": 10,
|
||||
"title": "제품 상세",
|
||||
"is_locked": true, // ⭐ 이 연결이 잠김
|
||||
"fields": [
|
||||
{
|
||||
"id": 100,
|
||||
"field_name": "제품명",
|
||||
"is_locked": false // ⭐ 이 연결은 잠기지 않음
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
"pages": [{
|
||||
"id": 1,
|
||||
"sections": [{
|
||||
"id": 10,
|
||||
"is_locked": true,
|
||||
"fields": [{
|
||||
"id": 100,
|
||||
"is_locked": false
|
||||
}]
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 프론트엔드 처리 가이드
|
||||
### 4.3 잠금 관련 에러
|
||||
|
||||
```typescript
|
||||
// 잠금 상태에 따른 UI 처리 예시
|
||||
interface SectionProps {
|
||||
section: ItemSection;
|
||||
isLocked: boolean; // 부모로부터 전달받은 잠금 상태
|
||||
}
|
||||
|
||||
function SectionItem({ section, isLocked }: SectionProps) {
|
||||
return (
|
||||
<div className={isLocked ? 'locked-section' : ''}>
|
||||
<h3>
|
||||
{section.title}
|
||||
{isLocked && <LockIcon />} {/* 잠금 아이콘 표시 */}
|
||||
</h3>
|
||||
|
||||
{/* 잠금 시 삭제/분리 버튼 비활성화 */}
|
||||
<button
|
||||
disabled={isLocked}
|
||||
onClick={handleUnlink}
|
||||
>
|
||||
분리
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled={isLocked}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
|
||||
{/* 수정은 항상 가능 */}
|
||||
<button onClick={handleEdit}>
|
||||
수정
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 잠금 관련 에러 처리
|
||||
|
||||
잠금된 항목에 대해 삭제/해제 시도 시 에러가 반환됩니다.
|
||||
|
||||
```typescript
|
||||
// 에러 응답 예시
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "잠금된 연결은 해제할 수 없습니다.",
|
||||
"error": "relationship_locked"
|
||||
}
|
||||
|
||||
// 프론트엔드 에러 처리
|
||||
try {
|
||||
await deleteSection(sectionId);
|
||||
} catch (error) {
|
||||
if (error.response?.data?.error === 'entity_protected_by_locked_relationship') {
|
||||
toast.error('잠금된 연결로 보호된 항목은 삭제할 수 없습니다.');
|
||||
}
|
||||
"error": "entity_protected_by_locked_relationship"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API 엔드포인트
|
||||
|
||||
### 4.1 초기화 API
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/v1/item-master/init` | 전체 데이터 로드 (페이지, 섹션, 커스텀탭, 단위옵션) |
|
||||
|
||||
**응답 구조:**
|
||||
```typescript
|
||||
interface InitResponse {
|
||||
pages: ItemPage[]; // 페이지 + 연결된 섹션/필드
|
||||
sections: ItemSection[]; // 모든 독립 섹션 (재사용 풀)
|
||||
customTabs: CustomTab[];
|
||||
unitOptions: UnitOption[];
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 페이지 API
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/v1/item-master/pages` | 페이지 목록 |
|
||||
| POST | `/api/v1/item-master/pages` | 페이지 생성 |
|
||||
| PUT | `/api/v1/item-master/pages/{id}` | 페이지 수정 |
|
||||
| DELETE | `/api/v1/item-master/pages/{id}` | 페이지 삭제 |
|
||||
|
||||
### 4.3 섹션 API
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/v1/item-master/sections` | 독립 섹션 목록 |
|
||||
| POST | `/api/v1/item-master/sections` | 독립 섹션 생성 |
|
||||
| POST | `/api/v1/item-master/pages/{pageId}/sections` | 섹션 생성 + 페이지 연결 |
|
||||
| PUT | `/api/v1/item-master/sections/{id}` | 섹션 수정 |
|
||||
| DELETE | `/api/v1/item-master/sections/{id}` | 섹션 삭제 |
|
||||
| POST | `/api/v1/item-master/sections/{id}/clone` | 섹션 복제 |
|
||||
| GET | `/api/v1/item-master/sections/{id}/usage` | 사용처 조회 |
|
||||
|
||||
### 4.4 필드 API
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/v1/item-master/fields` | 독립 필드 목록 |
|
||||
| POST | `/api/v1/item-master/fields` | 독립 필드 생성 |
|
||||
| POST | `/api/v1/item-master/sections/{sectionId}/fields` | 필드 생성 + 섹션 연결 |
|
||||
| PUT | `/api/v1/item-master/fields/{id}` | 필드 수정 |
|
||||
| DELETE | `/api/v1/item-master/fields/{id}` | 필드 삭제 |
|
||||
| POST | `/api/v1/item-master/fields/{id}/clone` | 필드 복제 |
|
||||
| GET | `/api/v1/item-master/fields/{id}/usage` | 사용처 조회 |
|
||||
|
||||
### 4.5 BOM API
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/v1/item-master/bom-items` | 독립 BOM 목록 |
|
||||
| POST | `/api/v1/item-master/bom-items` | 독립 BOM 생성 |
|
||||
| POST | `/api/v1/item-master/sections/{sectionId}/bom-items` | BOM 생성 + 섹션 연결 |
|
||||
| PUT | `/api/v1/item-master/bom-items/{id}` | BOM 수정 |
|
||||
| DELETE | `/api/v1/item-master/bom-items/{id}` | BOM 삭제 |
|
||||
|
||||
### 4.6 순서 변경 API
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| PUT | `/api/v1/item-master/pages/{pageId}/sections/reorder` | 섹션 순서 변경 |
|
||||
| PUT | `/api/v1/item-master/sections/{sectionId}/fields/reorder` | 필드 순서 변경 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 필드 타입
|
||||
|
||||
### 5.1 지원 필드 타입
|
||||
|
||||
| field_type | 설명 | 렌더링 컴포넌트 |
|
||||
|------------|------|----------------|
|
||||
| `textbox` | 텍스트 입력 | `<Input type="text" />` |
|
||||
@@ -268,111 +309,31 @@ interface InitResponse {
|
||||
| `date` | 날짜 선택 | `<DatePicker />` |
|
||||
| `textarea` | 장문 텍스트 | `<Textarea />` |
|
||||
|
||||
### 5.2 필드 속성
|
||||
|
||||
```typescript
|
||||
interface ItemField {
|
||||
id: number;
|
||||
field_name: string;
|
||||
field_type: FieldType;
|
||||
order_no: number;
|
||||
is_required: boolean;
|
||||
is_locked?: boolean; // init 응답에 포함
|
||||
default_value?: string;
|
||||
placeholder?: string;
|
||||
display_condition?: object; // 조건부 표시
|
||||
validation_rules?: object; // 유효성 검사 규칙
|
||||
options?: object; // dropdown 옵션 등
|
||||
properties?: object; // 추가 설정
|
||||
category?: string;
|
||||
description?: string;
|
||||
is_common?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 상태 관리 권장 패턴
|
||||
## 6. 주의사항
|
||||
|
||||
### 6.1 Zustand Store 예시
|
||||
|
||||
```typescript
|
||||
interface ItemMasterStore {
|
||||
// 데이터
|
||||
pages: ItemPage[];
|
||||
sections: ItemSection[];
|
||||
customTabs: CustomTab[];
|
||||
unitOptions: UnitOption[];
|
||||
|
||||
// 상태
|
||||
isLoading: boolean;
|
||||
selectedPageId: number | null;
|
||||
selectedSectionId: number | null;
|
||||
|
||||
// 액션
|
||||
fetchInit: () => Promise<void>;
|
||||
addSection: (pageId: number, data: CreateSectionDTO) => Promise<void>;
|
||||
updateSection: (id: number, data: UpdateSectionDTO) => Promise<void>;
|
||||
deleteSection: (id: number) => Promise<void>;
|
||||
reorderSections: (pageId: number, items: ReorderItem[]) => Promise<void>;
|
||||
|
||||
// 유틸리티
|
||||
getSectionsByPage: (pageId: number) => ItemSection[];
|
||||
isEntityLocked: (entityType: string, entityId: number) => boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 잠금 상태 캐싱
|
||||
|
||||
```typescript
|
||||
// 잠금 상태를 Map으로 캐싱
|
||||
const lockStatusMap = new Map<string, boolean>();
|
||||
|
||||
function buildLockStatusMap(pages: ItemPage[]) {
|
||||
pages.forEach(page => {
|
||||
page.sections?.forEach(section => {
|
||||
lockStatusMap.set(`section:${section.id}`, section.is_locked);
|
||||
|
||||
section.fields?.forEach(field => {
|
||||
lockStatusMap.set(`field:${field.id}`, field.is_locked);
|
||||
});
|
||||
|
||||
section.bom_items?.forEach(bom => {
|
||||
lockStatusMap.set(`bom:${bom.id}`, bom.is_locked);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isLocked(entityType: string, entityId: number): boolean {
|
||||
return lockStatusMap.get(`${entityType}:${entityId}`) || false;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 주의사항
|
||||
|
||||
### 7.1 삭제 시 동작
|
||||
### 6.1 삭제 시 동작
|
||||
- **페이지 삭제**: 연결된 섹션/필드는 삭제되지 않고 관계만 해제
|
||||
- **섹션 삭제**: 연결된 필드/BOM은 삭제되지 않고 관계만 해제
|
||||
- **잠금된 연결이 있으면**: 삭제/해제 불가
|
||||
|
||||
### 7.2 복제(Clone) 시 동작
|
||||
### 6.2 복제(Clone) 시 동작
|
||||
- 섹션 복제: 섹션 + 필드 + BOM 모두 복제
|
||||
- 필드 복제: 필드만 복제
|
||||
- 복제된 항목은 독립 엔티티로 생성됨
|
||||
|
||||
### 7.3 순서 변경
|
||||
### 6.3 순서 변경
|
||||
- Drag & Drop 후 reorder API 호출 필요
|
||||
- `items` 배열에 `{id, order_no}` 형태로 전달
|
||||
|
||||
---
|
||||
|
||||
## 8. 변경 이력
|
||||
## 7. 변경 이력
|
||||
|
||||
| 날짜 | 버전 | 변경 내용 |
|
||||
|------|------|----------|
|
||||
| 2025-11-27 | 1.0.0 | 잠금(Lock) 기능 추가 |
|
||||
| 2025-11-26 | 0.9.0 | 독립 엔티티 아키텍처 적용 |
|
||||
| 2025-11-20 | 0.8.0 | entity_relationships 링크 테이블 도입 |
|
||||
| 날짜 | 변경 내용 |
|
||||
|------|----------|
|
||||
| 2025-12-09 | 시스템 기반 문서 전면 재작성 |
|
||||
| 2025-11-27 | 잠금(Lock) 기능 추가 |
|
||||
| 2025-11-26 | 독립 엔티티 아키텍처 적용 |
|
||||
| 2025-11-20 | entity_relationships 링크 테이블 도입 |
|
||||
|
||||
@@ -1,669 +0,0 @@
|
||||
# HR API - React 동기화 계획
|
||||
|
||||
> **작성일**: 2025-12-09
|
||||
> **수정일**: 2025-12-09
|
||||
> **목적**: API와 React 프론트엔드 간 데이터 타입 동기화
|
||||
> **원칙**: **API snake_case 유지** - React에서 camelCase 변환 처리
|
||||
|
||||
---
|
||||
|
||||
## 📋 작업 요약
|
||||
|
||||
| 영역 | 작업 | 수정 필요 |
|
||||
|------|------|----------|
|
||||
| **Employee API** | 기존 snake_case 유지 | ❌ 불필요 |
|
||||
| **Attendance API** | 기존 snake_case 유지 | ❌ 불필요 |
|
||||
| **Department Tree API** | 기존 snake_case 유지 | ❌ 불필요 |
|
||||
| **React 프론트엔드** | 변환 유틸리티 적용 | ✅ 프론트엔드 |
|
||||
|
||||
---
|
||||
|
||||
# Part 1: 백엔드 (API) - 변경 없음
|
||||
|
||||
## 설계 원칙
|
||||
|
||||
- **API 응답은 snake_case 유지** (Laravel 표준)
|
||||
- **json_extra, json_details 구조 그대로 유지**
|
||||
- **기존 API 클라이언트 호환성 보장**
|
||||
|
||||
## 현재 API 응답 구조
|
||||
|
||||
### Employee API 응답
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"tenant_id": 1,
|
||||
"user_id": 10,
|
||||
"department_id": 5,
|
||||
"position_key": "DEVELOPER",
|
||||
"employment_type_key": "REGULAR",
|
||||
"employee_status": "active",
|
||||
"profile_photo_path": null,
|
||||
"json_extra": {
|
||||
"employee_code": "EMP001",
|
||||
"resident_number": "******-*******",
|
||||
"gender": "male",
|
||||
"address": "서울시 강남구",
|
||||
"salary": 50000000,
|
||||
"hire_date": "2023-01-15",
|
||||
"rank": "대리",
|
||||
"bank_account": {
|
||||
"bank": "국민",
|
||||
"account": "123-456-789",
|
||||
"holder": "홍길동"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"id": 10,
|
||||
"name": "홍길동",
|
||||
"email": "hong@example.com",
|
||||
"phone": "010-1234-5678",
|
||||
"is_active": true
|
||||
},
|
||||
"department": {
|
||||
"id": 5,
|
||||
"name": "개발팀"
|
||||
},
|
||||
"created_at": "2023-01-15T09:00:00.000000Z",
|
||||
"updated_at": "2024-12-09T10:30:00.000000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Attendance API 응답
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"tenant_id": 1,
|
||||
"user_id": 10,
|
||||
"base_date": "2024-12-09",
|
||||
"status": "onTime",
|
||||
"json_details": {
|
||||
"check_in": "09:00:00",
|
||||
"check_out": "18:00:00",
|
||||
"work_minutes": 540,
|
||||
"overtime_minutes": 60,
|
||||
"late_minutes": 0,
|
||||
"gps_data": {
|
||||
"check_in": { "lat": 37.5665, "lng": 126.9780 }
|
||||
}
|
||||
},
|
||||
"remarks": null,
|
||||
"user": {
|
||||
"id": 10,
|
||||
"name": "홍길동",
|
||||
"email": "hong@example.com"
|
||||
},
|
||||
"created_at": "2024-12-09T09:00:00.000000Z",
|
||||
"updated_at": "2024-12-09T18:00:00.000000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Department Tree API 응답
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"tenant_id": 1,
|
||||
"parent_id": null,
|
||||
"code": "DEV",
|
||||
"name": "개발본부",
|
||||
"description": "개발 조직",
|
||||
"is_active": true,
|
||||
"sort_order": 1,
|
||||
"children": [
|
||||
{
|
||||
"id": 2,
|
||||
"tenant_id": 1,
|
||||
"parent_id": 1,
|
||||
"code": "DEV-FE",
|
||||
"name": "프론트엔드팀",
|
||||
"is_active": true,
|
||||
"sort_order": 1,
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Part 2: 프론트엔드 (React) 수정사항
|
||||
|
||||
## 변환 전략
|
||||
|
||||
React 프론트엔드에서 API 응답을 받아 내부 타입으로 변환합니다.
|
||||
|
||||
### 변환 유틸리티 위치
|
||||
|
||||
```
|
||||
react/src/lib/
|
||||
├── api/
|
||||
│ └── transformers/
|
||||
│ ├── employee.ts # Employee 변환
|
||||
│ ├── attendance.ts # Attendance 변환
|
||||
│ ├── department.ts # Department 변환
|
||||
│ └── index.ts # 공통 유틸리티
|
||||
```
|
||||
|
||||
## 1. 공통 변환 유틸리티
|
||||
|
||||
**파일**: `react/src/lib/api/transformers/index.ts`
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* snake_case → camelCase 변환
|
||||
*/
|
||||
export function toCamelCase(str: string): string {
|
||||
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* 객체 키를 camelCase로 변환 (재귀)
|
||||
*/
|
||||
export function transformKeys<T>(obj: unknown): T {
|
||||
if (obj === null || obj === undefined) {
|
||||
return obj as T;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(item => transformKeys(item)) as T;
|
||||
}
|
||||
|
||||
if (typeof obj === 'object') {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const camelKey = toCamelCase(key);
|
||||
result[camelKey] = transformKeys(value);
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
|
||||
return obj as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO 문자열을 Date로 변환
|
||||
*/
|
||||
export function parseDate(dateStr: string | null): Date | null {
|
||||
if (!dateStr) return null;
|
||||
return new Date(dateStr);
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Employee 변환
|
||||
|
||||
**파일**: `react/src/lib/api/transformers/employee.ts`
|
||||
|
||||
```typescript
|
||||
import { transformKeys } from './index';
|
||||
import type { Employee, EmployeeApiResponse } from '@/types/hr';
|
||||
|
||||
/**
|
||||
* API 응답 → React Employee 타입 변환
|
||||
*/
|
||||
export function transformEmployee(data: EmployeeApiResponse): Employee {
|
||||
const base = transformKeys<Record<string, unknown>>(data);
|
||||
const jsonExtra = data.json_extra ?? {};
|
||||
|
||||
return {
|
||||
id: String(data.id),
|
||||
name: data.user?.name ?? '',
|
||||
email: data.user?.email ?? '',
|
||||
phone: data.user?.phone ?? null,
|
||||
residentNumber: jsonExtra.resident_number ?? null,
|
||||
salary: jsonExtra.salary ?? null,
|
||||
profileImage: data.profile_photo_path ?? null,
|
||||
employeeCode: jsonExtra.employee_code ?? null,
|
||||
gender: jsonExtra.gender ?? null,
|
||||
address: transformAddress(jsonExtra.address),
|
||||
bankAccount: transformBankAccount(jsonExtra.bank_account),
|
||||
hireDate: jsonExtra.hire_date ?? null,
|
||||
employmentType: mapEmploymentType(data.employment_type_key),
|
||||
rank: jsonExtra.rank ?? null,
|
||||
status: data.employee_status ?? 'active',
|
||||
departmentPositions: buildDepartmentPositions(data),
|
||||
userInfo: buildUserInfo(data),
|
||||
createdAt: data.created_at ?? null,
|
||||
updatedAt: data.updated_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function transformAddress(address: unknown): Employee['address'] {
|
||||
if (!address) return null;
|
||||
|
||||
if (typeof address === 'string') {
|
||||
return {
|
||||
zipCode: '',
|
||||
address1: address,
|
||||
address2: '',
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof address === 'object') {
|
||||
const addr = address as Record<string, string>;
|
||||
return {
|
||||
zipCode: addr.zip_code ?? addr.zipCode ?? '',
|
||||
address1: addr.address1 ?? addr.address_1 ?? '',
|
||||
address2: addr.address2 ?? addr.address_2 ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function transformBankAccount(bankAccount: unknown): Employee['bankAccount'] {
|
||||
if (!bankAccount || typeof bankAccount !== 'object') return null;
|
||||
|
||||
const ba = bankAccount as Record<string, string>;
|
||||
return {
|
||||
bankName: ba.bank ?? ba.bankName ?? '',
|
||||
accountNumber: ba.account ?? ba.accountNumber ?? '',
|
||||
accountHolder: ba.holder ?? ba.accountHolder ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function mapEmploymentType(key: string | null): string | null {
|
||||
if (!key) return null;
|
||||
|
||||
const map: Record<string, string> = {
|
||||
REGULAR: 'regular',
|
||||
CONTRACT: 'contract',
|
||||
PARTTIME: 'parttime',
|
||||
INTERN: 'intern',
|
||||
};
|
||||
|
||||
return map[key] ?? key.toLowerCase();
|
||||
}
|
||||
|
||||
function buildDepartmentPositions(data: EmployeeApiResponse): Employee['departmentPositions'] {
|
||||
if (!data.department_id) return [];
|
||||
|
||||
return [{
|
||||
id: String(data.id),
|
||||
departmentId: String(data.department_id),
|
||||
departmentName: data.department?.name ?? '',
|
||||
positionId: data.position_key ?? '',
|
||||
positionName: data.position_key ?? '',
|
||||
}];
|
||||
}
|
||||
|
||||
function buildUserInfo(data: EmployeeApiResponse): Employee['userInfo'] {
|
||||
if (!data.user) return null;
|
||||
|
||||
return {
|
||||
userId: data.user.user_id ?? data.user.email,
|
||||
role: 'user', // TODO: 실제 역할 정보
|
||||
accountStatus: data.user.is_active ? 'active' : 'inactive',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Employee 목록 변환
|
||||
*/
|
||||
export function transformEmployeeList(data: EmployeeApiResponse[]): Employee[] {
|
||||
return data.map(transformEmployee);
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Attendance 변환
|
||||
|
||||
**파일**: `react/src/lib/api/transformers/attendance.ts`
|
||||
|
||||
```typescript
|
||||
import type { Attendance, AttendanceApiResponse } from '@/types/hr';
|
||||
|
||||
/**
|
||||
* API 응답 → React Attendance 타입 변환
|
||||
*/
|
||||
export function transformAttendance(data: AttendanceApiResponse): Attendance {
|
||||
const jsonDetails = data.json_details ?? {};
|
||||
|
||||
return {
|
||||
id: String(data.id),
|
||||
employeeId: String(data.user_id),
|
||||
employeeName: data.user?.name ?? '',
|
||||
department: '', // TODO: user.tenantProfile.department.name
|
||||
position: '', // TODO: user.tenantProfile.position_key
|
||||
rank: '', // TODO: user.tenantProfile.json_extra.rank
|
||||
baseDate: data.base_date,
|
||||
checkIn: jsonDetails.check_in ?? null,
|
||||
checkOut: jsonDetails.check_out ?? null,
|
||||
breakTime: jsonDetails.break_time ?? null,
|
||||
overtimeHours: formatOvertimeHours(jsonDetails.overtime_minutes),
|
||||
reason: buildReason(data),
|
||||
status: data.status,
|
||||
createdAt: data.created_at ?? null,
|
||||
updatedAt: data.updated_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function formatOvertimeHours(minutes: number | undefined): string | null {
|
||||
if (minutes === undefined || minutes === null) return null;
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
|
||||
return mins > 0 ? `${hours}시간 ${mins}분` : `${hours}시간`;
|
||||
}
|
||||
|
||||
function buildReason(data: AttendanceApiResponse): Attendance['reason'] {
|
||||
if (!data.remarks) return null;
|
||||
|
||||
const typeMap: Record<string, string> = {
|
||||
vacation: 'vacationRequest',
|
||||
businessTrip: 'businessTripRequest',
|
||||
fieldWork: 'fieldWorkRequest',
|
||||
overtime: 'overtimeRequest',
|
||||
};
|
||||
|
||||
return {
|
||||
type: typeMap[data.status] ?? 'vacationRequest',
|
||||
label: data.remarks,
|
||||
documentId: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Attendance 목록 변환
|
||||
*/
|
||||
export function transformAttendanceList(data: AttendanceApiResponse[]): Attendance[] {
|
||||
return data.map(transformAttendance);
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Department Tree 변환
|
||||
|
||||
**파일**: `react/src/lib/api/transformers/department.ts`
|
||||
|
||||
```typescript
|
||||
import type { DepartmentNode, DepartmentApiResponse } from '@/types/hr';
|
||||
|
||||
/**
|
||||
* API 응답 → React Department 타입 변환 (재귀)
|
||||
*/
|
||||
export function transformDepartmentTree(
|
||||
data: DepartmentApiResponse[],
|
||||
depth: number = 0
|
||||
): DepartmentNode[] {
|
||||
return data.map(dept => ({
|
||||
id: dept.id,
|
||||
name: dept.name,
|
||||
parentId: dept.parent_id,
|
||||
depth: depth,
|
||||
children: dept.children
|
||||
? transformDepartmentTree(dept.children, depth + 1)
|
||||
: [],
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
## 5. API 호출 래퍼
|
||||
|
||||
**파일**: `react/src/lib/api/hr.ts`
|
||||
|
||||
```typescript
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import {
|
||||
transformEmployee,
|
||||
transformEmployeeList
|
||||
} from './transformers/employee';
|
||||
import {
|
||||
transformAttendance,
|
||||
transformAttendanceList
|
||||
} from './transformers/attendance';
|
||||
import { transformDepartmentTree } from './transformers/department';
|
||||
|
||||
// Employee API
|
||||
export async function getEmployees(params?: Record<string, unknown>) {
|
||||
const response = await apiClient.get('/v1/employees', { params });
|
||||
return transformEmployeeList(response.data.data);
|
||||
}
|
||||
|
||||
export async function getEmployee(id: string) {
|
||||
const response = await apiClient.get(`/v1/employees/${id}`);
|
||||
return transformEmployee(response.data.data);
|
||||
}
|
||||
|
||||
// Attendance API
|
||||
export async function getAttendances(params?: Record<string, unknown>) {
|
||||
const response = await apiClient.get('/v1/attendances', { params });
|
||||
return transformAttendanceList(response.data.data);
|
||||
}
|
||||
|
||||
export async function getAttendance(id: string) {
|
||||
const response = await apiClient.get(`/v1/attendances/${id}`);
|
||||
return transformAttendance(response.data.data);
|
||||
}
|
||||
|
||||
// Department API
|
||||
export async function getDepartmentTree(params?: Record<string, unknown>) {
|
||||
const response = await apiClient.get('/v1/departments/tree', { params });
|
||||
return transformDepartmentTree(response.data.data);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Part 3: React 타입 정의
|
||||
|
||||
**파일**: `react/src/types/hr.ts`
|
||||
|
||||
```typescript
|
||||
// ============================================================
|
||||
// React 내부 타입 (camelCase)
|
||||
// ============================================================
|
||||
|
||||
export interface Employee {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
residentNumber: string | null;
|
||||
salary: number | null;
|
||||
profileImage: string | null;
|
||||
employeeCode: string | null;
|
||||
gender: 'male' | 'female' | null;
|
||||
address: {
|
||||
zipCode: string;
|
||||
address1: string;
|
||||
address2: string;
|
||||
} | null;
|
||||
bankAccount: {
|
||||
bankName: string;
|
||||
accountNumber: string;
|
||||
accountHolder: string;
|
||||
} | null;
|
||||
hireDate: string | null;
|
||||
employmentType: 'regular' | 'contract' | 'parttime' | 'intern' | null;
|
||||
rank: string | null;
|
||||
status: 'active' | 'leave' | 'resigned';
|
||||
departmentPositions: {
|
||||
id: string;
|
||||
departmentId: string;
|
||||
departmentName: string;
|
||||
positionId: string;
|
||||
positionName: string;
|
||||
}[];
|
||||
userInfo: {
|
||||
userId: string;
|
||||
role: string;
|
||||
accountStatus: 'active' | 'inactive';
|
||||
} | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface Attendance {
|
||||
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: 'vacationRequest' | 'businessTripRequest' | 'fieldWorkRequest' | 'overtimeRequest';
|
||||
label: string;
|
||||
documentId: string | null;
|
||||
} | null;
|
||||
status: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface DepartmentNode {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number | null;
|
||||
depth: number;
|
||||
children: DepartmentNode[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API 응답 타입 (snake_case)
|
||||
// ============================================================
|
||||
|
||||
export interface EmployeeApiResponse {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
user_id: number;
|
||||
department_id: number | null;
|
||||
position_key: string | null;
|
||||
employment_type_key: string | null;
|
||||
employee_status: string;
|
||||
profile_photo_path: string | null;
|
||||
json_extra: Record<string, unknown> | null;
|
||||
user: {
|
||||
id: number;
|
||||
user_id?: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
is_active: boolean;
|
||||
} | null;
|
||||
department: {
|
||||
id: number;
|
||||
name: string;
|
||||
} | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface AttendanceApiResponse {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
user_id: number;
|
||||
base_date: string;
|
||||
status: string;
|
||||
json_details: Record<string, unknown> | null;
|
||||
remarks: string | null;
|
||||
user: {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
} | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface DepartmentApiResponse {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
parent_id: number | null;
|
||||
code: string | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_active: boolean;
|
||||
sort_order: number;
|
||||
children: DepartmentApiResponse[] | null;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Part 4: 작업 체크리스트
|
||||
|
||||
## 프론트엔드 작업
|
||||
|
||||
- [ ] `react/src/lib/api/transformers/index.ts` - 공통 변환 유틸리티
|
||||
- [ ] `react/src/lib/api/transformers/employee.ts` - Employee 변환
|
||||
- [ ] `react/src/lib/api/transformers/attendance.ts` - Attendance 변환
|
||||
- [ ] `react/src/lib/api/transformers/department.ts` - Department 변환
|
||||
- [ ] `react/src/lib/api/hr.ts` - API 호출 래퍼
|
||||
- [ ] `react/src/types/hr.ts` - 타입 정의 (내부용 + API 응답용)
|
||||
- [ ] 기존 API 호출 코드를 래퍼 함수로 교체
|
||||
|
||||
## 백엔드 작업
|
||||
|
||||
- [x] ~~Resource 클래스 생성~~ → **취소** (기존 응답 유지)
|
||||
- [x] ~~Controller 수정~~ → **취소**
|
||||
- [x] ~~Service 수정~~ → **취소**
|
||||
|
||||
---
|
||||
|
||||
# Part 5: 장단점 비교
|
||||
|
||||
## 현재 접근법 (React 변환)
|
||||
|
||||
**장점:**
|
||||
- API 하위 호환성 유지 (기존 클라이언트 영향 없음)
|
||||
- Laravel 표준 컨벤션 유지 (snake_case)
|
||||
- 백엔드 변경 불필요
|
||||
|
||||
**단점:**
|
||||
- React에서 변환 로직 필요
|
||||
- 타입 이중 정의 (API 타입 + 내부 타입)
|
||||
|
||||
## 대안 접근법 (API camelCase 변환)
|
||||
|
||||
**장점:**
|
||||
- React에서 변환 불필요
|
||||
- 프론트엔드 코드 단순화
|
||||
|
||||
**단점:**
|
||||
- 기존 API 클라이언트 호환성 깨짐
|
||||
- Laravel 표준과 불일치
|
||||
- Resource 클래스 추가 유지보수
|
||||
|
||||
---
|
||||
|
||||
# Part 6: 참고 사항
|
||||
|
||||
## 변환 시점
|
||||
|
||||
1. **API 호출 직후**: `transformXxx()` 함수로 즉시 변환
|
||||
2. **React Query/SWR 사용 시**: fetcher 함수 내에서 변환
|
||||
3. **Zustand/Redux 사용 시**: store에 저장 전 변환
|
||||
|
||||
## 성능 고려
|
||||
|
||||
- 대량 데이터 변환 시 Web Worker 고려
|
||||
- 변환 결과 캐싱 (React Query의 staleTime 활용)
|
||||
- 필요한 필드만 변환하는 최적화 가능
|
||||
|
||||
## 테스트 전략
|
||||
|
||||
```typescript
|
||||
// 변환 함수 단위 테스트
|
||||
describe('transformEmployee', () => {
|
||||
it('should transform snake_case to camelCase', () => {
|
||||
const apiResponse = { employee_status: 'active' };
|
||||
const result = transformEmployee(apiResponse);
|
||||
expect(result.status).toBe('active');
|
||||
});
|
||||
|
||||
it('should handle null json_extra', () => {
|
||||
const apiResponse = { json_extra: null };
|
||||
const result = transformEmployee(apiResponse);
|
||||
expect(result.address).toBeNull();
|
||||
});
|
||||
});
|
||||
```
|
||||
Reference in New Issue
Block a user