docs: 문서 인덱스 및 가이드 업데이트

- INDEX.md 문서 구조 업데이트
- front/item-master-guide.md 프론트엔드 API 가이드 업데이트
- plans/hr-api-react-sync-plan.md 삭제 (폴더 재구성)
This commit is contained in:
2025-12-09 20:30:43 +09:00
parent ceae830e41
commit 4aab8f205d
3 changed files with 275 additions and 951 deletions

View File

@@ -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/` 폴더 추가
- 개발 계획 문서용 임시 폴더
- 작업 완료 후 정리 → 삭제 워크플로우

View File

@@ -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 링크 테이블 도입 |

View File

@@ -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();
});
});
```