fix: 품목기준관리 실시간 동기화 수정
- BOM 항목 추가/수정/삭제 시 섹션탭 즉시 반영 - 섹션 복제 시 UI 즉시 업데이트 (null vs undefined 이슈 해결) - 항목 수정 기능 추가 (useTemplateManagement) - 실시간 동기화 문서 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -102,3 +102,10 @@ build/
|
||||
# ---> Unused components and contexts (archived)
|
||||
src/components/_unused/
|
||||
src/contexts/_unused/
|
||||
|
||||
# ---> Playwright (E2E 테스트 - 로컬 전용)
|
||||
e2e/
|
||||
playwright.config.ts
|
||||
playwright-report/
|
||||
test-results/
|
||||
.playwright/
|
||||
|
||||
96
claudedocs/[GUIDE] collaboration-with-claude.md
Normal file
96
claudedocs/[GUIDE] collaboration-with-claude.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Claude와 효율적인 협업 가이드
|
||||
|
||||
## 데이터 페칭 요청 시 체크리스트
|
||||
|
||||
```
|
||||
□ API 엔드포인트 + 메서드
|
||||
□ 응답 JSON 구조 (성공/실패)
|
||||
□ 인증 방식 (HttpOnly 쿠키면 프록시 필요)
|
||||
□ UI 시나리오 (로딩/성공/실패)
|
||||
```
|
||||
|
||||
이 4가지만 있으면 바로 구현 가능!
|
||||
|
||||
---
|
||||
|
||||
## CSS vs 데이터 페칭 협업 차이
|
||||
|
||||
| CSS | 데이터 페칭 |
|
||||
|-----|------------|
|
||||
| 시각적 비교로 충분 | 보이지 않는 로직이라 설명 필요 |
|
||||
| "이거 안 맞아" → 바로 이해 | "안 돼" → 어디서? 왜? 필요 |
|
||||
| 스크린샷이 곧 스펙 | API 응답이 곧 스펙 |
|
||||
|
||||
---
|
||||
|
||||
## 상황별 질문 템플릿
|
||||
|
||||
### 1. 데이터 페칭 요청
|
||||
|
||||
```markdown
|
||||
## 기능: [기능명]
|
||||
|
||||
### API
|
||||
- 엔드포인트:
|
||||
- 메서드: GET/POST/PUT/DELETE
|
||||
- 인증: HttpOnly 쿠키 / Bearer
|
||||
|
||||
### 응답 구조
|
||||
```json
|
||||
{ ... }
|
||||
```
|
||||
|
||||
### UI 동작
|
||||
- 로딩 중:
|
||||
- 성공 시:
|
||||
- 실패 시:
|
||||
```
|
||||
|
||||
### 2. 에러 발생 시
|
||||
|
||||
```markdown
|
||||
## 에러: [에러 메시지]
|
||||
|
||||
### Network 탭 정보
|
||||
- Request URL:
|
||||
- Request Headers:
|
||||
- Response:
|
||||
|
||||
### 콘솔 에러 (있다면)
|
||||
```
|
||||
|
||||
### 3. 기능 요청 시
|
||||
|
||||
```markdown
|
||||
## 기능: [기능명]
|
||||
|
||||
### 기대 동작
|
||||
1. [트리거] 클릭/입력/등
|
||||
2. [API 호출] (있다면)
|
||||
3. [성공 시] →
|
||||
4. [실패 시] →
|
||||
```
|
||||
|
||||
### 4. CSS 수정 요청 시
|
||||
|
||||
```markdown
|
||||
## 수정 대상: [컴포넌트/파일명]
|
||||
|
||||
### 현재
|
||||
[현재 상태 설명 또는 스크린샷]
|
||||
|
||||
### 원하는
|
||||
[기대 상태 설명 또는 React 원본 참조]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 기억할 점
|
||||
|
||||
- **CSS**: 지금처럼 하면 완벽 ✅
|
||||
- **데이터 페칭**: API 응답 구조 + 시나리오만 추가하면 OK
|
||||
- **에러**: 전체 에러 스택/Network 탭 정보 공유하면 빠른 해결
|
||||
|
||||
---
|
||||
|
||||
*2025-11-27 작성*
|
||||
307
claudedocs/[IMPL-2025-11-27] item-master-api-refactor.md
Normal file
307
claudedocs/[IMPL-2025-11-27] item-master-api-refactor.md
Normal file
@@ -0,0 +1,307 @@
|
||||
# 품목기준관리 API 구조 변경 대응 작업
|
||||
|
||||
## 작업 일자: 2025-11-27
|
||||
|
||||
---
|
||||
|
||||
## 1. 백엔드 API 변경 요약
|
||||
|
||||
### 1.1 핵심 구조 변경: 독립 엔티티 + 링크 테이블
|
||||
|
||||
**Before (CASCADE FK)**:
|
||||
```
|
||||
item_pages
|
||||
↓ page_id FK (CASCADE) - 삭제 시 연쇄 삭제
|
||||
item_sections
|
||||
↓ section_id FK (CASCADE) - 삭제 시 연쇄 삭제
|
||||
item_fields / item_bom_items
|
||||
```
|
||||
|
||||
**After (독립 + 링크)**:
|
||||
```
|
||||
item_pages (독립)
|
||||
item_sections (독립) → entity_relationships (링크 테이블)
|
||||
item_fields (독립)
|
||||
item_bom_items (독립)
|
||||
```
|
||||
|
||||
### 1.2 `item_master_fields` 테이블 통합 → DROP
|
||||
|
||||
- `item_master_fields` 테이블 삭제됨
|
||||
- `item_fields` 테이블에 통합:
|
||||
- `section_id` → nullable (독립 필드는 NULL)
|
||||
- `category`, `description`, `is_common` 컬럼 추가
|
||||
|
||||
**결과**: 마스터 항목 = `item_fields` WHERE `section_id IS NULL`
|
||||
|
||||
### 1.3 `entity_relationships` 테이블 구조
|
||||
|
||||
```sql
|
||||
CREATE TABLE entity_relationships (
|
||||
id BIGINT PRIMARY KEY,
|
||||
tenant_id BIGINT,
|
||||
group_id INT DEFAULT 1, -- 1: 품목관리
|
||||
parent_type ENUM('page', 'section'),
|
||||
parent_id BIGINT,
|
||||
child_type ENUM('section', 'field', 'bom'),
|
||||
child_id BIGINT,
|
||||
order_no INT DEFAULT 0,
|
||||
metadata JSON,
|
||||
created_at, updated_at
|
||||
);
|
||||
```
|
||||
|
||||
### 1.4 새 API 엔드포인트 (14개)
|
||||
|
||||
**페이지-섹션 연결**:
|
||||
- `POST /pages/{pageId}/link-section` - { child_id, order_no? }
|
||||
- `DELETE /pages/{pageId}/unlink-section/{sectionId}`
|
||||
|
||||
**페이지-필드 직접 연결**:
|
||||
- `POST /pages/{pageId}/link-field` - { child_id, order_no? }
|
||||
- `DELETE /pages/{pageId}/unlink-field/{fieldId}`
|
||||
|
||||
**페이지 관계 조회**:
|
||||
- `GET /pages/{pageId}/relationships`
|
||||
- `GET /pages/{pageId}/structure` ⭐ 전체 구조 조회
|
||||
|
||||
**섹션-필드 연결**:
|
||||
- `POST /sections/{sectionId}/link-field` - { child_id, order_no? }
|
||||
- `DELETE /sections/{sectionId}/unlink-field/{fieldId}`
|
||||
|
||||
**섹션-BOM 연결**:
|
||||
- `POST /sections/{sectionId}/link-bom` - { child_id, order_no? }
|
||||
- `DELETE /sections/{sectionId}/unlink-bom/{bomId}`
|
||||
|
||||
**섹션 관계 조회**:
|
||||
- `GET /sections/{sectionId}/relationships`
|
||||
|
||||
**순서 변경**:
|
||||
- `POST /relationships/reorder`
|
||||
```json
|
||||
{
|
||||
"parent_type": "page",
|
||||
"parent_id": 1,
|
||||
"ordered_items": [
|
||||
{ "child_type": "section", "child_id": 1 },
|
||||
{ "child_type": "section", "child_id": 2 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 1.5 독립 엔티티 API
|
||||
|
||||
**섹션**:
|
||||
- `GET /sections` - 독립 섹션 목록 (is_template 필터 가능)
|
||||
- `POST /sections` - 독립 섹션 생성
|
||||
- `POST /sections/{id}/clone` - 섹션 복제
|
||||
- `GET /sections/{id}/usage` - 섹션 사용처 조회
|
||||
|
||||
**필드**:
|
||||
- `GET /fields` - 독립 필드 목록
|
||||
- `POST /fields` - 독립 필드 생성
|
||||
- `POST /fields/{id}/clone` - 필드 복제
|
||||
- `GET /fields/{id}/usage` - 필드 사용처 조회
|
||||
|
||||
**BOM**:
|
||||
- `GET /bom-items` - 독립 BOM 목록
|
||||
- `POST /bom-items` - 독립 BOM 생성
|
||||
|
||||
---
|
||||
|
||||
## 2. 프론트엔드 수정 계획
|
||||
|
||||
### 2.1 타입 정의 수정 (item-master-api.ts) ✅ 완료
|
||||
|
||||
**제거 또는 수정**:
|
||||
- [x] `ItemMasterField` → `ItemField`로 통합 (section_id = null) ✅
|
||||
- [x] `MasterFieldRequest`, `MasterFieldResponse` deprecated 표시 ✅
|
||||
|
||||
**추가**:
|
||||
- [x] `EntityRelationship` 타입 추가 (`EntityRelationshipResponse`) ✅
|
||||
- [x] `LinkEntityRequest` 타입 추가 ✅
|
||||
- [x] `ReorderRelationshipsRequest` 타입 추가 ✅
|
||||
- [x] `PageStructureResponse` 타입 추가 ✅
|
||||
- [x] `LinkBomRequest` 타입 추가 ✅
|
||||
|
||||
**수정**:
|
||||
- [x] `ItemFieldResponse`에 `category`, `description`, `is_common` 추가 ✅
|
||||
- [x] `IndependentFieldRequest`에 `category`, `description`, `is_common` 추가 ✅
|
||||
- [x] `InitResponse`에 `fields` 필드 추가, `masterFields` deprecated ✅
|
||||
|
||||
### 2.2 API 함수 수정 (lib/api/item-master.ts) ✅ 완료
|
||||
|
||||
**deprecated 표시**:
|
||||
- [x] `masterFields.list()` - deprecated, `fields.list()` 사용 권장 ✅
|
||||
- [x] `masterFields.create()` - deprecated, `fields.createIndependent()` 사용 권장 ✅
|
||||
- [x] `masterFields.update()` - deprecated, `fields.update()` 사용 권장 ✅
|
||||
- [x] `masterFields.delete()` - deprecated, `fields.delete()` 사용 권장 ✅
|
||||
|
||||
**추가 (link/unlink API)**:
|
||||
- [x] `pages.linkSection()` ✅ (기존)
|
||||
- [x] `pages.unlinkSection()` ✅ (기존)
|
||||
- [x] `pages.linkField()` ✅
|
||||
- [x] `pages.unlinkField()` ✅
|
||||
- [x] `sections.linkField()` ✅ (기존)
|
||||
- [x] `sections.unlinkField()` ✅ (기존)
|
||||
- [x] `sections.linkBom()` ✅
|
||||
- [x] `sections.unlinkBom()` ✅
|
||||
- [x] `pages.getRelationships()` ✅
|
||||
- [x] `pages.getStructure()` ✅ (기존)
|
||||
- [x] `sections.getRelationships()` ✅
|
||||
- [x] `relationships.reorder()` ✅
|
||||
|
||||
**추가 (독립 엔티티 API)** - 기존 구현됨:
|
||||
- [x] `sections.list()` ✅
|
||||
- [x] `sections.createIndependent()` ✅
|
||||
- [x] `sections.clone()` ✅
|
||||
- [x] `sections.getUsage()` ✅
|
||||
- [x] `fields.list()` ✅
|
||||
- [x] `fields.createIndependent()` ✅
|
||||
- [x] `fields.clone()` ✅
|
||||
- [x] `fields.getUsage()` ✅
|
||||
|
||||
### 2.3 Context 수정 (ItemMasterContext.tsx) ✅ 완료
|
||||
|
||||
**상태 변경**:
|
||||
- [x] `itemMasterFields` → deprecated 표시 (기존 유지, `independentFields` 병행) ✅
|
||||
- [x] 독립 필드 = `independentFields` state 이미 존재 ✅
|
||||
|
||||
**함수 변경 (API 마이그레이션 + deprecated 표시)**:
|
||||
- [x] `addItemMasterField` → `fields.createIndependent()` 사용 ✅
|
||||
- [x] `updateItemMasterField` → `fields.update()` 사용 ✅
|
||||
- [x] `deleteItemMasterField` → `fields.delete()` 사용 ✅
|
||||
- [x] `loadItemMasterFields` → deprecated 표시 추가 ✅
|
||||
|
||||
**신규 함수 (이미 구현됨)**:
|
||||
- [x] `linkSectionToPage(pageId, sectionId)` ✅ (line 2225)
|
||||
- [x] `unlinkSectionFromPage(pageId, sectionId)` ✅ (line 2294)
|
||||
- [x] `linkFieldToSection(sectionId, fieldId)` ✅ (line 2328)
|
||||
- [x] `unlinkFieldFromSection(sectionId, fieldId)` ✅ (line 2366)
|
||||
|
||||
### 2.4 UI 컴포넌트 수정
|
||||
|
||||
**계층구조 탭 (HierarchyTab)**: ✅ 완료
|
||||
- [x] 섹션 추가 시 → link-section API 사용 ✅ (기존 구현)
|
||||
- [x] 섹션 제거 시 → unlink-section API 사용 ✅ (기존 구현)
|
||||
- [x] 필드 제거 시 → unlinkFieldFromSection API 사용 ✅
|
||||
- [x] confirm/toast 메시지 "연결 해제"로 변경 ✅
|
||||
- [x] ItemMasterDataManagement.tsx에서 handleUnlinkFieldWithTracking 사용 ✅
|
||||
|
||||
**섹션 탭 (SectionsTab)**: ✅ 완료 (2025-11-27)
|
||||
- [x] handleDeleteTemplateField → unlinkFieldFromSection API 호출로 변경 ✅
|
||||
- [x] SectionsTab.tsx 필드 삭제 아이콘 → Unlink 아이콘 (orange) ✅
|
||||
- [x] 버튼 title "삭제" → "연결 해제" 변경 ✅
|
||||
- [x] confirm 메시지 "연결을 해제하시겠습니까?" 변경 ✅
|
||||
- [x] toast 메시지 "항목 연결이 해제되었습니다" 변경 ✅
|
||||
- [x] useTemplateManagement.ts에 linkFieldToSection, unlinkFieldFromSection import 추가 ✅
|
||||
|
||||
**항목 탭 (MasterFieldTab → FieldTab)**: ✅ 완료
|
||||
- [x] 데이터 소스: Context에서 `fields.*` API 사용 (2025-11-27)
|
||||
- [x] CRUD → 독립 필드 API 사용 (`fields.createIndependent()`, `fields.update()`, `fields.delete()`)
|
||||
- [x] useMasterFieldManagement.ts에 deprecated 표시 추가
|
||||
- [x] MasterFieldTab/index.tsx UI 텍스트 "항목"으로 변경
|
||||
- [x] MasterFieldDialog 제목/설명 변경
|
||||
- [x] toast 메시지 "마스터 항목" → "항목" 변경
|
||||
- [x] 다이얼로그 텍스트 변경 완료 (FieldDialog, FieldDrawer, TemplateFieldDialog, ImportFieldDialog)
|
||||
|
||||
**속성 탭 (AttributesTab)**: ✅ 분석 완료 - itemMasterFields 양방향 연동 (2025-11-27)
|
||||
- [x] 데이터 소스: `itemMasterFields` (Context) + 로컬 옵션 state ✅
|
||||
- [x] 양방향 연동: 항목탭 ⇄ 속성탭 (같은 itemMasterFields 참조)
|
||||
- [x] 항목 추가/수정/삭제 → 속성탭에도 반영 ✅
|
||||
- [x] 속성 옵션 변경 → 해당 필드의 dropdown options 자동 업데이트 (useAttributeManagement.ts:131-161)
|
||||
|
||||
**ImportFieldDialog**: ✅ 탭 통합 완료 (2025-11-27)
|
||||
- [x] 항목/독립필드 탭 → 단일 필드 목록으로 통합 ✅
|
||||
- [x] `fields` prop 추가, `independentFields`/`itemMasterFields` deprecated ✅
|
||||
- [x] `onImport` 시그니처 단순화: `(source?: ImportSource)` → `()` ✅
|
||||
- [x] `handleImportField` 함수: source 분기 제거 → `linkFieldToSection` 단일 호출 ✅
|
||||
|
||||
---
|
||||
|
||||
## 3. 삭제 동작 변경
|
||||
|
||||
### 3.1 기존 (CASCADE 삭제)
|
||||
|
||||
```
|
||||
페이지 삭제 → 연결된 섹션도 삭제 → 연결된 필드도 삭제
|
||||
```
|
||||
|
||||
### 3.2 변경 후 (unlink만)
|
||||
|
||||
```
|
||||
페이지 삭제 → entity_relationships에서 링크만 제거
|
||||
→ 섹션은 섹션 탭에 유지
|
||||
→ 필드는 항목 탭에 유지
|
||||
```
|
||||
|
||||
**UI에서 "삭제" vs "연결 해제" 구분**:
|
||||
- 연결 해제: 현재 페이지/섹션에서만 제거, 원본 유지
|
||||
- 실제 삭제: 엔티티 자체를 삭제 (모든 곳에서 사라짐)
|
||||
|
||||
---
|
||||
|
||||
## 4. 데이터 통일
|
||||
|
||||
### 4.1 필드 속성 공유
|
||||
|
||||
**현재 문제**:
|
||||
- 마스터 항목에서 `is_required` 설정
|
||||
- 계층구조/섹션에서는 복사본이라 반영 안됨
|
||||
|
||||
**해결**:
|
||||
- 이제 같은 `item_fields` 레코드를 링크로 참조
|
||||
- 한 곳에서 수정 → 모든 곳에 반영
|
||||
|
||||
### 4.2 UI 표시 통일
|
||||
|
||||
| 탭 | 데이터 소스 | 용도 |
|
||||
|---|---|---|
|
||||
| 항목 탭 | `itemMasterFields` (Context) | 독립 필드 CRUD |
|
||||
| 속성 탭 | `itemMasterFields` + 로컬 옵션 state | 단위/재질/표면처리 + 필드 연동 |
|
||||
| 계층구조 탭 | `entity_relationships` → `item_fields` | 페이지-섹션-필드 구조 |
|
||||
| 섹션 탭 | `entity_relationships` → `item_fields` | 섹션-필드 연결 관리 |
|
||||
|
||||
**핵심**: 모든 탭이 `item_fields`를 공유 → 어디서 수정해도 전체 반영!
|
||||
|
||||
---
|
||||
|
||||
## 5. 작업 체크리스트
|
||||
|
||||
### Phase 1: 타입 및 API 수정 ✅ 완료
|
||||
- [x] `item-master-api.ts` 타입 정의 수정 ✅
|
||||
- [x] `lib/api/item-master.ts` API 함수 추가 ✅
|
||||
|
||||
### Phase 2: Context 수정 ✅ 완료
|
||||
- [x] `ItemMasterContext.tsx` 상태 및 함수 수정 ✅
|
||||
|
||||
### Phase 3: UI 컴포넌트 수정 ✅ 완료
|
||||
- [x] 계층구조 탭 - 필드 삭제 → unlink 변경 ✅
|
||||
- [x] 섹션 탭 - handleDeleteTemplateField → unlinkFieldFromSection API 호출 ✅ (2025-11-27)
|
||||
- [x] 항목 탭 - 데이터 소스 변경 + UI 텍스트 변경 ✅ (다이얼로그 포함 완료)
|
||||
- [x] 속성 탭 - 동일 데이터 소스 사용 (통합됨) ✅
|
||||
- [x] ImportFieldDialog - 탭 통합 완료 (항목/독립필드 → 필드) ✅ (2025-11-27)
|
||||
|
||||
### Phase 4: 테스트
|
||||
- [ ] 페이지 삭제 시 섹션 유지 확인
|
||||
- [ ] 섹션에서 필드 제거 시 항목 탭에 유지 확인
|
||||
- [ ] 필드 속성 변경 시 모든 탭에 반영 확인
|
||||
|
||||
---
|
||||
|
||||
## 6. 관련 파일
|
||||
|
||||
### 프론트엔드
|
||||
- `src/types/item-master-api.ts`
|
||||
- `src/lib/api/item-master.ts`
|
||||
- `src/contexts/ItemMasterContext.tsx`
|
||||
- `src/components/items/ItemMasterDataManagement.tsx`
|
||||
- `src/components/items/ItemMasterDataManagement/tabs/HierarchyTab/`
|
||||
- `src/components/items/ItemMasterDataManagement/tabs/SectionsTab.tsx`
|
||||
- `src/components/items/ItemMasterDataManagement/tabs/MasterFieldTab/`
|
||||
- `src/components/items/ItemMasterDataManagement/dialogs/ImportFieldDialog.tsx`
|
||||
|
||||
### 백엔드 (참조)
|
||||
- `app/Models/ItemMaster/EntityRelationship.php`
|
||||
- `app/Swagger/v1/EntityRelationshipApi.php`
|
||||
- `routes/api.php`
|
||||
@@ -1,116 +0,0 @@
|
||||
# 품목기준관리 - API 대기 및 다음 작업
|
||||
|
||||
**작성일**: 2025-11-26
|
||||
**상태**: API 대기 중
|
||||
|
||||
---
|
||||
|
||||
## 1. 현재 대기 중인 API 작업
|
||||
|
||||
### 1.1 계층구조(페이지) 탭
|
||||
|
||||
| 기능 | 설명 | 상태 |
|
||||
|------|------|------|
|
||||
| 생성 데이터 연결 | 최하위 항목(필드)까지 공통으로 연결 | ⏳ 대기 |
|
||||
| 페이지 삭제 | 실제 삭제 (Soft Delete) | ⏳ 대기 |
|
||||
| 섹션 연결 끊기 | 삭제가 아닌 연결만 해제 (`page_id = null`) | ⏳ 대기 |
|
||||
| 항목 연결 끊기 | 삭제가 아닌 연결만 해제 | ⏳ 대기 |
|
||||
| 섹션 불러오기 | 기존 섹션 목록에서 선택하여 연결 | ⏳ 대기 |
|
||||
| 섹션 리스트 조회 | 연결 가능한 섹션 목록 표시 | ⏳ 대기 |
|
||||
| 항목 불러오기 | 기존 항목 목록에서 선택하여 연결 | ⏳ 대기 |
|
||||
| 항목 리스트 조회 | 연결 가능한 항목 목록 표시 | ⏳ 대기 |
|
||||
|
||||
### 1.2 섹션 탭
|
||||
|
||||
| 기능 | 설명 | 상태 |
|
||||
|------|------|------|
|
||||
| 항목 불러오기 | 마스터 항목에서 선택하여 추가 | ⏳ 대기 |
|
||||
| 항목 리스트 조회 | 추가 가능한 마스터 항목 목록 | ⏳ 대기 |
|
||||
|
||||
### 1.3 데이터 동기화
|
||||
|
||||
| 기능 | 설명 | 상태 |
|
||||
|------|------|------|
|
||||
| 개별 수정 시 연결된 데이터 동기화 | 마스터 항목 수정 → 연결된 모든 필드에 반영 | ⏳ 대기 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 삭제 vs 연결 끊기 정리
|
||||
|
||||
```
|
||||
[계층구조 탭에서]
|
||||
├─ 페이지 삭제 → 실제 삭제 (Soft Delete)
|
||||
├─ 섹션 제거 → 연결만 끊기 (page_id = null), 섹션 데이터는 유지
|
||||
└─ 항목 제거 → 연결만 끊기 (section_id = null), 항목 데이터는 유지
|
||||
|
||||
[섹션 탭에서]
|
||||
├─ 섹션 삭제 → 실제 삭제 (Soft Delete)
|
||||
└─ 항목 삭제 → 실제 삭제 (Soft Delete)
|
||||
|
||||
[마스터 항목 탭에서]
|
||||
└─ 마스터 항목 삭제 → 실제 삭제 (참조된 필드는 master_field_id = null)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 데이터 연결 구조
|
||||
|
||||
```
|
||||
마스터 항목 (master_fields)
|
||||
↓ 참조 (master_field_id)
|
||||
섹션 템플릿 항목 (template_fields) ←──┐
|
||||
↓ 복사 │
|
||||
섹션 내 항목 (fields) ───────────────┘
|
||||
↑ 소속 (section_id)
|
||||
섹션 (sections)
|
||||
↑ 소속 (page_id) - 연결/해제 가능
|
||||
페이지 (pages) = 품목유형별 필드 구성
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 작업 순서
|
||||
|
||||
### Step 1: 품목기준관리 API 연동 (현재 대기)
|
||||
- 위 1~3번 항목 API 연동
|
||||
- 품목기준관리 페이지 최종 완료
|
||||
|
||||
### Step 2: 품목관리 동적 렌더링 API 검토
|
||||
- 품목기준관리 완료 시점에 필요한 API 다시 검토
|
||||
- 추가 API 필요 여부 확인
|
||||
- `[API-2025-11-24] item-management-dynamic-api-spec.md` 업데이트
|
||||
|
||||
### Step 3: 품목관리 페이지 동적 렌더링 구현
|
||||
|
||||
```
|
||||
품목 등록 페이지 (/items/create)
|
||||
│
|
||||
├─ 품목유형 선택 (FG, PT, SM, RM, CS)
|
||||
│
|
||||
├─ GET /api/v1/item-master/pages?item_type={선택된유형}
|
||||
│
|
||||
└─ 응답받은 섹션/필드 구조로 동적 폼 생성
|
||||
```
|
||||
|
||||
### 4.2 참고 문서
|
||||
|
||||
- `claudedocs/[API-2025-11-25] item-master-data-management-api-request.md`
|
||||
- `claudedocs/[API-2025-11-24] item-management-dynamic-api-spec.md`
|
||||
- `src/types/item-master-api.ts`
|
||||
- `src/lib/api/item-master.ts`
|
||||
|
||||
---
|
||||
|
||||
## 5. 핵심 개념 (잊지 말 것!)
|
||||
|
||||
> **"페이지"는 실제 URL 경로가 아니라, 품목유형별 필드 구성 템플릿이다!**
|
||||
|
||||
```
|
||||
품목기준관리의 "페이지"
|
||||
= 품목유형(FG, PT, SM, RM, CS)별로
|
||||
= 품목 등록 시 어떤 섹션/필드를 보여줄지 정의하는 템플릿
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**다음 세션 시작 시**: 이 문서 확인 후 API 상태 체크하고 작업 진행
|
||||
135
claudedocs/_index.md
Normal file
135
claudedocs/_index.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# claudedocs 문서 맵
|
||||
|
||||
> 프로젝트 기술 문서 인덱스 (Last Updated: 2025-11-27)
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```
|
||||
claudedocs/
|
||||
├── _index.md # 이 파일 - 문서 맵
|
||||
├── auth/ # 🔐 인증 & 토큰 관리
|
||||
├── item-master/ # 📦 품목기준관리
|
||||
├── dashboard/ # 📊 대시보드 & 사이드바
|
||||
├── api/ # 🔌 API 통합
|
||||
├── guides/ # 📚 범용 가이드
|
||||
├── architecture/ # 🏗️ 아키텍처 & 시스템
|
||||
└── archive/ # 📁 레거시/완료된 문서
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 auth/ - 인증 & 토큰 관리
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `token-management-guide.md` | ⭐ **핵심** - Access/Refresh Token 완전 가이드 |
|
||||
| `jwt-cookie-authentication-final.md` | JWT + HttpOnly Cookie 구현 |
|
||||
| `auth-guard-usage.md` | AuthGuard 훅 사용법 |
|
||||
| `route-protection-architecture.md` | 라우트 보호 아키텍처 |
|
||||
| `middleware-issue-resolution.md` | 미들웨어 이슈 해결 |
|
||||
| `safari-cookie-compatibility.md` | Safari 쿠키 호환성 |
|
||||
| `httponly-cookie-implementation.md` | HttpOnly 쿠키 구현 계획 |
|
||||
| `httponly-cookie-security-validation.md` | 보안 검증 케이스 |
|
||||
| `session-migration-*.md` | 세션 마이그레이션 관련 |
|
||||
| `nextjs15-middleware-*.md` | Next.js 15 미들웨어 연구 |
|
||||
|
||||
---
|
||||
|
||||
## 📦 item-master/ - 품목기준관리
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `[IMPL-2025-11-27] realtime-sync-fixes.md` | ⭐ **최신** - 실시간 동기화 수정 (BOM, 섹션 복제, 항목 수정) |
|
||||
| `item-master-api-pending-tasks.md` | 진행중인 API 연동 작업 |
|
||||
| `item-master-pending-integration.md` | 대기중인 통합 작업 |
|
||||
| `item-master-specification.md` | API 명세 |
|
||||
| `item-master-backend-requirements.md` | 백엔드 요구사항 |
|
||||
| `item-management-dynamic-api-spec.md` | 동적 필드 API 스펙 |
|
||||
| `item-management-dynamic-frontend.md` | 동적 필드 프론트엔드 설계 |
|
||||
| `item-master-data-management.md` | 데이터 관리 분석 |
|
||||
| `item-master-hooks-refactoring.md` | Hooks 리팩토링 |
|
||||
| `ITEM-MANAGEMENT-MIGRATION.md` | 마이그레이션 가이드 |
|
||||
|
||||
---
|
||||
|
||||
## 📊 dashboard/ - 대시보드 & 사이드바
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `dashboard-integration-complete.md` | 대시보드 통합 완료 |
|
||||
| `dashboard-cleanup-summary.md` | 정리 요약 |
|
||||
| `dashboard-migration-summary.md` | 마이그레이션 요약 |
|
||||
| `sidebar-active-menu-sync.md` | 사이드바 메뉴 동기화 |
|
||||
| `sidebar-scroll-improvements.md` | 스크롤 개선 |
|
||||
|
||||
---
|
||||
|
||||
## 🔌 api/ - API 통합
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `api-requirements.md` | API 요구사항 |
|
||||
| `api-analysis.md` | API 분석 |
|
||||
| `api-route-type-safety.md` | 라우트 타입 안전성 |
|
||||
| `api-key-management.md` | API 키 관리 |
|
||||
|
||||
---
|
||||
|
||||
## 📚 guides/ - 범용 가이드
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `i18n-usage-guide.md` | 다국어 사용 가이드 |
|
||||
| `form-validation-guide.md` | 폼 유효성 검사 |
|
||||
| `CSS-MIGRATION-WORKFLOW.md` | CSS 마이그레이션 워크플로우 |
|
||||
| `LARGE-FILE-WORKFLOW.md` | 대용량 파일 작업 워크플로우 |
|
||||
| `ZOD-VALIDATION-TROUBLESHOOTING.md` | Zod 유효성 검사 트러블슈팅 |
|
||||
| `nextjs-error-handling-guide.md` | Next.js 에러 처리 |
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ architecture/ - 아키텍처 & 시스템
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `multi-tenancy-implementation.md` | 멀티테넌시 구현 |
|
||||
| `multi-tenancy-test-guide.md` | 멀티테넌시 테스트 |
|
||||
| `architecture-integration-risks.md` | 통합 리스크 |
|
||||
| `browser-support-policy.md` | 브라우저 지원 정책 |
|
||||
| `ssr-hydration-fix.md` | SSR 하이드레이션 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 📁 archive/ - 레거시/완료된 문서
|
||||
|
||||
완료되거나 더 이상 활성화되지 않은 문서들. 참조용으로 보관.
|
||||
|
||||
---
|
||||
|
||||
## 문서 작성 규칙
|
||||
|
||||
### 파일명 컨벤션
|
||||
```
|
||||
[TYPE-YYYY-MM-DD] description.md
|
||||
```
|
||||
|
||||
**TYPE 종류**:
|
||||
- `IMPL` - 구현 문서
|
||||
- `API` - API 명세/요청
|
||||
- `GUIDE` - 사용 가이드
|
||||
- `REF` - 참조 문서
|
||||
- `ANALYSIS` - 분석 노트
|
||||
- `PLAN` - 계획 문서
|
||||
- `DESIGN` - 설계 문서
|
||||
- `TEST` - 테스트 가이드
|
||||
- `NEXT` - 다음 작업 목록
|
||||
|
||||
### 폴더 배치 기준
|
||||
1. **기능/도메인 우선**: 문서 주제에 맞는 폴더에 배치
|
||||
2. **범용 가이드**: 여러 기능에 적용되면 `guides/`에 배치
|
||||
3. **완료된 작업**: 더 이상 활성화되지 않으면 `archive/`로 이동
|
||||
4. **신규 도메인**: 3개 이상 문서가 생기면 새 폴더 생성 고려
|
||||
|
||||
### 문서 업데이트
|
||||
- 중요 변경 시 문서 상단에 날짜와 함께 변경사항 기록
|
||||
- `_index.md`에 새 문서 추가 시 테이블 업데이트
|
||||
@@ -303,4 +303,18 @@ API Key 설정 완료 후:
|
||||
|
||||
- **API Key 발급**: PHP 백엔드 팀
|
||||
- **기술 지원**: 프론트엔드 팀
|
||||
- **보안 문제**: DevOps/보안 팀
|
||||
- **보안 문제**: DevOps/보안 팀
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 프론트엔드
|
||||
- `src/lib/api/auth/api-key-client.ts` - API Key 클라이언트
|
||||
- `src/lib/api/auth/api-key-validator.ts` - API Key 검증 유틸리티
|
||||
- `src/app/api/sync/route.ts` - 서버 사이드 API Route 예시
|
||||
|
||||
### 설정 파일
|
||||
- `.env.local` - 환경 변수 (API_KEY 저장)
|
||||
- `.env.example` - 환경 변수 템플릿
|
||||
- `.gitignore` - Git 제외 설정
|
||||
@@ -319,3 +319,16 @@ interface Response {
|
||||
**작성일:** 2025-11-11
|
||||
**작성자:** Claude Code
|
||||
**마지막 수정:** 2025-11-11
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 프론트엔드
|
||||
- `src/app/api/auth/login/route.ts` - 로그인 API Route
|
||||
- `src/types/auth.ts` - 인증 타입 정의
|
||||
- `src/lib/api/auth/types.ts` - API 인증 타입
|
||||
|
||||
### 참조 문서
|
||||
- `claudedocs/auth/[IMPL-2025-11-07] authentication-implementation-guide.md`
|
||||
- `claudedocs/auth/[IMPL-2025-11-10] token-management-guide.md`
|
||||
@@ -324,4 +324,19 @@ curl -X GET https://api.5130.co.kr/api/user \
|
||||
|
||||
하지만 최종 결정은 백엔드 아키텍처와 요구사항에 따라야 합니다!
|
||||
|
||||
**백엔드 개발자에게 이 문서 공유 후 협의 추천** 👍
|
||||
**백엔드 개발자에게 이 문서 공유 후 협의 추천** 👍
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 프론트엔드
|
||||
- `src/lib/api/client.ts` - 통합 HTTP 클라이언트
|
||||
- `src/lib/api/auth/token-storage.ts` - Token 저장 관리
|
||||
- `src/lib/api/auth/auth-config.ts` - 인증 설정
|
||||
- `src/middleware.ts` - 인증 미들웨어
|
||||
- `src/contexts/AuthContext.tsx` - 인증 상태 관리
|
||||
|
||||
### 설정 파일
|
||||
- `.env.local` - 환경 변수
|
||||
- `next.config.ts` - Next.js 설정
|
||||
@@ -417,4 +417,20 @@ interface RegisterData {
|
||||
|
||||
---
|
||||
|
||||
**API 준비되면 바로 알려주세요! 🚀**
|
||||
**API 준비되면 바로 알려주세요! 🚀**
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 프론트엔드
|
||||
- `src/lib/api/client.ts` - 통합 HTTP 클라이언트
|
||||
- `src/lib/api/auth/sanctum-client.ts` - Sanctum 클라이언트
|
||||
- `src/lib/api/auth/auth-config.ts` - 인증 설정 (라우트, URL)
|
||||
- `src/middleware.ts` - 인증 미들웨어
|
||||
- `src/app/[locale]/(auth)/login/page.tsx` - 로그인 페이지
|
||||
- `src/app/[locale]/(auth)/signup/page.tsx` - 회원가입 페이지
|
||||
|
||||
### 설정 파일
|
||||
- `.env.local` - 환경 변수 (API URL, API Key)
|
||||
- `next.config.ts` - Next.js 설정
|
||||
@@ -495,4 +495,22 @@ if (typeof feature === 'undefined') {
|
||||
2. **크로스 브라우저 테스트 필수**: Chrome, Safari, Edge
|
||||
3. **사용자 친화적 안내**: IE 사용자에게 명확한 업그레이드 안내
|
||||
|
||||
**문의**: 고객센터 또는 개발팀
|
||||
**문의**: 고객센터 또는 개발팀
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 프론트엔드
|
||||
- `src/middleware.ts` - IE 감지 및 차단 미들웨어 (isInternetExplorer 함수)
|
||||
- `public/unsupported-browser.html` - 브라우저 업그레이드 안내 페이지
|
||||
- `src/lib/api/auth/token-storage.ts` - Safari 호환 토큰 저장소
|
||||
|
||||
### 설정 파일
|
||||
- `next.config.ts` - Next.js 브라우저 타겟 설정
|
||||
- `package.json` - 브라우저 호환 의존성 (next, react 버전)
|
||||
- `tsconfig.json` - TypeScript 타겟 설정
|
||||
|
||||
### 참조 문서
|
||||
- `claudedocs/auth/[IMPL-2025-11-13] safari-cookie-compatibility.md` - Safari 쿠키 호환성
|
||||
- `claudedocs/architecture/[IMPL-2025-11-18] ssr-hydration-fix.md` - SSR/Hydration 에러 해결
|
||||
@@ -90,4 +90,17 @@ useEffect(() => {
|
||||
|
||||
## 참고 문서
|
||||
- Next.js SSR/Hydration: https://nextjs.org/docs/messages/react-hydration-error
|
||||
- React useEffect: https://react.dev/reference/react/useEffect
|
||||
- React useEffect: https://react.dev/reference/react/useEffect
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 프론트엔드
|
||||
- `src/contexts/AuthContext.tsx` - SSR-safe 패턴 적용된 인증 Context
|
||||
- `src/contexts/ItemMasterContext.tsx` - SSR-safe 패턴 적용된 품목 마스터 Context (13개 state)
|
||||
- `src/components/items/ItemMasterDataManagement.tsx` - 품목기준관리 컴포넌트
|
||||
|
||||
### 참조 문서
|
||||
- `claudedocs/auth/[IMPL-2025-11-07] authentication-implementation-guide.md` - 인증 구현 가이드
|
||||
- `claudedocs/architecture/[REF-2025-11-19] multi-tenancy-implementation.md` - 멀티테넌시 구현 (localStorage 패턴)
|
||||
@@ -1023,4 +1023,23 @@ const response = await fetch(`/api/tenants/350/item-master-config`);
|
||||
|
||||
**문서 버전**: 1.1 (tenant.id 반영)
|
||||
**마지막 업데이트**: 2025-11-19
|
||||
**다음 리뷰**: Phase 1 완료 후
|
||||
**다음 리뷰**: Phase 1 완료 후
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 프론트엔드
|
||||
- `src/contexts/AuthContext.tsx` - 인증 및 테넌트 정보 관리
|
||||
- `src/contexts/ItemMasterContext.tsx` - 품목 마스터 데이터 Context (localStorage 사용)
|
||||
- `src/lib/cache/TenantAwareCache.ts` - 테넌트별 캐시 유틸리티 (구현 예정)
|
||||
- `src/middleware.ts` - 테넌트 식별 미들웨어
|
||||
|
||||
### 백엔드 (구현 예정)
|
||||
- `app/api/tenants/[tenantId]/item-master-config/route.ts` - 테넌트별 API 라우트
|
||||
- `backend/middleware/auth.ts` - 테넌트 접근 검증 미들웨어
|
||||
|
||||
### 참조 문서
|
||||
- `claudedocs/auth/[IMPL-2025-11-07] authentication-implementation-guide.md` - 인증 구현 가이드
|
||||
- `claudedocs/architecture/[REF] architecture-integration-risks.md` - 아키텍처 통합 위험 요소
|
||||
- `claudedocs/architecture/[IMPL-2025-11-18] ssr-hydration-fix.md` - SSR/Hydration 에러 해결
|
||||
@@ -842,4 +842,26 @@ const tenantId = createTenantId('acme-corp');
|
||||
**다음 리뷰**: 설계 가이드 통합 후 또는 주요 아키텍처 변경 시
|
||||
|
||||
**작성자**: Claude Code
|
||||
**승인 필요**: 프로젝트 매니저, 시니어 개발자
|
||||
**승인 필요**: 프로젝트 매니저, 시니어 개발자
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 프론트엔드
|
||||
- `src/middleware.ts` - 통합 미들웨어 (i18n, 인증, 봇 차단)
|
||||
- `src/contexts/AuthContext.tsx` - 인증 상태 관리 Context
|
||||
- `src/contexts/ItemMasterContext.tsx` - 품목 마스터 데이터 Context
|
||||
- `src/lib/api/client.ts` - 통합 HTTP 클라이언트
|
||||
- `src/i18n/routing.ts` - 다국어 라우팅 설정
|
||||
- `src/messages/*.json` - 다국어 번역 파일 (ko, en, ja)
|
||||
|
||||
### 설정 파일
|
||||
- `next.config.ts` - Next.js 설정
|
||||
- `.env.local` - 환경 변수 (API URL, 인증 설정)
|
||||
- `tsconfig.json` - TypeScript 설정
|
||||
- `tailwind.config.ts` - Tailwind CSS 설정
|
||||
|
||||
### 참조 문서
|
||||
- `claudedocs/auth/[IMPL-2025-11-07] authentication-implementation-guide.md` - 인증 구현 가이드
|
||||
- `claudedocs/architecture/[REF-2025-11-19] multi-tenancy-implementation.md` - 멀티테넌시 구현
|
||||
@@ -317,3 +317,19 @@ export default function Page() {
|
||||
- 브라우저 캐시 악용 방지
|
||||
- 실시간 인증 상태 동기화
|
||||
- 로그아웃 후 완전한 페이지 접근 차단
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 프론트엔드
|
||||
- `src/hooks/useAuthGuard.ts` - Auth Guard Hook 구현
|
||||
- `src/app/api/auth/check/route.ts` - 인증 체크 API
|
||||
- `src/app/[locale]/(protected)/layout.tsx` - Protected Layout
|
||||
- `src/middleware.ts` - 인증 미들웨어
|
||||
- `src/lib/api/auth/auth-config.ts` - 인증 설정 (라우트)
|
||||
|
||||
### 보호된 페이지
|
||||
- `src/app/[locale]/(protected)/dashboard/page.tsx`
|
||||
- `src/app/[locale]/(protected)/profile/page.tsx`
|
||||
- `src/app/[locale]/(protected)/settings/page.tsx`
|
||||
@@ -307,4 +307,22 @@ const user = await bearerClient.login({
|
||||
- [Laravel Sanctum 공식 문서](https://laravel.com/docs/sanctum)
|
||||
- [Next.js Middleware 문서](https://nextjs.org/docs/app/building-your-application/routing/middleware)
|
||||
- [claudedocs/authentication-design.md](./authentication-design.md)
|
||||
- [claudedocs/api-requirements.md](./api-requirements.md)
|
||||
- [claudedocs/api-requirements.md](./api-requirements.md)
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 프론트엔드
|
||||
- `src/lib/api/client.ts` - 통합 HTTP Client
|
||||
- `src/lib/api/auth/types.ts` - 인증 타입 정의
|
||||
- `src/lib/api/auth/auth-config.ts` - 인증 설정
|
||||
- `src/lib/api/auth/sanctum-client.ts` - Sanctum 전용 클라이언트
|
||||
- `src/lib/api/auth/bearer-client.ts` - Bearer 토큰 클라이언트
|
||||
- `src/lib/api/auth/api-key-client.ts` - API Key 클라이언트
|
||||
- `src/contexts/AuthContext.tsx` - 클라이언트 인증 상태 관리
|
||||
- `src/middleware.ts` - 통합 미들웨어
|
||||
|
||||
### 설정 파일
|
||||
- `.env.local` - 환경 변수
|
||||
- `.env.example` - 환경 변수 템플릿
|
||||
@@ -488,4 +488,21 @@ headers: { 'Authorization': `Bearer ${token}` }
|
||||
- POST /api/v1/logout
|
||||
4. 🚀 구현 시작 (2-3시간)
|
||||
|
||||
**준비되면 바로 시작합니다!** 🎯
|
||||
**준비되면 바로 시작합니다!** 🎯
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 프론트엔드
|
||||
- `src/middleware.ts` - 인증 미들웨어
|
||||
- `src/lib/api/auth/auth-config.ts` - 인증 설정 (라우트, URL)
|
||||
- `src/lib/api/auth/token-storage.ts` - Token 저장 관리
|
||||
- `src/lib/api/auth/jwt-client.ts` - JWT 클라이언트
|
||||
- `src/contexts/AuthContext.tsx` - 클라이언트 인증 상태 관리
|
||||
- `src/app/[locale]/(auth)/login/page.tsx` - 로그인 페이지
|
||||
- `src/app/[locale]/(protected)/dashboard/page.tsx` - 보호된 페이지
|
||||
|
||||
### 설정 파일
|
||||
- `.env.local` - 환경 변수 (API URL, API Key)
|
||||
- `next.config.ts` - Next.js 설정
|
||||
@@ -199,7 +199,15 @@ HttpOnly 쿠키 삭제
|
||||
**참고**:
|
||||
- 🔵 **Next.js 내부 API** (PHP 백엔드 X)
|
||||
- 성능 최적화: 로컬 쿠키만 확인하여 빠른 응답
|
||||
- 로그인/회원가입 페이지에서 이미 로그인된 사용자를 대시보드로 리다이렉트하는 데 사용
|
||||
|
||||
> ⚠️ **2025-11-27 변경사항**:
|
||||
> - `LoginPage.tsx`에서 auth/check 호출 제거됨
|
||||
> - **제거 이유**:
|
||||
> 1. 미들웨어(`middleware.ts`)에서 이미 동일한 처리를 함 (guestOnlyRoutes 리다이렉트)
|
||||
> 2. 401 응답이 Network 탭에 에러로 표시되어 백엔드 개발자 혼란 유발
|
||||
> 3. 불필요한 API 호출로 인한 성능 저하
|
||||
> - **대체 방안**: 미들웨어가 서버 사이드에서 쿠키 체크 후 리다이렉트 처리
|
||||
> - 참고: `src/components/auth/LoginPage.tsx` 주석 참조
|
||||
|
||||
---
|
||||
|
||||
@@ -239,7 +247,40 @@ if (refreshToken && !accessToken) {
|
||||
}
|
||||
```
|
||||
|
||||
### 3. API Client에서 자동 갱신
|
||||
### 3. Proxy에서 자동 갱신 (✅ 2025-11-27 구현)
|
||||
|
||||
`src/app/api/proxy/[...path]/route.ts`:
|
||||
```typescript
|
||||
// 401 응답 시 자동 토큰 갱신 후 재시도
|
||||
if (backendResponse.status === 401 && refreshToken) {
|
||||
const refreshResult = await refreshAccessToken(refreshToken);
|
||||
|
||||
if (refreshResult.success && refreshResult.accessToken) {
|
||||
// 새 토큰으로 원래 요청 재시도
|
||||
token = refreshResult.accessToken;
|
||||
backendResponse = await executeBackendRequest(url, method, token, body, contentType);
|
||||
|
||||
// 새 토큰을 쿠키에 저장
|
||||
createTokenCookies(newTokens).forEach(cookie => {
|
||||
clientResponse.headers.append('Set-Cookie', cookie);
|
||||
});
|
||||
} else {
|
||||
// 리프레시 실패 → 쿠키 삭제 후 401 반환
|
||||
return NextResponse.json({ error: 'Authentication failed', needsReauth: true }, { status: 401 });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**동작 방식**:
|
||||
1. 백엔드 API 호출 (access_token 사용)
|
||||
2. 401 Unauthorized 응답 받음
|
||||
3. refresh_token으로 `/api/v1/refresh` 호출
|
||||
4. 성공 시: 새 토큰으로 원래 요청 재시도 + 쿠키 업데이트
|
||||
5. 실패 시: 쿠키 삭제 + `needsReauth: true` 응답
|
||||
|
||||
> **장점**: 프론트엔드 코드 수정 없이 모든 `/api/proxy/*` 요청에 자동 토큰 갱신 적용
|
||||
|
||||
### 4. API Client에서 자동 갱신 (Legacy)
|
||||
|
||||
`src/lib/api/client.ts`:
|
||||
```typescript
|
||||
@@ -256,6 +297,8 @@ const data = await withTokenRefresh(() =>
|
||||
4. 성공 시 원래 API 재시도
|
||||
5. 실패 시 로그인 페이지로 리다이렉트
|
||||
|
||||
> **참고**: 대부분의 API 호출은 프록시를 통해 자동 갱신되므로 직접 사용할 필요 없음
|
||||
|
||||
---
|
||||
|
||||
## 사용 예시
|
||||
@@ -374,4 +374,18 @@ NEXT_PUBLIC_AUTH_MODE=sanctum
|
||||
- 로그인/로그아웃 플로우
|
||||
- HttpOnly 쿠키 동작 확인
|
||||
- 비로그인 상태 차단 확인
|
||||
- XSS 방어 검증
|
||||
- XSS 방어 검증
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 프론트엔드
|
||||
- `src/app/api/auth/login/route.ts` - 로그인 프록시 API
|
||||
- `src/app/api/auth/logout/route.ts` - 로그아웃 프록시 API
|
||||
- `src/components/auth/LoginPage.tsx` - 로그인 페이지 컴포넌트
|
||||
- `src/middleware.ts` - 인증 미들웨어
|
||||
- `src/app/[locale]/dashboard/page.tsx` - 대시보드 (로그아웃 버튼)
|
||||
|
||||
### 설정 파일
|
||||
- `.env.local` - 환경 변수 (API URL, API Key)
|
||||
@@ -189,3 +189,24 @@ npm run dev
|
||||
✅ **역할 기반 시스템**: 5가지 역할별 대시보드가 동작함
|
||||
|
||||
이제 `npm run dev`로 개발 서버를 실행하고 로그인하면 새로운 역할 기반 대시보드를 확인할 수 있습니다!
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 프론트엔드
|
||||
- `src/app/[locale]/(protected)/dashboard/layout.tsx` - 대시보드 레이아웃
|
||||
- `src/app/[locale]/(protected)/dashboard/page.tsx` - 역할 기반 대시보드 페이지
|
||||
- `src/layouts/DashboardLayout.tsx` - 대시보드 레이아웃 컴포넌트
|
||||
- `src/components/business/Dashboard.tsx` - 대시보드 라우터
|
||||
- `src/components/business/CEODashboard.tsx` - CEO 대시보드
|
||||
- `src/components/business/ProductionManagerDashboard.tsx` - 생산관리자 대시보드
|
||||
- `src/components/business/WorkerDashboard.tsx` - 작업자 대시보드
|
||||
- `src/components/business/SystemAdminDashboard.tsx` - 시스템관리자 대시보드
|
||||
- `src/components/business/SalesLeadDashboard.tsx` - 영업 대시보드
|
||||
- `src/components/auth/LoginPage.tsx` - 로그인 페이지 (localStorage 저장)
|
||||
- `src/hooks/useUserRole.ts` - 역할 관리 훅
|
||||
|
||||
### 참조 문서
|
||||
- `claudedocs/dashboard/[REF] dashboard-migration-summary.md` - 대시보드 마이그레이션 요약
|
||||
- `claudedocs/auth/[IMPL-2025-11-07] authentication-implementation-guide.md` - 인증 구현 가이드
|
||||
@@ -183,3 +183,15 @@ const handleLogout = async () => {
|
||||
✅ **UI 개선**: 깔끔하고 명확한 헤더 레이아웃
|
||||
|
||||
대시보드 레이아웃이 프로덕션에 적합한 상태로 정리되었습니다!
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 프론트엔드
|
||||
- `src/layouts/DashboardLayout.tsx` - 대시보드 레이아웃 (역할 선택 제거, 로그아웃 버튼 추가)
|
||||
- `src/app/[locale]/(protected)/dashboard/page.tsx` - 대시보드 페이지
|
||||
- `src/app/[locale]/(protected)/dashboard/page.tsx.backup` - 기존 페이지 백업
|
||||
|
||||
### 참조 문서
|
||||
- `claudedocs/dashboard/[IMPL-2025-11-10] dashboard-integration-complete.md` - 대시보드 통합 완료 보고서
|
||||
@@ -580,4 +580,17 @@ const normalizedPath = pathname.replace(/^\/(ko|en|ja)/, '');
|
||||
|
||||
**작성일:** 2025-11-11
|
||||
**작성자:** Claude Code
|
||||
**마지막 수정:** 2025-11-11
|
||||
**마지막 수정:** 2025-11-11
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 프론트엔드
|
||||
- `src/layouts/DashboardLayout.tsx` - usePathname 훅으로 경로 기반 메뉴 활성화
|
||||
- `src/components/layout/Sidebar.tsx` - 사이드바 컴포넌트
|
||||
- `src/store/menuStore.ts` - 메뉴 상태 관리 (Zustand)
|
||||
|
||||
### 참조 문서
|
||||
- `claudedocs/dashboard/[IMPL-2025-11-13] sidebar-scroll-improvements.md` - 사이드바 스크롤 개선
|
||||
- `claudedocs/architecture/[REF] architecture-integration-risks.md` - 미들웨어 아키텍처
|
||||
@@ -400,4 +400,17 @@ useEffect(() => {
|
||||
3. **미니멀리즘**: 필요할 때만 UI 요소 표시 (스크롤바)
|
||||
4. **성능**: 불필요한 리렌더링과 스크롤 방지
|
||||
|
||||
이러한 작은 개선들이 모여 전체적인 사용자 만족도를 크게 향상시킬 수 있습니다.
|
||||
이러한 작은 개선들이 모여 전체적인 사용자 만족도를 크게 향상시킬 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 프론트엔드
|
||||
- `src/components/layout/Sidebar.tsx` - 사이드바 컴포넌트 (스크롤 및 ref 처리)
|
||||
- `src/layouts/DashboardLayout.tsx` - 대시보드 레이아웃 (sticky, 경로 매칭)
|
||||
- `src/app/globals.css` - macOS 스타일 스크롤바 CSS (301-344 라인)
|
||||
|
||||
### 참조 문서
|
||||
- `claudedocs/dashboard/[IMPL-2025-11-11] sidebar-active-menu-sync.md` - 메뉴 활성화 동기화
|
||||
- `claudedocs/architecture/[IMPL-2025-11-13] browser-support-policy.md` - 브라우저 지원 정책
|
||||
@@ -147,3 +147,24 @@ To test the migration:
|
||||
3. Test role switching via dropdown
|
||||
4. Verify each dashboard loads correctly
|
||||
5. Check responsive design (mobile/desktop)
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 프론트엔드
|
||||
- `src/components/business/Dashboard.tsx` - 대시보드 라우터 (lazy loading)
|
||||
- `src/components/business/CEODashboard.tsx` - CEO 대시보드
|
||||
- `src/components/business/ProductionManagerDashboard.tsx` - 생산관리자 대시보드
|
||||
- `src/components/business/WorkerDashboard.tsx` - 작업자 대시보드
|
||||
- `src/components/business/SystemAdminDashboard.tsx` - 시스템관리자 대시보드
|
||||
- `src/components/business/SalesLeadDashboard.tsx` - 영업 대시보드
|
||||
- `src/layouts/DashboardLayout.tsx` - 대시보드 레이아웃
|
||||
- `src/components/layout/Sidebar.tsx` - 사이드바 컴포넌트
|
||||
- `src/hooks/useUserRole.ts` - 역할 관리 훅
|
||||
- `src/hooks/useCurrentTime.ts` - 현재 시간 훅
|
||||
- `src/store/menuStore.ts` - 메뉴 상태 관리 (Zustand)
|
||||
- `src/store/themeStore.ts` - 테마 상태 관리 (Zustand)
|
||||
|
||||
### 참조 문서
|
||||
- `claudedocs/dashboard/[IMPL-2025-11-10] dashboard-integration-complete.md` - 대시보드 통합 완료
|
||||
@@ -409,4 +409,18 @@
|
||||
## 버전 히스토리
|
||||
|
||||
- **v1.0** (2025-11-17): 초안 작성, 4가지 방법론 정의
|
||||
- **v2.0** (2025-11-17): 실험 완료, 베스트 프랙티스 확립, 표준 워크플로우 정립
|
||||
- **v2.0** (2025-11-17): 실험 완료, 베스트 프랙티스 확립, 표준 워크플로우 정립
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 프론트엔드
|
||||
- `src/components/items/ItemListClient.tsx` - 품목 관리 리스트 페이지 (마이그레이션 대상)
|
||||
- `src/components/ui/tabs.tsx` - Tabs UI 컴포넌트
|
||||
|
||||
### 참조 소스 (React 원본)
|
||||
- `sma-react-v2.0/src/components/ItemManagement.tsx` - React 원본 파일
|
||||
|
||||
### 참조 문서
|
||||
- `claudedocs/guides/[GUIDE] LARGE-FILE-WORKFLOW.md` - 대용량 파일 작업 워크플로우
|
||||
@@ -548,3 +548,17 @@ AI:
|
||||
- v1.1.0 (2025-01-15): Phase 4 추가 - 복잡한 다중 작업 처리 프로토콜
|
||||
- 이유: 여러 요구사항 동시 처리 시 누락 발생 방지
|
||||
- 목적: TodoWrite 기반 체계적 작업 분해 및 순차 실행
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 프론트엔드
|
||||
- `src/components/items/ItemListClient.tsx` - 품목 관리 리스트 페이지
|
||||
- `src/components/items/ItemForm.tsx` - 품목 등록/수정 폼
|
||||
|
||||
### 참조 소스 (React 원본)
|
||||
- `sma-react-v2.0/src/components/ItemManagement.tsx` - React 원본 파일 (2600줄)
|
||||
|
||||
### 참조 문서
|
||||
- `claudedocs/guides/[GUIDE] CSS-MIGRATION-WORKFLOW.md` - CSS 마이그레이션 워크플로우
|
||||
@@ -657,6 +657,11 @@ const materialSchemaBase = itemMasterBaseSchema
|
||||
Claude Code
|
||||
|
||||
## 관련 파일
|
||||
- `/src/lib/utils/validation.ts`
|
||||
- `/src/components/items/ItemForm.tsx`
|
||||
- `/src/types/item.ts`
|
||||
|
||||
### 프론트엔드
|
||||
- `src/lib/utils/validation.ts` - Zod 유효성 검증 스키마 정의
|
||||
- `src/components/items/ItemForm.tsx` - 품목 등록/수정 폼 컴포넌트
|
||||
- `src/types/item.ts` - 품목 타입 정의
|
||||
|
||||
### 참조 문서
|
||||
- `claudedocs/guides/[IMPL-2025-11-07] form-validation-guide.md` - 폼 및 유효성 검증 가이드
|
||||
@@ -735,4 +735,24 @@ export default function ClientComponent() {
|
||||
|
||||
**문서 작성일**: 2025-11-06
|
||||
**작성자**: Claude Code
|
||||
**프로젝트**: Multi-tenant ERP System
|
||||
**프로젝트**: Multi-tenant ERP System
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 프론트엔드
|
||||
- `src/i18n/config.ts` - i18n 설정 (지원 언어, 기본 언어)
|
||||
- `src/i18n/request.ts` - 서버사이드 메시지 로딩
|
||||
- `src/messages/ko.json` - 한국어 메시지
|
||||
- `src/messages/en.json` - 영어 메시지
|
||||
- `src/messages/ja.json` - 일본어 메시지
|
||||
- `src/middleware.ts` - 로케일 감지 + 봇 차단 미들웨어
|
||||
- `src/app/[locale]/layout.tsx` - 루트 레이아웃 (NextIntlClientProvider)
|
||||
- `src/components/LanguageSwitcher.tsx` - 언어 전환 컴포넌트
|
||||
|
||||
### 설정 파일
|
||||
- `next.config.ts` - Next.js 설정 (next-intl 플러그인)
|
||||
|
||||
### 참조 문서
|
||||
- `claudedocs/architecture/[REF] architecture-integration-risks.md` - 아키텍처 통합 위험 분석
|
||||
@@ -1017,4 +1017,25 @@ describe('loginSchema', () => {
|
||||
**문서 유효기간**: 2025-11-06 ~
|
||||
**다음 업데이트**: 새로운 폼 패턴 추가 시
|
||||
|
||||
**작성자**: Claude Code
|
||||
**작성자**: Claude Code
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 프론트엔드
|
||||
- `src/lib/validation/auth.schema.ts` - 인증 관련 스키마
|
||||
- `src/lib/validation/product.schema.ts` - 제품 관련 스키마
|
||||
- `src/lib/utils/validation.ts` - 공통 유효성 검증 유틸리티
|
||||
- `src/lib/utils/form-error.ts` - 다국어 에러 메시지 유틸리티
|
||||
- `src/components/form/FormInput.tsx` - 재사용 가능 Input 컴포넌트
|
||||
- `src/components/form/FormSelect.tsx` - 재사용 가능 Select 컴포넌트
|
||||
- `src/components/items/ItemForm.tsx` - 품목 등록/수정 폼
|
||||
|
||||
### 다국어 메시지
|
||||
- `src/messages/ko.json` - 한국어 유효성 검증 메시지
|
||||
- `src/messages/en.json` - 영어 유효성 검증 메시지
|
||||
- `src/messages/ja.json` - 일본어 유효성 검증 메시지
|
||||
|
||||
### 참조 문서
|
||||
- `claudedocs/guides/[GUIDE] ZOD-VALIDATION-TROUBLESHOOTING.md` - Zod 유효성 검증 문제 해결
|
||||
@@ -703,4 +703,27 @@ export default function Error() {
|
||||
|
||||
## 마무리
|
||||
|
||||
이 가이드를 바탕으로 Next.js 15 App Router 프로젝트에 체계적인 에러 처리와 로딩 상태 관리를 구현할 수 있습니다. 파일 위치와 우선순위를 정확히 이해하고, 각 파일의 역할과 요구사항을 준수하여 사용자 경험을 개선하세요.
|
||||
이 가이드를 바탕으로 Next.js 15 App Router 프로젝트에 체계적인 에러 처리와 로딩 상태 관리를 구현할 수 있습니다. 파일 위치와 우선순위를 정확히 이해하고, 각 파일의 역할과 요구사항을 준수하여 사용자 경험을 개선하세요.
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 프론트엔드
|
||||
- `src/app/global-error.tsx` - 루트 레벨 에러 처리
|
||||
- `src/app/error.tsx` - 전역 에러 바운더리
|
||||
- `src/app/not-found.tsx` - 전역 404 페이지
|
||||
- `src/app/loading.tsx` - 전역 로딩 UI
|
||||
- `src/app/[locale]/error.tsx` - 로케일별 에러 처리
|
||||
- `src/app/[locale]/not-found.tsx` - 로케일별 404 페이지
|
||||
- `src/app/[locale]/loading.tsx` - 로케일별 로딩 UI
|
||||
- `src/app/[locale]/(protected)/error.tsx` - Protected 그룹 에러 처리
|
||||
- `src/app/[locale]/(protected)/loading.tsx` - Protected 그룹 로딩 UI
|
||||
|
||||
### 다국어 메시지
|
||||
- `src/messages/ko.json` - 한국어 에러/404 메시지
|
||||
- `src/messages/en.json` - 영어 에러/404 메시지
|
||||
- `src/messages/ja.json` - 일본어 에러/404 메시지
|
||||
|
||||
### 참조 문서
|
||||
- `claudedocs/guides/[IMPL-2025-11-06] i18n-usage-guide.md` - 다국어 설정 가이드
|
||||
1371
claudedocs/item-master/[ANALYSIS-2025-11-26] item-master-notes.md
Normal file
1371
claudedocs/item-master/[ANALYSIS-2025-11-26] item-master-notes.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -268,9 +268,19 @@ curl -X GET https://api.codebridge-x.com/item-master/init \
|
||||
|
||||
---
|
||||
|
||||
## 📎 참고 파일
|
||||
---
|
||||
|
||||
- API 클라이언트: `/src/lib/api/item-master.ts`
|
||||
- Context 정의: `/src/contexts/ItemMasterContext.tsx`
|
||||
- UI 컴포넌트: `/src/components/items/ItemMasterDataManagement/tabs/HierarchyTab/index.tsx`
|
||||
- 환경 변수: `/.env.local`
|
||||
## 관련 파일
|
||||
|
||||
### 프론트엔드
|
||||
- `src/lib/api/item-master.ts` - API 클라이언트
|
||||
- `src/contexts/ItemMasterContext.tsx` - Context 정의
|
||||
- `src/components/items/ItemMasterDataManagement/tabs/HierarchyTab/index.tsx` - UI 컴포넌트
|
||||
- `src/types/item-master-api.ts` - API 타입 정의
|
||||
|
||||
### 설정 파일
|
||||
- `.env.local` - 환경 변수
|
||||
|
||||
### 참조 문서
|
||||
- `claudedocs/item-master/[API-2025-11-24] item-management-dynamic-api-spec.md` - 동적 화면 API 명세
|
||||
- `claudedocs/item-master/[REF] api-requirements-items.md` - 품목 관리 API 요구사항
|
||||
@@ -1584,11 +1584,24 @@ php artisan l5-swagger:generate
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
## 관련 파일
|
||||
|
||||
- [프론트엔드 동적 렌더링 설계](./[DESIGN-2025-11-24]%20item-management-dynamic-frontend.md)
|
||||
- [API 개발 규칙](../../../Desktop/코브라브릿지백엔드문서/SAM_Develop_docs_251118/api_rules.md)
|
||||
- [아키텍처 문서](../../../Desktop/코브라브릿지백엔드문서/SAM_Develop_docs_251118/architecture.md)
|
||||
### 프론트엔드
|
||||
- `src/components/items/ItemMasterDataManagement.tsx` - 메인 UI 컴포넌트
|
||||
- `src/contexts/ItemMasterContext.tsx` - Context Provider
|
||||
- `src/lib/api/item-master.ts` - API 클라이언트
|
||||
- `src/types/item-master-api.ts` - API 타입 정의
|
||||
|
||||
### 백엔드 (Laravel/PHP)
|
||||
- `app/Http/Controllers/Api/V1/ItemMasterController.php` - API 컨트롤러 (예정)
|
||||
- `app/Services/ItemMasterService.php` - 비즈니스 로직 (예정)
|
||||
- `app/Models/ItemMasterPage.php` - 페이지 모델 (예정)
|
||||
- `app/Models/ItemMasterSection.php` - 섹션 모델 (예정)
|
||||
- `app/Models/ItemMasterField.php` - 필드 모델 (예정)
|
||||
|
||||
### 참조 문서
|
||||
- `claudedocs/item-master/[DESIGN-2025-11-24] item-management-dynamic-frontend.md` - 프론트엔드 동적 렌더링 설계
|
||||
- `claudedocs/item-master/[API-2025-11-25] item-master-data-management-api-request.md` - API 요청서
|
||||
|
||||
---
|
||||
|
||||
@@ -1017,4 +1017,18 @@ public function create(int $pageId, array $data): ItemSection
|
||||
질문이나 협의 사항이 있으면 언제든 연락 바랍니다.
|
||||
|
||||
**프론트엔드 담당**: [담당자명]
|
||||
**작성일**: 2025-11-25
|
||||
**작성일**: 2025-11-25
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 프론트엔드
|
||||
- `src/lib/api/item-master.ts` - API 클라이언트 코드
|
||||
- `src/types/item-master-api.ts` - API 타입 정의
|
||||
- `src/components/items/ItemMasterDataManagement.tsx` - 메인 UI 컴포넌트
|
||||
- `src/contexts/ItemMasterContext.tsx` - 상태 관리 Context
|
||||
|
||||
### 참조 문서
|
||||
- `claudedocs/item-master/[API-2025-11-24] item-management-dynamic-api-spec.md` - 동적 화면 API 명세
|
||||
- `claudedocs/item-master/[DESIGN-2025-11-24] item-management-dynamic-frontend.md` - 프론트엔드 설계 문서
|
||||
@@ -582,6 +582,22 @@ export interface SectionTemplateResponse {
|
||||
|
||||
질문 있으시면 프론트엔드 팀으로 연락 주세요.
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 프론트엔드
|
||||
- `src/components/items/ItemMasterDataManagement.tsx` - 메인 컴포넌트
|
||||
- `src/components/items/ItemMasterDataManagement/tabs/SectionsTab.tsx` - 섹션 탭
|
||||
- `src/components/items/ItemMasterDataManagement/dialogs/TemplateFieldDialog.tsx` - 템플릿 필드 다이얼로그
|
||||
- `src/lib/api/item-master.ts` - API 클라이언트
|
||||
- `src/types/item-master-api.ts` - API 타입 정의
|
||||
- `src/contexts/ItemMasterContext.tsx` - Context Provider
|
||||
|
||||
### 참조 문서
|
||||
- `claudedocs/item-master/[API-2025-11-20] item-master-specification.md` - 기준 문서
|
||||
- `claudedocs/item-master/[API-2025-11-25] item-master-data-management-api-request.md` - API 요청서
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-11-25
|
||||
@@ -1117,11 +1117,20 @@ DynamicTable에 "중량" 컬럼 자동 추가
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
## 관련 파일
|
||||
|
||||
- [백엔드 API 명세서](./[API-2025-11-24]%20item-management-dynamic-api-spec.md)
|
||||
- [품목기준관리 컴포넌트](/src/components/items/ItemMasterDataManagement.tsx)
|
||||
- [품목기준관리 Context](/src/contexts/ItemMasterContext.tsx)
|
||||
### 프론트엔드
|
||||
- `src/components/items/ItemMasterDataManagement.tsx` - 메인 컴포넌트
|
||||
- `src/components/items/ItemMasterDataManagement/hooks/` - 커스텀 훅 (7개)
|
||||
- `src/components/items/ItemMasterDataManagement/tabs/` - 탭 컴포넌트
|
||||
- `src/components/items/ItemMasterDataManagement/dialogs/` - 다이얼로그 컴포넌트
|
||||
- `src/contexts/ItemMasterContext.tsx` - Context Provider
|
||||
- `src/lib/api/item-master.ts` - API 클라이언트
|
||||
- `src/types/item-master-api.ts` - API 타입 정의
|
||||
|
||||
### 참조 문서
|
||||
- `claudedocs/item-master/[API-2025-11-24] item-management-dynamic-api-spec.md` - 백엔드 API 명세서
|
||||
- `claudedocs/item-master/[REF-2025-11-26] item-master-hooks-refactoring.md` - 훅 리팩토링 문서
|
||||
|
||||
---
|
||||
|
||||
188
claudedocs/item-master/[IMPL-2025-11-27] realtime-sync-fixes.md
Normal file
188
claudedocs/item-master/[IMPL-2025-11-27] realtime-sync-fixes.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# [IMPL-2025-11-27] 품목기준관리 실시간 동기화 수정
|
||||
|
||||
## 개요
|
||||
품목기준관리 모듈에서 데이터 변경 시 새로고침 없이 UI가 즉시 업데이트되지 않는 문제들을 수정함.
|
||||
|
||||
---
|
||||
|
||||
## 핵심 구조 이해
|
||||
|
||||
### 상태 의존성 구조
|
||||
```
|
||||
sectionsAsTemplates (useMemo)
|
||||
├── itemPages → 모듈 섹션 (page_id가 있는 섹션)
|
||||
└── independentSections → 일반 섹션 (page_id가 null인 섹션)
|
||||
```
|
||||
|
||||
**중요**: `sectionsAsTemplates`는 `[itemPages, independentSections]` 두 상태를 의존성으로 가짐.
|
||||
따라서 어떤 데이터든 변경되면 **두 상태 모두 업데이트**해야 UI가 즉시 반영됨.
|
||||
|
||||
### 탭별 데이터 소스
|
||||
| 탭 | 데이터 소스 | 비고 |
|
||||
|---|---|---|
|
||||
| 계층구조탭 | `itemPages` | 페이지 → 섹션 → 필드 구조 |
|
||||
| 섹션탭 (일반) | `sectionsAsTemplates` (section_type !== 'BOM') | independentSections 기반 |
|
||||
| 섹션탭 (모듈) | `sectionsAsTemplates` (section_type === 'BOM') | itemPages 기반 |
|
||||
| 항목탭 | `independentFields` / `itemMasterFields` | 독립 필드 목록 |
|
||||
|
||||
---
|
||||
|
||||
## 수정 내역
|
||||
|
||||
### 1. BOM 실시간 동기화 (`ItemMasterContext.tsx`)
|
||||
|
||||
**문제**: BOM 항목 추가/수정/삭제 시 계층구조탭에서 변경되지만 섹션탭에서는 새로고침 필요
|
||||
|
||||
**원인**: `addBOMItem`, `updateBOMItem`, `deleteBOMItem`이 `itemPages`만 업데이트하고 `independentSections`는 업데이트하지 않음
|
||||
|
||||
**해결**: 세 함수 모두에 `setIndependentSections` 업데이트 추가
|
||||
|
||||
```typescript
|
||||
// addBOMItem, updateBOMItem, deleteBOMItem 모두에 추가
|
||||
setIndependentSections(prev => prev.map(section =>
|
||||
section.id === sectionId
|
||||
? { ...section, bom_items: [...(section.bom_items || []), newBOM] }
|
||||
: section
|
||||
));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 섹션 복제 실시간 반영 (`ItemMasterContext.tsx`)
|
||||
|
||||
**문제**: 섹션 복제 후 새로고침해야 목록에 표시됨
|
||||
|
||||
**원인 1**: `page_id === null` 체크가 `undefined`를 처리하지 못함
|
||||
- API 응답: `page_id: null`
|
||||
- 변환 후: `page_id: undefined` (transformer에서 변환 시 undefined로 됨)
|
||||
- `null === undefined` → `false` → 잘못된 분기 진입
|
||||
|
||||
**해결**: strict equality(`===`) → loose equality(`==`) 변경
|
||||
|
||||
```typescript
|
||||
// 변경 전
|
||||
if (clonedSection.page_id === null)
|
||||
|
||||
// 변경 후
|
||||
if (clonedSection.page_id == null) // null과 undefined 둘 다 true
|
||||
```
|
||||
|
||||
**원인 2**: 모듈 섹션 복제 시 `itemPages` 업데이트 누락
|
||||
|
||||
**해결**: `else` 분기에 `setItemPages` 업데이트 추가
|
||||
|
||||
```typescript
|
||||
if (clonedSection.page_id == null) {
|
||||
// 독립 섹션 → independentSections에 추가
|
||||
setIndependentSections(prev => [...prev, clonedSection]);
|
||||
} else {
|
||||
// 모듈 섹션 → itemPages 내 해당 페이지에 추가
|
||||
setItemPages(prev => prev.map(page => {
|
||||
if (page.id === clonedSection.page_id) {
|
||||
return { ...page, sections: [...page.sections, clonedSection] };
|
||||
}
|
||||
return page;
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 항목 수정 기능 (`useTemplateManagement.ts`)
|
||||
|
||||
**문제**: 섹션탭에서 항목 수정 버튼 클릭 시 아무 동작 없음
|
||||
|
||||
**원인**: `handleAddTemplateField` 함수가 추가 로직만 있고 수정 로직이 없음
|
||||
|
||||
**해결**: `editingTemplateFieldId` 체크 후 `updateField` API 호출 분기 추가
|
||||
|
||||
```typescript
|
||||
const handleAddTemplateField = async () => {
|
||||
// 수정 모드
|
||||
if (editingTemplateFieldId) {
|
||||
const updateData = {
|
||||
field_name: templateFieldName,
|
||||
field_type: templateFieldInputType,
|
||||
is_required: templateFieldRequired,
|
||||
// ... 기타 필드
|
||||
};
|
||||
await updateField(editingTemplateFieldId, updateData);
|
||||
toast.success('항목이 수정되었습니다');
|
||||
resetTemplateFieldForm();
|
||||
return;
|
||||
}
|
||||
|
||||
// 추가 모드 (기존 로직)
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 백엔드 요청 사항 (미해결)
|
||||
|
||||
### 섹션 복제 시 필드 복제 문제
|
||||
|
||||
**API**: `POST /v1/item-master/sections/{id}/clone`
|
||||
|
||||
**현재 동작**: 섹션 복제 시 연결된 필드들도 새로운 레코드로 복제됨
|
||||
|
||||
**문제점**: 섹션-필드는 링크 관계이므로 섹션만 복제하고 필드는 기존 필드에 링크만 연결해야 함
|
||||
|
||||
**백엔드 수정 요청**:
|
||||
```php
|
||||
// 현재 코드 (문제)
|
||||
foreach ($section->fields as $field) {
|
||||
$newField = $field->replicate(); // ❌ 새 필드 레코드 생성
|
||||
$newField->section_id = $clonedSection->id;
|
||||
$newField->save();
|
||||
}
|
||||
|
||||
// 수정 요청
|
||||
foreach ($section->fields as $field) {
|
||||
// ✅ 기존 필드를 새 섹션에 링크만 연결
|
||||
$clonedSection->fields()->attach($field->id);
|
||||
}
|
||||
```
|
||||
|
||||
**파일 위치**: `/sam-api/app/Services/ItemMaster/ItemSectionService.php` (Line 99-113)
|
||||
|
||||
---
|
||||
|
||||
## 디버깅 로그 (제거 가능)
|
||||
|
||||
개발 중 추가된 디버깅 로그들 (프로덕션 전 제거 권장):
|
||||
|
||||
```typescript
|
||||
// ItemMasterContext.tsx - cloneSection
|
||||
console.log('[cloneSection] API 응답 원본:', sectionData);
|
||||
console.log('[cloneSection] 변환 후 섹션:', {...});
|
||||
console.log('[cloneSection] 독립 섹션 추가 (independentSections)');
|
||||
console.log('[cloneSection] independentSections 업데이트:', newSections.length);
|
||||
|
||||
// SectionsTab.tsx
|
||||
console.log('[SectionsTab] 📥 sectionTemplates prop changed:', {...});
|
||||
console.log('[SectionsTab] 🔄 Rendering section templates:', {...});
|
||||
|
||||
// ItemMasterDataManagement.tsx
|
||||
console.log('[sectionsAsTemplates] useMemo 재계산!', {...});
|
||||
console.log('[ItemMasterDataManagement] 📋 sectionsAsTemplates changed:', {...});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 수정된 파일 목록
|
||||
|
||||
| 파일 | 수정 내용 |
|
||||
|---|---|
|
||||
| `src/contexts/ItemMasterContext.tsx` | BOM 동기화, 섹션 복제 수정 |
|
||||
| `src/components/items/ItemMasterDataManagement/hooks/useTemplateManagement.ts` | 항목 수정 로직 추가 |
|
||||
| `src/components/items/ItemMasterDataManagement/dialogs/TemplateFieldDialog.tsx` | prop 타입 수정 (void → void \| Promise<void>) |
|
||||
|
||||
---
|
||||
|
||||
## 향후 작업 시 주의사항
|
||||
|
||||
1. **양방향 동기화 필수**: 데이터 변경 시 `itemPages`와 `independentSections` 모두 업데이트해야 함
|
||||
2. **null vs undefined**: API 응답의 null 값이 transformer 거치면서 undefined로 바뀔 수 있음 → `== null` 사용 권장
|
||||
3. **useMemo 의존성**: `sectionsAsTemplates`의 의존성 배열 확인 → 두 상태가 모두 업데이트되어야 재계산됨
|
||||
@@ -0,0 +1,227 @@
|
||||
# 품목기준관리 - API 연동 작업 체크리스트
|
||||
|
||||
**작성일**: 2025-11-26
|
||||
**상태**: ✅ Phase 3 완료
|
||||
**마지막 업데이트**: 2025-11-26 API 연결 구현 완료 (Phase 3 ✅)
|
||||
|
||||
---
|
||||
|
||||
## 1. 구조 변경 사항
|
||||
|
||||
- `section_templates` 테이블 삭제 → `item_sections.is_template=true`로 통합
|
||||
- `section_name` → `title`로 통일 (API와 동일)
|
||||
- `bomItems` → `bom_items`로 통일 (API와 동일)
|
||||
- `field_type`: API와 Frontend가 동일한 값 사용 ('textbox', 'number', 'dropdown' 등)
|
||||
|
||||
---
|
||||
|
||||
## 2. API 연동 체크리스트
|
||||
|
||||
### 2.1 타입 정의 (src/types/item-master-api.ts)
|
||||
|
||||
- [x] ItemSectionResponse에 is_template, is_default, description, group_id 추가
|
||||
- [x] IndependentSectionRequest 추가
|
||||
- [x] IndependentFieldRequest 추가
|
||||
- [x] IndependentBomItemRequest 추가
|
||||
- [x] SectionUsageResponse 추가
|
||||
- [x] FieldUsageResponse 추가
|
||||
- [x] LinkSectionRequest 추가
|
||||
- [x] LinkFieldRequest 추가
|
||||
- [x] PageStructureResponse 추가
|
||||
- [x] MasterFieldResponse에 is_common, default_value, options 추가
|
||||
|
||||
### 2.2 API 클라이언트 (src/lib/api/item-master.ts) - ✅ 완료
|
||||
|
||||
#### 독립 엔티티 API (완료)
|
||||
- [x] `GET /sections` - `sections.list()` (is_template 필터 지원)
|
||||
- [x] `POST /sections` - `sections.createIndependent()`
|
||||
- [x] `POST /sections/{id}/clone` - `sections.clone()`
|
||||
- [x] `GET /sections/{id}/usage` - `sections.getUsage()`
|
||||
- [x] `GET /fields` - `fields.list()`
|
||||
- [x] `POST /fields` - `fields.createIndependent()`
|
||||
- [x] `POST /fields/{id}/clone` - `fields.clone()`
|
||||
- [x] `GET /fields/{id}/usage` - `fields.getUsage()`
|
||||
- [x] `GET /bom-items` - `bomItems.list()`
|
||||
- [x] `POST /bom-items` - `bomItems.createIndependent()`
|
||||
|
||||
#### 링크 관리 API (완료)
|
||||
- [x] `POST /pages/{id}/link-section` - `pages.linkSection()`
|
||||
- [x] `DELETE /pages/{id}/unlink-section/{sectionId}` - `pages.unlinkSection()`
|
||||
- [x] `POST /sections/{id}/link-field` - `sections.linkField()`
|
||||
- [x] `DELETE /sections/{id}/unlink-field/{fieldId}` - `sections.unlinkField()`
|
||||
- [x] `GET /pages/{id}/structure` - `pages.getStructure()`
|
||||
|
||||
#### 섹션 템플릿 API 수정 (완료)
|
||||
- [x] `sections.list({ is_template: true })` 로 템플릿 조회 가능
|
||||
|
||||
### 2.3 Context 업데이트 (src/contexts/ItemMasterContext.tsx)
|
||||
|
||||
#### 인터페이스 수정 (완료)
|
||||
- [x] ItemSection 인터페이스에 title, group_id, is_template, is_default, description 추가
|
||||
- [x] ItemSection.section_name → title 변경
|
||||
- [x] ItemSection.bomItems → bom_items 변경
|
||||
- [x] ItemMasterField 인터페이스에 is_common, default_value, options, validation_rules, properties 추가
|
||||
|
||||
#### Transformer 수정 (완료)
|
||||
- [x] transformSectionResponse: 새 필드 추가 (group_id, is_template, is_default, description)
|
||||
- [x] transformMasterFieldResponse: 새 필드 추가 및 속성명 통일
|
||||
- [x] field_type 변환 제거 (API와 동일한 값 사용)
|
||||
|
||||
#### TypeScript 오류 수정 (완료 ✅)
|
||||
- [x] bomItems → bom_items 참조 수정 (addBOMItem, updateBOMItem, deleteBOMItem)
|
||||
- [x] transformers.ts FIELD_TYPE_MAP 오류 수정
|
||||
- [x] transformPageResponse: order_no, description 추가
|
||||
- [x] ItemPageResponse: order_no, description 추가
|
||||
- [x] 전체 타입 검증 완료
|
||||
|
||||
#### 기능 추가 (완료 ✅)
|
||||
- [x] 독립 섹션/필드/BOM 상태 추가
|
||||
- [x] 링크/언링크 메서드 추가
|
||||
- [x] 사용처 조회 메서드 추가
|
||||
- [x] 섹션 템플릿 로직 수정 (is_template 필터)
|
||||
- [x] 복제 기능 (cloneSection, cloneField)
|
||||
|
||||
### 2.4 계층구조(페이지) 탭 UI - ✅ 완료
|
||||
|
||||
- [x] 섹션 불러오기 다이얼로그 (ImportSectionDialog.tsx)
|
||||
- [x] 필드 불러오기 다이얼로그 (ImportFieldDialog.tsx)
|
||||
- [x] 불러오기 버튼 추가 (HierarchyTab)
|
||||
- [x] 사용처 표시 UI (다이얼로그 내 Usage Info Panel)
|
||||
|
||||
### 2.5 섹션 탭 UI - ✅ 완료
|
||||
|
||||
- [x] 섹션 복제(Clone) 버튼 추가 (SectionsTab.tsx)
|
||||
- [x] 필드 불러오기(Import Field) 버튼 추가 (SectionsTab.tsx)
|
||||
- [x] ItemMasterDataManagement에서 props 연결 (handleCloneSection, setIsImportFieldDialogOpen)
|
||||
- [x] TypeScript 오류 수정:
|
||||
- section_name → title 변경 (useSectionManagement, useTemplateManagement, DraggableSection, FieldDrawer, ConditionalDisplayUI)
|
||||
- bomItems → bom_items 변경 (hooks 파일들)
|
||||
- is_template, is_default 필수 속성 추가
|
||||
|
||||
### 2.6 마스터 항목 탭 UI - ✅ 완료
|
||||
|
||||
- [x] 기본 CRUD UI 구현됨 (MasterFieldTab/index.tsx)
|
||||
- [x] 필드 타입 배지 표시
|
||||
- [x] 필수 여부, 카테고리, 속성 타입 배지 표시
|
||||
- [x] 옵션 목록 표시
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase 3: API 연결 구현 - ✅ 완료
|
||||
|
||||
> **분석 결과**: 모든 API 연결이 이미 Context에서 완료되어 있습니다.
|
||||
|
||||
### 3.1 초기화 API 연결 - ✅ 완료
|
||||
|
||||
- [x] `/v1/item-master/init` API 호출 구현 (ItemMasterDataManagement.tsx:301-361)
|
||||
- [x] Context `loadItemPages`, `loadSectionTemplates`, `loadItemMasterFields` 메서드 연결
|
||||
- [x] 로딩 상태 관리 UI (LoadingSpinner, ErrorMessage)
|
||||
|
||||
### 3.2 페이지 CRUD API 연결 - ✅ 완료
|
||||
|
||||
- [x] 페이지 생성 API 연결 (`addItemPage` → `itemMasterApi.pages.create()`)
|
||||
- [x] 페이지 수정 API 연결 (`updateItemPage` → `itemMasterApi.pages.update()`)
|
||||
- [x] 페이지 삭제 API 연결 (`deleteItemPage` → `itemMasterApi.pages.delete()`)
|
||||
- [x] 페이지 순서 변경 API 연결 (`reorderPages` → `itemMasterApi.pages.reorder()`)
|
||||
- [x] 섹션 링크/언링크 API 연결 (`linkSectionToPage`, `unlinkSectionFromPage`)
|
||||
|
||||
### 3.3 섹션 CRUD API 연결 - ✅ 완료
|
||||
|
||||
- [x] 섹션 생성 API 연결 (`addSectionToPage` → `itemMasterApi.sections.create()`)
|
||||
- [x] 섹션 수정 API 연결 (`updateSection` → `itemMasterApi.sections.update()`)
|
||||
- [x] 섹션 삭제/언링크 API 연결 (`deleteSection` → `itemMasterApi.sections.delete()`)
|
||||
- [x] 섹션 순서 변경 API 연결 (`reorderSections` → `itemMasterApi.sections.reorder()`)
|
||||
- [x] 독립 섹션 생성 (`createIndependentSection` → `itemMasterApi.sections.createIndependent()`)
|
||||
- [x] 섹션 복제 (`cloneSection` → `itemMasterApi.sections.clone()`)
|
||||
- [x] 섹션 사용처 조회 (`getSectionUsage` → `itemMasterApi.sections.getUsage()`)
|
||||
- [x] 필드 링크/언링크 API 연결 (`linkFieldToSection`, `unlinkFieldFromSection`)
|
||||
|
||||
### 3.4 필드 CRUD API 연결 - ✅ 완료
|
||||
|
||||
- [x] 필드 생성 API 연결 (`addFieldToSection` → `itemMasterApi.fields.create()`)
|
||||
- [x] 필드 수정 API 연결 (`updateField` → `itemMasterApi.fields.update()`)
|
||||
- [x] 필드 삭제/언링크 API 연결 (`deleteField` → `itemMasterApi.fields.delete()`)
|
||||
- [x] 필드 순서 변경 API 연결 (`reorderFields` → `itemMasterApi.fields.reorder()`)
|
||||
- [x] 독립 필드 생성 (`createIndependentField` → `itemMasterApi.fields.createIndependent()`)
|
||||
- [x] 필드 복제 (`cloneField` → `itemMasterApi.fields.clone()`)
|
||||
- [x] 필드 사용처 조회 (`getFieldUsage` → `itemMasterApi.fields.getUsage()`)
|
||||
|
||||
### 3.5 마스터 필드 CRUD API 연결 - ✅ 완료
|
||||
|
||||
- [x] 마스터 필드 생성 API 연결 (`addItemMasterField` → `itemMasterApi.masterFields.create()`)
|
||||
- [x] 마스터 필드 수정 API 연결 (`updateItemMasterField` → `itemMasterApi.masterFields.update()`)
|
||||
- [x] 마스터 필드 삭제 API 연결 (`deleteItemMasterField` → `itemMasterApi.masterFields.delete()`)
|
||||
|
||||
### 3.6 BOM CRUD API 연결 - ✅ 완료
|
||||
|
||||
- [x] BOM 생성 API 연결 (`addBOMItem` → `itemMasterApi.bomItems.create()`)
|
||||
- [x] BOM 수정 API 연결 (`updateBOMItem` → `itemMasterApi.bomItems.update()`)
|
||||
- [x] BOM 삭제 API 연결 (`deleteBOMItem` → `itemMasterApi.bomItems.delete()`)
|
||||
- [x] 독립 BOM 생성 (`createIndependentBomItem` → `itemMasterApi.bomItems.createIndependent()`)
|
||||
|
||||
### Hooks → Context 연결 현황 - ✅ 완료
|
||||
|
||||
| Hook | Context 함수 | 상태 |
|
||||
|------|-------------|------|
|
||||
| usePageManagement | `addItemPage`, `updateItemPage`, `deleteItemPage` | ✅ |
|
||||
| useSectionManagement | `addSectionToPage`, `updateSection`, `deleteSection` | ✅ |
|
||||
| useFieldManagement | `addFieldToSection`, `updateField`, `deleteField` | ✅ |
|
||||
| useMasterFieldManagement | `addItemMasterField`, `updateItemMasterField`, `deleteItemMasterField` | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 4. 삭제 vs 연결해제 정리
|
||||
|
||||
```
|
||||
[계층구조 탭에서]
|
||||
├─ 페이지 삭제 → 실제 삭제 (Cascade)
|
||||
├─ 섹션 제거 → 연결만 끊기 (unlink), 섹션 데이터는 유지
|
||||
└─ 항목 제거 → 연결만 끊기 (unlink), 항목 데이터는 유지
|
||||
|
||||
[섹션 탭에서]
|
||||
├─ 섹션 삭제 → 실제 삭제 (Cascade)
|
||||
└─ 항목 삭제 → 실제 삭제
|
||||
|
||||
[마스터 항목 탭에서]
|
||||
└─ 마스터 항목 삭제 → 실제 삭제
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 데이터 연결 구조
|
||||
|
||||
```
|
||||
독립 필드 (fields, section_id=null)
|
||||
│
|
||||
├──[link-field]──→ 섹션에 연결
|
||||
│ ↓
|
||||
독립 섹션 (sections, page_id=null)
|
||||
│
|
||||
├──[link-section]──→ 페이지에 연결
|
||||
│ ↓
|
||||
페이지 (pages) = 품목유형별 필드 구성
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 핵심 개념
|
||||
|
||||
> **"페이지"는 실제 URL 경로가 아니라, 품목유형별 필드 구성 템플릿이다!**
|
||||
|
||||
```
|
||||
품목기준관리의 "페이지"
|
||||
= 품목유형(FG, PT, SM, RM, CS)별로
|
||||
= 품목 등록 시 어떤 섹션/필드를 보여줄지 정의하는 템플릿
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 참고 문서
|
||||
|
||||
- `claudedocs/[ANALYSIS-2025-11-21] item-master-notes.md` - 이전 API 문서
|
||||
- `claudedocs/[ANALYSIS-2025-11-26] item-master-notes.md` - 신규 API 문서
|
||||
- `~/Desktop/코브라브릿지백엔드문서/[API-2025-11-26] item-master-api-changes.md` - API 변경사항
|
||||
|
||||
---
|
||||
|
||||
**마지막 업데이트**: 2025-11-26 작업 시작
|
||||
@@ -0,0 +1,106 @@
|
||||
# 품목기준관리 - 백엔드 통합 대기 작업
|
||||
|
||||
**작성일**: 2025-11-26
|
||||
**상태**: 백엔드 통합 작업 대기 중
|
||||
|
||||
---
|
||||
|
||||
## 현재 상황 요약
|
||||
|
||||
### 해결된 이슈
|
||||
|
||||
1. **섹션 순서 변경 422 에러**
|
||||
- 원인: 백엔드가 `items` 필드를 기대하는데 프론트가 `section_orders` 전송
|
||||
- 수정 파일:
|
||||
- `src/types/item-master-api.ts` - `SectionReorderRequest.items`로 변경
|
||||
- `src/contexts/ItemMasterContext.tsx` - `reorderSections` 함수 수정
|
||||
|
||||
2. **response.data.map is not a function 에러**
|
||||
- 원인: 백엔드 응답이 배열이 아닌 경우 처리 누락
|
||||
- 수정: 배열/비배열 응답 모두 처리하도록 조건문 추가
|
||||
|
||||
3. **불러오기 다이얼로그에 마스터 항목 미표시**
|
||||
- 수정 파일:
|
||||
- `src/components/items/ItemMasterDataManagement/dialogs/ImportFieldDialog.tsx`
|
||||
- `src/components/items/ItemMasterDataManagement.tsx`
|
||||
- 변경 내용: 마스터 항목 / 독립 필드 탭 분리
|
||||
|
||||
---
|
||||
|
||||
## 백엔드 통합 대기 중인 이슈
|
||||
|
||||
### 데이터 동기화 문제
|
||||
|
||||
**현상**:
|
||||
- 계층구조에서 섹션 내 항목 생성 시 → 마스터항목 탭, 속성 탭, 불러오기에도 표시됨
|
||||
- 원인: `GET /v1/item-master/fields` API가 모든 필드를 반환 (독립 필드만 반환해야 함)
|
||||
|
||||
**백엔드 요청 사항**:
|
||||
1. `GET /v1/item-master/fields` → `section_id IS NULL`인 필드만 반환
|
||||
2. 마스터 항목 + 섹션 필드 통합 구조 검토
|
||||
|
||||
### 현재 데이터 구조 (분리됨)
|
||||
|
||||
```
|
||||
item_master_fields 테이블 (마스터 항목)
|
||||
├─ 항목 탭에서 생성/관리
|
||||
├─ 속성 탭 서브탭으로 표시
|
||||
└─ 불러오기 시 "복사"하여 새 필드 생성
|
||||
|
||||
item_fields 테이블 (실제 필드)
|
||||
├─ section_id != null → 섹션 필드 (계층구조/섹션 탭)
|
||||
└─ section_id = null → 독립 필드 (불러오기에서 "연결")
|
||||
```
|
||||
|
||||
### 예상되는 통합 구조 (백엔드 작업 중)
|
||||
|
||||
```
|
||||
통합된 필드 테이블
|
||||
├─ is_master = true → 마스터 필드 (템플릿)
|
||||
├─ section_id != null → 섹션 필드
|
||||
└─ section_id = null, is_master = false → 독립 필드
|
||||
|
||||
→ 마스터 필드 수정 시 연결된 모든 필드에 반영
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 프론트엔드 수정 필요 사항 (백엔드 통합 후)
|
||||
|
||||
### 1. API 응답 구조 변경 대응
|
||||
- `InitResponse` 타입 수정 (통합된 필드 구조)
|
||||
- `transformers.ts` 변환 로직 수정
|
||||
|
||||
### 2. Context 수정
|
||||
- `itemMasterFields` vs `independentFields` 통합 가능성
|
||||
- 필드 CRUD 함수 통합
|
||||
|
||||
### 3. UI 수정
|
||||
- ImportFieldDialog 탭 구조 재검토 (통합되면 탭 불필요할 수 있음)
|
||||
- 데이터 동기화 로직 단순화
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일 목록
|
||||
|
||||
### 수정된 파일 (2025-11-26)
|
||||
- `src/types/item-master-api.ts`
|
||||
- `src/contexts/ItemMasterContext.tsx`
|
||||
- `src/lib/api/error-handler.ts`
|
||||
- `src/components/items/ItemMasterDataManagement.tsx`
|
||||
- `src/components/items/ItemMasterDataManagement/dialogs/ImportFieldDialog.tsx`
|
||||
|
||||
### 참고할 파일
|
||||
- `src/lib/api/item-master.ts` - API 호출 함수
|
||||
- `src/lib/api/transformers.ts` - 응답 변환 함수
|
||||
- `src/components/items/ItemMasterDataManagement/hooks/useTabManagement.ts` - 속성 탭 생성 로직
|
||||
|
||||
---
|
||||
|
||||
## 다음 작업 체크리스트
|
||||
|
||||
- [ ] 백엔드 통합 API 완료 확인
|
||||
- [ ] 새 API 응답 구조 확인 및 타입 수정
|
||||
- [ ] Context 데이터 구조 통합
|
||||
- [ ] ImportFieldDialog 통합 여부 결정
|
||||
- [ ] 테스트: 마스터 항목 수정 → 연결된 필드 동기화 확인
|
||||
@@ -0,0 +1,276 @@
|
||||
# ItemForm.tsx 컴포넌트 분리 계획
|
||||
|
||||
## 작업 일자: 2025-11-27 (분석)
|
||||
|
||||
---
|
||||
|
||||
## 1. 현재 상태
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **파일 경로** | `src/components/items/ItemForm.tsx` |
|
||||
| **파일 크기** | 2,600줄 |
|
||||
| **useState 수** | 25개+ |
|
||||
| **주요 섹션** | 5개 Card |
|
||||
| **복잡도** | 높음 (품목유형별 조건부 렌더링) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 구조 분석
|
||||
|
||||
```
|
||||
ItemForm.tsx (2,600줄)
|
||||
├── Constants (62-134) ────────────────── 72줄
|
||||
│ ├── PART_TYPE_CATEGORIES
|
||||
│ └── PART_ITEM_NAMES
|
||||
│
|
||||
├── State & Hooks (142-218) ───────────── 76줄
|
||||
│ └── 25+ useState hooks
|
||||
│
|
||||
├── Functions (220-409) ───────────────── 190줄
|
||||
│ ├── generateItemCode()
|
||||
│ ├── handleFormSubmit()
|
||||
│ └── handleItemTypeChange()
|
||||
│
|
||||
└── JSX (411-2599) ────────────────────── 2,188줄
|
||||
├── Validation Alert (414-459) ────── 45줄
|
||||
├── Header (461-499) ──────────────── 38줄
|
||||
├── 기본 정보 Card (501-1780) ──────── 1,279줄 ⚠️ 가장 큼
|
||||
│ ├── FG (제품) ─────── 102줄
|
||||
│ ├── PT (부품) ─────── 822줄 ⚠️
|
||||
│ │ ├── ASSEMBLY ──── 216줄
|
||||
│ │ ├── BENDING ───── 464줄
|
||||
│ │ └── PURCHASED ─── 223줄
|
||||
│ └── RM/SM/CS ──────── 320줄
|
||||
│
|
||||
├── FG 비고 섹션 (1784-1940) ────────── 156줄
|
||||
├── 전개도 Card (1942-2280) ─────────── 338줄
|
||||
└── BOM Card (2281-2584) ────────────── 303줄
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 분리 계획
|
||||
|
||||
### Phase 1: 상수 분리 (즉시 가능)
|
||||
|
||||
```
|
||||
src/components/items/ItemForm/
|
||||
├── constants.ts # PART_TYPE_CATEGORIES, PART_ITEM_NAMES
|
||||
├── types.ts # 타입 정의
|
||||
└── index.tsx # 메인 컴포넌트
|
||||
```
|
||||
|
||||
**작업 내용**: ✅ 완료
|
||||
- [x] `constants.ts` 생성 및 상수 이동
|
||||
- [x] `types.ts` 생성 (ItemFormProps 등)
|
||||
- [x] import 경로 수정
|
||||
|
||||
### Phase 2: 섹션 컴포넌트 분리
|
||||
|
||||
```
|
||||
src/components/items/ItemForm/
|
||||
├── sections/
|
||||
│ ├── FormHeader.tsx # 헤더 + 저장/취소 버튼
|
||||
│ ├── ValidationAlert.tsx # 폼 에러 Alert
|
||||
│ ├── BendingDiagramSection.tsx # 전개도 카드 (338줄)
|
||||
│ └── BOMSection.tsx # 부품 구성 카드 (303줄)
|
||||
```
|
||||
|
||||
**작업 내용**: ✅ 완료 (2025-11-27)
|
||||
- [x] `FormHeader.tsx` 분리 (63줄) - ItemForm/ 폴더에 배치
|
||||
- [x] `ValidationAlert.tsx` 분리 (45줄) - ItemForm/ 폴더에 배치
|
||||
- [x] `BendingDiagramSection.tsx` 분리 (~300줄) - ItemForm/ 폴더에 배치
|
||||
- [x] `BOMSection.tsx` 분리 (~280줄) - ItemForm/ 폴더에 배치
|
||||
|
||||
### Phase 3: 품목 유형별 폼 분리
|
||||
|
||||
```
|
||||
src/components/items/ItemForm/
|
||||
├── forms/
|
||||
│ ├── ProductForm.tsx # FG (제품) - 102줄
|
||||
│ ├── PartForm.tsx # PT (부품) - 822줄 → 추가 분리 필요
|
||||
│ └── MaterialForm.tsx # RM/SM/CS - 320줄
|
||||
```
|
||||
|
||||
**작업 내용**: ✅ 완료 (2025-11-27)
|
||||
- [x] `ProductForm.tsx` 분리 (FG 전용 필드)
|
||||
- [x] `MaterialForm.tsx` 분리 (RM/SM/CS 공통)
|
||||
- [x] `PartForm.tsx` 분리 (PT 전용, 하위 분리 완료)
|
||||
|
||||
### Phase 4: 부품 유형별 추가 분리
|
||||
|
||||
```
|
||||
src/components/items/ItemForm/forms/
|
||||
├── parts/
|
||||
│ ├── AssemblyPartForm.tsx # 조립 부품 - ~300줄
|
||||
│ ├── BendingPartForm.tsx # 절곡 부품 - ~280줄
|
||||
│ └── PurchasedPartForm.tsx # 구매 부품 - ~270줄
|
||||
```
|
||||
|
||||
**작업 내용**: ✅ 완료 (2025-11-27)
|
||||
- [x] `AssemblyPartForm.tsx` 분리
|
||||
- [x] `BendingPartForm.tsx` 분리
|
||||
- [x] `PurchasedPartForm.tsx` 분리
|
||||
- [x] `parts/index.ts` export 파일 생성
|
||||
|
||||
### Phase 5: 공통 컴포넌트 & 훅
|
||||
|
||||
```
|
||||
src/components/items/ItemForm/
|
||||
├── context/
|
||||
│ ├── ItemFormContext.tsx # 폼 상태 컨텍스트
|
||||
│ └── index.ts # export 파일
|
||||
└── hooks/
|
||||
├── useItemFormState.ts # 25+ useState 통합
|
||||
├── useBOMManagement.ts # BOM 라인 관리
|
||||
├── useBendingDetails.ts # 전개도 계산
|
||||
└── index.ts # export 파일
|
||||
```
|
||||
|
||||
**작업 내용**: ✅ 완료 (2025-11-27)
|
||||
- [x] ItemFormContext.tsx 생성 (Context Provider)
|
||||
- [x] useItemFormState.ts 생성 (상태 통합 훅)
|
||||
- [x] useBOMManagement.ts 생성 (BOM 관리 훅)
|
||||
- [x] useBendingDetails.ts 생성 (전개도 계산 훅)
|
||||
- [x] export 파일 생성
|
||||
|
||||
---
|
||||
|
||||
## 4. 분리 우선순위
|
||||
|
||||
| 우선순위 | 대상 | 효과 | 난이도 | API 의존 |
|
||||
|---------|------|------|--------|----------|
|
||||
| 🔴 **1** | constants.ts | 즉시 분리 가능 | 쉬움 | ❌ |
|
||||
| 🔴 **2** | BOMSection.tsx | 독립적, 재사용 가능 | 쉬움 | ⚠️ 검색만 |
|
||||
| 🟡 **3** | BendingDiagramSection.tsx | 독립적 | 쉬움 | ❌ |
|
||||
| 🟡 **4** | ValidationAlert.tsx | 독립적 | 쉬움 | ❌ |
|
||||
| 🟡 **5** | FormHeader.tsx | 독립적 | 쉬움 | ❌ |
|
||||
| 🟡 **6** | MaterialForm.tsx | 명확한 경계 | 중간 | ❌ |
|
||||
| 🟡 **7** | ProductForm.tsx | 명확한 경계 | 중간 | ❌ |
|
||||
| 🟢 **8** | PartForm + 하위 분리 | 복잡한 상태 의존성 | 어려움 | ❌ |
|
||||
| 🟢 **9** | useItemFormState.ts | 전체 리팩토링 필요 | 어려움 | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## 5. 주의사항
|
||||
|
||||
### 상태 공유 문제
|
||||
- 품목 유형별 폼이 `react-hook-form`의 `setValue`, `getValues` 공유
|
||||
- 하위 폼들이 부모의 선택 상태에 의존
|
||||
- 품목코드 자동생성 로직이 여러 필드 값 조합
|
||||
|
||||
### 해결 방안
|
||||
1. **Context 패턴**: ItemFormContext로 공유 상태 관리
|
||||
2. **Props Drilling**: 필요한 props만 하위 컴포넌트에 전달
|
||||
3. **Render Props**: 유연한 컴포넌트 조합
|
||||
|
||||
### 권장 접근법
|
||||
```typescript
|
||||
// ItemFormContext.tsx
|
||||
interface ItemFormContextType {
|
||||
form: UseFormReturn<CreateItemFormData>;
|
||||
selectedItemType: ItemType | '';
|
||||
selectedPartType: string;
|
||||
// ... 공유 상태
|
||||
}
|
||||
|
||||
// 하위 컴포넌트에서 사용
|
||||
const { form, selectedItemType } = useItemFormContext();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 예상 결과
|
||||
|
||||
### Before
|
||||
```
|
||||
src/components/items/
|
||||
├── ItemForm.tsx (2,600줄) ← 모놀리식
|
||||
```
|
||||
|
||||
### After
|
||||
```
|
||||
src/components/items/ItemForm/
|
||||
├── index.tsx (300줄) ← 메인 컴포넌트
|
||||
├── constants.ts (72줄)
|
||||
├── types.ts (50줄)
|
||||
├── context.tsx (100줄)
|
||||
├── sections/
|
||||
│ ├── FormHeader.tsx (50줄)
|
||||
│ ├── ValidationAlert.tsx (50줄)
|
||||
│ ├── BendingDiagramSection.tsx (350줄)
|
||||
│ └── BOMSection.tsx (320줄)
|
||||
├── forms/
|
||||
│ ├── ProductForm.tsx (120줄)
|
||||
│ ├── MaterialForm.tsx (350줄)
|
||||
│ └── PartForm.tsx (200줄)
|
||||
│ └── parts/
|
||||
│ ├── AssemblyPartForm.tsx (230줄)
|
||||
│ ├── BendingPartForm.tsx (480줄)
|
||||
│ └── PurchasedPartForm.tsx (240줄)
|
||||
├── components/
|
||||
│ ├── UnitSelect.tsx (60줄)
|
||||
│ └── StatusSelect.tsx (50줄)
|
||||
└── hooks/
|
||||
├── useItemFormState.ts (100줄)
|
||||
├── useBOMManagement.ts (80줄)
|
||||
└── useBendingDetails.ts (60줄)
|
||||
```
|
||||
|
||||
**총 파일 수**: 1개 → 18개
|
||||
**최대 파일 크기**: 2,600줄 → ~480줄
|
||||
**평균 파일 크기**: ~150줄
|
||||
|
||||
---
|
||||
|
||||
## 7. 관련 파일
|
||||
|
||||
- `src/components/items/ItemForm.tsx` - 메인 대상
|
||||
- `src/components/items/ItemTypeSelect.tsx` - 이미 분리됨
|
||||
- `src/components/items/FileUpload.tsx` - 이미 분리됨
|
||||
- `src/components/items/DrawingCanvas.tsx` - 이미 분리됨
|
||||
- `src/components/items/BOMManagementSection.tsx` - 참고용 (다른 BOM 컴포넌트)
|
||||
- `src/lib/utils/validation.ts` - Zod 스키마
|
||||
|
||||
---
|
||||
|
||||
## 8. 작업 체크리스트
|
||||
|
||||
### Phase 1: 즉시 가능 (API 독립적) ✅ 완료 (2025-11-27)
|
||||
- [x] constants.ts 분리
|
||||
- [x] types.ts 분리
|
||||
- [x] ValidationAlert.tsx 분리
|
||||
- [x] FormHeader.tsx 분리
|
||||
|
||||
### Phase 2: 섹션 분리 ✅ 완료 (2025-11-27)
|
||||
- [x] BendingDiagramSection.tsx 분리
|
||||
- [x] BOMSection.tsx 분리
|
||||
|
||||
### Phase 3: 폼 분리 ✅ 완료 (2025-11-27)
|
||||
- [x] MaterialForm.tsx 분리 (RM/SM/CS)
|
||||
- [x] ProductForm.tsx 분리 (FG) + ProductCertificationSection
|
||||
- [x] PartForm.tsx 분리 (PT)
|
||||
- [x] forms/index.ts export 파일 생성
|
||||
- [x] index.tsx에서 ProductForm, ProductCertificationSection 적용
|
||||
|
||||
### Phase 4: 부품 폼 분리 ✅ 완료 (2025-11-27)
|
||||
- [x] AssemblyPartForm.tsx 분리 (~300줄)
|
||||
- [x] BendingPartForm.tsx 분리 (~280줄)
|
||||
- [x] PurchasedPartForm.tsx 분리 (~270줄)
|
||||
- [x] parts/index.ts export 파일 생성
|
||||
- [x] PartForm.tsx에서 하위 컴포넌트 적용 (~273줄로 감소)
|
||||
|
||||
### Phase 5: 훅 & 컨텍스트 ✅ 완료 (2025-11-27)
|
||||
- [x] context/ItemFormContext.tsx 생성 (~80줄)
|
||||
- [x] context/index.ts export 파일 생성
|
||||
- [x] hooks/useItemFormState.ts 생성 (~280줄) - 25+ useState 통합
|
||||
- [x] hooks/useBOMManagement.ts 생성 (~180줄) - BOM 라인 관리
|
||||
- [x] hooks/useBendingDetails.ts 생성 (~150줄) - 전개도 계산
|
||||
- [x] hooks/index.ts export 파일 생성
|
||||
|
||||
### Phase 6: 테스트 & 검증
|
||||
- [ ] 모든 품목 유형 등록 테스트
|
||||
- [ ] 수정 모드 테스트
|
||||
- [ ] 폼 검증 테스트
|
||||
- [ ] BOM 추가/삭제 테스트
|
||||
@@ -424,4 +424,23 @@ ItemMasterDataManagement/
|
||||
├── utils/
|
||||
├── types.ts
|
||||
└── index.tsx (메인 컴포넌트, ~200줄 목표)
|
||||
```
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 프론트엔드
|
||||
- `src/components/items/ItemMasterDataManagement.tsx` - 메인 컴포넌트 (리팩토링 대상)
|
||||
- `src/components/items/ItemMasterDataManagement/hooks/index.ts` - 훅 export
|
||||
- `src/components/items/ItemMasterDataManagement/hooks/usePageManagement.ts` - 페이지 관리 훅
|
||||
- `src/components/items/ItemMasterDataManagement/hooks/useSectionManagement.ts` - 섹션 관리 훅
|
||||
- `src/components/items/ItemMasterDataManagement/hooks/useFieldManagement.ts` - 필드 관리 훅
|
||||
- `src/components/items/ItemMasterDataManagement/hooks/useMasterFieldManagement.ts` - 마스터 필드 훅
|
||||
- `src/components/items/ItemMasterDataManagement/hooks/useTemplateManagement.ts` - 템플릿 관리 훅
|
||||
- `src/components/items/ItemMasterDataManagement/hooks/useAttributeManagement.ts` - 속성 관리 훅
|
||||
- `src/components/items/ItemMasterDataManagement/hooks/useTabManagement.ts` - 탭 관리 훅
|
||||
- `src/contexts/ItemMasterContext.tsx` - Context Provider
|
||||
|
||||
### 참조 문서
|
||||
- `claudedocs/item-master/[NEXT-2025-11-26] item-master-api-pending-tasks.md` - API 연동 작업 체크리스트
|
||||
@@ -915,4 +915,23 @@ GET /api/files/{file_id}/download
|
||||
- 파일 저장소 구현 가이드 참조 추가 (`/downloads/file_storage_implementation_guide.md`)
|
||||
- 테넌트별 파일 저장 경로 구조 명시
|
||||
- 파일명 처리 방식 명시 (난수 저장명 + 원본명 보존)
|
||||
- 차단 확장자 목록 추가 (보안)
|
||||
- 차단 확장자 목록 추가 (보안)
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 프론트엔드
|
||||
- `src/types/item.ts` - 품목 타입 정의
|
||||
- `src/lib/api/items.ts` - 품목 API 클라이언트
|
||||
- `src/components/items/ItemListClient.tsx` - 품목 목록 화면
|
||||
- `src/components/items/ItemDetailClient.tsx` - 품목 상세 화면
|
||||
- `src/components/items/ItemForm.tsx` - 품목 등록/수정 폼
|
||||
- `src/lib/utils/validation.ts` - Zod 검증 스키마
|
||||
|
||||
### 설정 파일
|
||||
- `.env.local` - API URL 및 키 설정
|
||||
|
||||
### 참조 문서
|
||||
- `claudedocs/item-master/[API-2025-11-23] item-master-backend-requirements.md` - 백엔드 요구사항
|
||||
- `claudedocs/item-master/[DESIGN-2025-11-24] item-management-dynamic-frontend.md` - 동적 화면 설계
|
||||
64
package-lock.json
generated
64
package-lock.json
generated
@@ -41,6 +41,7 @@
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
@@ -1092,6 +1093,22 @@
|
||||
"node": ">=12.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
|
||||
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.57.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/number": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||
@@ -5787,6 +5804,21 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -7509,6 +7541,38 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
|
||||
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.57.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
|
||||
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:headed": "playwright test --headed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
@@ -42,6 +45,7 @@
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
|
||||
@@ -72,8 +72,77 @@ async function refreshAccessToken(refreshToken: string): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 백엔드 API 요청 실행 함수
|
||||
*/
|
||||
async function executeBackendRequest(
|
||||
url: URL,
|
||||
method: string,
|
||||
token: string | undefined,
|
||||
body: string | undefined,
|
||||
contentType: string
|
||||
): Promise<Response> {
|
||||
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}` : '',
|
||||
},
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿠키 생성 헬퍼 함수
|
||||
*/
|
||||
function createTokenCookies(tokens: { accessToken?: string; refreshToken?: string; expiresIn?: number }) {
|
||||
const cookies: string[] = [];
|
||||
|
||||
if (tokens.accessToken) {
|
||||
cookies.push([
|
||||
`access_token=${tokens.accessToken}`,
|
||||
'HttpOnly',
|
||||
'Secure',
|
||||
'SameSite=Strict',
|
||||
'Path=/',
|
||||
`Max-Age=${tokens.expiresIn || 7200}`,
|
||||
].join('; '));
|
||||
}
|
||||
|
||||
if (tokens.refreshToken) {
|
||||
cookies.push([
|
||||
`refresh_token=${tokens.refreshToken}`,
|
||||
'HttpOnly',
|
||||
'Secure',
|
||||
'SameSite=Strict',
|
||||
'Path=/',
|
||||
'Max-Age=604800', // 7 days
|
||||
].join('; '));
|
||||
}
|
||||
|
||||
return cookies;
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿠키 삭제 헬퍼 함수 (토큰 만료 시)
|
||||
*/
|
||||
function createClearTokenCookies(): string[] {
|
||||
return [
|
||||
'access_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0',
|
||||
'refresh_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Catch-all proxy handler for all HTTP methods
|
||||
*
|
||||
* 🔄 토큰 갱신 로직:
|
||||
* 1. 현재 access_token으로 백엔드 요청
|
||||
* 2. 401 응답 시 → refresh_token으로 새 토큰 발급
|
||||
* 3. 새 토큰으로 원래 요청 재시도
|
||||
* 4. 재시도도 실패하면 → 쿠키 삭제 후 401 반환
|
||||
*/
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
@@ -85,21 +154,8 @@ async function proxyRequest(
|
||||
let token = request.cookies.get('access_token')?.value;
|
||||
const refreshToken = request.cookies.get('refresh_token')?.value;
|
||||
|
||||
// 1-1. access_token이 없고 refresh_token이 있으면 자동 갱신
|
||||
let newTokens: { accessToken?: string; refreshToken?: string; expiresIn?: number } | null = null;
|
||||
if (!token && refreshToken) {
|
||||
console.log('🔄 [PROXY] No access_token, attempting refresh...');
|
||||
const refreshResult = await refreshAccessToken(refreshToken);
|
||||
if (refreshResult.success && refreshResult.accessToken) {
|
||||
token = refreshResult.accessToken;
|
||||
newTokens = refreshResult;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 백엔드 URL 구성
|
||||
const backendUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/${params.path.join('/')}`;
|
||||
|
||||
// 쿼리 파라미터 추가
|
||||
const url = new URL(backendUrl);
|
||||
request.nextUrl.searchParams.forEach((value, key) => {
|
||||
url.searchParams.append(key, value);
|
||||
@@ -107,48 +163,63 @@ async function proxyRequest(
|
||||
|
||||
// 3. 요청 바디 읽기 (POST, PUT, DELETE)
|
||||
let body: string | undefined;
|
||||
if (['POST', 'PUT', 'DELETE'].includes(method)) {
|
||||
// Content-Type에 따라 바디 처리
|
||||
const contentType = request.headers.get('content-type') || '';
|
||||
const contentType = request.headers.get('content-type') || 'application/json';
|
||||
|
||||
if (['POST', 'PUT', 'DELETE'].includes(method)) {
|
||||
if (contentType.includes('application/json')) {
|
||||
body = await request.text();
|
||||
|
||||
// 🔍 디버깅: 전송 데이터 로그
|
||||
console.log('🔵 [PROXY DEBUG] Request Details:');
|
||||
console.log(' Method:', method);
|
||||
console.log(' URL:', url.toString());
|
||||
console.log(' Body:', body);
|
||||
console.log(' Token:', token ? `${token.substring(0, 20)}...` : 'null');
|
||||
console.log('🔵 [PROXY] Request:', method, url.toString());
|
||||
console.log('🔵 [PROXY] Request Body:', body); // 디버깅용
|
||||
} else if (contentType.includes('multipart/form-data')) {
|
||||
// FormData는 그대로 전달
|
||||
const formData = await request.formData();
|
||||
// FormData를 백엔드로 전달하기 위해 다시 변환
|
||||
// multipart는 formData로 처리해야 하지만, 현재는 지원하지 않음
|
||||
console.warn('🟡 [PROXY] multipart/form-data is not fully supported');
|
||||
body = await request.text();
|
||||
}
|
||||
} else {
|
||||
console.log('🔵 [PROXY] Request:', method, url.toString());
|
||||
}
|
||||
|
||||
// 4. 백엔드로 프록시 요청
|
||||
const backendResponse = await fetch(url.toString(), {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': request.headers.get('content-type') || 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
},
|
||||
body,
|
||||
});
|
||||
let backendResponse = await executeBackendRequest(url, method, token, body, contentType);
|
||||
let newTokens: { accessToken?: string; refreshToken?: string; expiresIn?: number } | null = null;
|
||||
|
||||
// 5. 응답 데이터 읽기
|
||||
// 5. 🔄 401 응답 시 토큰 갱신 후 재시도
|
||||
if (backendResponse.status === 401 && refreshToken) {
|
||||
console.log('🔄 [PROXY] Got 401, attempting token refresh...');
|
||||
|
||||
const refreshResult = await refreshAccessToken(refreshToken);
|
||||
|
||||
if (refreshResult.success && refreshResult.accessToken) {
|
||||
console.log('✅ [PROXY] Token refreshed, retrying original request...');
|
||||
|
||||
// 새 토큰으로 원래 요청 재시도
|
||||
token = refreshResult.accessToken;
|
||||
newTokens = refreshResult;
|
||||
backendResponse = await executeBackendRequest(url, method, token, body, contentType);
|
||||
|
||||
console.log('🔵 [PROXY] Retry response status:', backendResponse.status);
|
||||
} else {
|
||||
// 리프레시 실패 → 쿠키 삭제하고 401 반환
|
||||
console.warn('🔴 [PROXY] Token refresh failed, clearing cookies...');
|
||||
|
||||
const clearResponse = NextResponse.json(
|
||||
{ error: 'Authentication failed', needsReauth: true },
|
||||
{ status: 401 }
|
||||
);
|
||||
|
||||
createClearTokenCookies().forEach(cookie => {
|
||||
clearResponse.headers.append('Set-Cookie', cookie);
|
||||
});
|
||||
|
||||
return clearResponse;
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 응답 데이터 읽기
|
||||
const responseData = await backendResponse.text();
|
||||
console.log('🔵 [PROXY] Response status:', backendResponse.status);
|
||||
|
||||
// 🔍 디버깅: 백엔드 응답 로그
|
||||
console.log('🔵 [PROXY DEBUG] Backend Response:');
|
||||
console.log(' Status:', backendResponse.status);
|
||||
console.log(' Response:', responseData.substring(0, 500)); // 처음 500자만
|
||||
|
||||
// 6. 클라이언트로 응답 전달
|
||||
// 7. 클라이언트로 응답 전달
|
||||
const clientResponse = new NextResponse(responseData, {
|
||||
status: backendResponse.status,
|
||||
headers: {
|
||||
@@ -156,31 +227,11 @@ async function proxyRequest(
|
||||
},
|
||||
});
|
||||
|
||||
// 6-1. 토큰이 갱신되었으면 새 쿠키 설정
|
||||
// 8. 토큰이 갱신되었으면 새 쿠키 설정
|
||||
if (newTokens && newTokens.accessToken) {
|
||||
const accessTokenCookie = [
|
||||
`access_token=${newTokens.accessToken}`,
|
||||
'HttpOnly',
|
||||
'Secure',
|
||||
'SameSite=Strict',
|
||||
'Path=/',
|
||||
`Max-Age=${newTokens.expiresIn || 7200}`,
|
||||
].join('; ');
|
||||
|
||||
clientResponse.headers.append('Set-Cookie', accessTokenCookie);
|
||||
|
||||
if (newTokens.refreshToken) {
|
||||
const refreshTokenCookie = [
|
||||
`refresh_token=${newTokens.refreshToken}`,
|
||||
'HttpOnly',
|
||||
'Secure',
|
||||
'SameSite=Strict',
|
||||
'Path=/',
|
||||
'Max-Age=604800', // 7 days
|
||||
].join('; ');
|
||||
clientResponse.headers.append('Set-Cookie', refreshTokenCookie);
|
||||
}
|
||||
|
||||
createTokenCookies(newTokens).forEach(cookie => {
|
||||
clientResponse.headers.append('Set-Cookie', cookie);
|
||||
});
|
||||
console.log('🍪 [PROXY] New tokens set in cookies');
|
||||
}
|
||||
|
||||
|
||||
@@ -27,31 +27,45 @@ export function LoginPage() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
// 2025-11-27: isChecking 상태 제거 - 미들웨어에서 인증 체크하므로 불필요
|
||||
// const [isChecking, setIsChecking] = useState(true);
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(false); // ✅ 로그인 진행 중 상태
|
||||
|
||||
// 이미 로그인된 상태인지 확인 (페이지 로드 시, 뒤로가기 시)
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
// 🔵 Next.js 내부 API - 쿠키에서 토큰 확인 (PHP 호출 X, 성능 최적화)
|
||||
const response = await fetch('/api/auth/check');
|
||||
|
||||
if (response.ok) {
|
||||
// 이미 로그인됨 → 대시보드로 리다이렉트 (replace로 히스토리에서 제거)
|
||||
router.replace('/dashboard');
|
||||
return;
|
||||
}
|
||||
// 인증 안됨 (401) → 현재 페이지 유지
|
||||
} catch {
|
||||
// API 호출 실패 → 현재 페이지 유지
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [router]);
|
||||
/**
|
||||
* 🚫 2025-11-27: auth/check API 호출 제거
|
||||
*
|
||||
* [이전 동작]
|
||||
* - 로그인 페이지 진입 시 /api/auth/check 호출
|
||||
* - 이미 로그인된 사용자를 대시보드로 리다이렉트
|
||||
*
|
||||
* [제거 이유]
|
||||
* 1. 미들웨어(middleware.ts)에서 이미 동일한 처리를 함
|
||||
* - guestOnlyRoutes(/login, /signup)에서 인증된 사용자 → /dashboard 리다이렉트
|
||||
* 2. 401 응답이 Network 탭에 에러로 표시되어 백엔드 개발자 혼란 유발
|
||||
* 3. 불필요한 API 호출로 인한 성능 저하
|
||||
*
|
||||
* [대체 방안]
|
||||
* - 미들웨어가 서버 사이드에서 쿠키 체크 후 리다이렉트 처리
|
||||
* - 클라이언트에서 추가 API 호출 불필요
|
||||
*
|
||||
* @see middleware.ts - isGuestOnlyRoute(), checkAuthentication()
|
||||
*/
|
||||
// useEffect(() => {
|
||||
// const checkAuth = async () => {
|
||||
// try {
|
||||
// const response = await fetch('/api/auth/check');
|
||||
// if (response.ok) {
|
||||
// router.replace('/dashboard');
|
||||
// return;
|
||||
// }
|
||||
// } catch {
|
||||
// // API 호출 실패 → 현재 페이지 유지
|
||||
// } finally {
|
||||
// setIsChecking(false);
|
||||
// }
|
||||
// };
|
||||
// checkAuth();
|
||||
// }, [router]);
|
||||
|
||||
const handleLogin = async () => {
|
||||
// ✅ 중복 요청 방지
|
||||
@@ -137,17 +151,17 @@ export function LoginPage() {
|
||||
};
|
||||
|
||||
|
||||
// 인증 체크 중일 때는 로딩 표시
|
||||
if (isChecking) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 2025-11-27: isChecking 로딩 UI 제거 - 미들웨어에서 처리하므로 불필요
|
||||
// if (isChecking) {
|
||||
// return (
|
||||
// <div className="min-h-screen flex items-center justify-center">
|
||||
// <div className="text-center">
|
||||
// <div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
|
||||
// <p className="text-muted-foreground">Loading...</p>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
|
||||
140
src/components/common/ServerErrorPage.tsx
Normal file
140
src/components/common/ServerErrorPage.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
ServerCrash,
|
||||
RefreshCw,
|
||||
Home,
|
||||
ArrowLeft,
|
||||
MessageCircleQuestion,
|
||||
} from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface ServerErrorPageProps {
|
||||
title?: string;
|
||||
message?: string;
|
||||
errorCode?: string | number;
|
||||
onRetry?: () => void;
|
||||
showBackButton?: boolean;
|
||||
showHomeButton?: boolean;
|
||||
showContactInfo?: boolean;
|
||||
contactEmail?: string;
|
||||
}
|
||||
|
||||
export function ServerErrorPage({
|
||||
title = '서버 오류가 발생했습니다',
|
||||
message = '일시적인 문제가 발생했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
errorCode,
|
||||
onRetry,
|
||||
showBackButton = true,
|
||||
showHomeButton = true,
|
||||
showContactInfo = true,
|
||||
contactEmail = 'admin@company.com',
|
||||
}: ServerErrorPageProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const handleRetry = () => {
|
||||
if (onRetry) {
|
||||
onRetry();
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-200px)] flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl border border-border/20 bg-card/50 backdrop-blur">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="relative">
|
||||
<div className="w-24 h-24 bg-gradient-to-br from-red-500/20 to-orange-500/10 rounded-2xl flex items-center justify-center">
|
||||
<ServerCrash className="w-12 h-12 text-red-500" />
|
||||
</div>
|
||||
<div className="absolute -top-1 -right-1 w-6 h-6 bg-red-500 rounded-full flex items-center justify-center">
|
||||
<span className="text-xs text-white font-bold">!</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl md:text-3xl font-bold text-foreground mb-2">
|
||||
{title}
|
||||
</CardTitle>
|
||||
{errorCode && (
|
||||
<p className="text-muted-foreground text-sm md:text-base">
|
||||
오류 코드: <code className="bg-muted px-2 py-1 rounded text-xs">{errorCode}</code>
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="text-center space-y-6">
|
||||
<div className="bg-red-50 dark:bg-red-950/30 rounded-xl p-6 space-y-3 border border-red-200 dark:border-red-900/50">
|
||||
<p className="text-lg text-foreground font-medium">
|
||||
{message}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
문제가 지속되면 관리자에게 문의해 주세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center pt-4">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleRetry}
|
||||
className="rounded-xl bg-red-500 hover:bg-red-600"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
다시 시도
|
||||
</Button>
|
||||
|
||||
{showBackButton && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
className="rounded-xl"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
이전 페이지
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showHomeButton && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push('/dashboard')}
|
||||
className="rounded-xl"
|
||||
>
|
||||
<Home className="w-4 h-4 mr-2" />
|
||||
대시보드로 이동
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showContactInfo && (
|
||||
<div className="pt-6 border-t border-border/20">
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<MessageCircleQuestion className="w-4 h-4" />
|
||||
<span>
|
||||
문제가 계속되면{' '}
|
||||
<a
|
||||
href={`mailto:${contactEmail}`}
|
||||
className="text-primary hover:underline font-medium"
|
||||
>
|
||||
관리자에게 문의
|
||||
</a>
|
||||
해 주세요.
|
||||
</span>
|
||||
</div>
|
||||
{pathname && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
발생 위치: <code className="bg-muted px-1.5 py-0.5 rounded">{pathname}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
365
src/components/items/ItemForm/BOMSection.tsx
Normal file
365
src/components/items/ItemForm/BOMSection.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* BOMSection - 부품 구성 (BOM) 섹션
|
||||
*/
|
||||
|
||||
import { Fragment } from 'react';
|
||||
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 { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Check, Package, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import type { BOMLine } from '@/types/item';
|
||||
import type { BOMSearchState } from './types';
|
||||
|
||||
export interface BOMSectionProps {
|
||||
bomLines: BOMLine[];
|
||||
setBomLines: (lines: BOMLine[]) => void;
|
||||
bomSearchStates: Record<string, BOMSearchState>;
|
||||
setBomSearchStates: (states: Record<string, BOMSearchState>) => void;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export default function BOMSection({
|
||||
bomLines,
|
||||
setBomLines,
|
||||
bomSearchStates,
|
||||
setBomSearchStates,
|
||||
isSubmitting,
|
||||
}: BOMSectionProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>부품 구성 (BOM)</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newLine: BOMLine = {
|
||||
id: `bom-${Date.now()}`,
|
||||
childItemCode: '',
|
||||
childItemName: '',
|
||||
quantity: 1,
|
||||
unit: 'EA',
|
||||
};
|
||||
setBomLines([...bomLines, newLine]);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
BOM 품목 추가
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{bomLines.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Package className="h-16 w-16 text-muted-foreground mb-4 opacity-20" />
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
아직 부품 구성이 추가되지 않았습니다
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
품목의 구성 부품, 원자재, 부자재를 추가할 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[280px]">품목코드 / 품목명 입력</TableHead>
|
||||
<TableHead className="w-[180px]">품목명</TableHead>
|
||||
<TableHead className="w-[150px]">규격</TableHead>
|
||||
<TableHead className="w-[100px]">재질</TableHead>
|
||||
<TableHead className="w-20">수량</TableHead>
|
||||
<TableHead className="w-16">단위</TableHead>
|
||||
<TableHead className="w-24 text-right">단가</TableHead>
|
||||
<TableHead className="w-[180px]">비고</TableHead>
|
||||
<TableHead className="w-16">삭제</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{bomLines.map((line) => {
|
||||
// 각 라인별 검색 상태 가져오기
|
||||
const searchState = bomSearchStates[line.id] || { searchValue: '', isOpen: false };
|
||||
const searchValue = searchState.searchValue;
|
||||
const searchOpen = searchState.isOpen;
|
||||
|
||||
// TODO: 실제 itemMasters 데이터로 교체 필요
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const availableItems: any[] = [];
|
||||
|
||||
return (
|
||||
<Fragment key={line.id}>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Popover
|
||||
open={searchOpen}
|
||||
onOpenChange={(open) => {
|
||||
setBomSearchStates({
|
||||
...bomSearchStates,
|
||||
[line.id]: { ...searchState, isOpen: open },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 relative">
|
||||
<PopoverAnchor asChild>
|
||||
<Input
|
||||
placeholder="품목코드 또는 품목명 입력..."
|
||||
value={line.childItemCode || searchValue}
|
||||
onChange={(e) => {
|
||||
// 단순 입력만 처리 (서버 자동완성 준비)
|
||||
setBomSearchStates({
|
||||
...bomSearchStates,
|
||||
[line.id]: { ...searchState, searchValue: e.target.value },
|
||||
});
|
||||
}}
|
||||
className="w-full"
|
||||
readOnly={!!line.childItemCode}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
{line.childItemCode && (
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setBomSearchStates({
|
||||
...bomSearchStates,
|
||||
[line.id]: { searchValue: '', isOpen: true },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[400px] p-0 opacity-0 data-[state=open]:opacity-100 transition-opacity duration-150 delay-200"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
disableSlideAnimation
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="품목코드 또는 품목명 검색..."
|
||||
value={searchValue}
|
||||
onValueChange={(value) => {
|
||||
setBomSearchStates({
|
||||
...bomSearchStates,
|
||||
[line.id]: { ...searchState, searchValue: value },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableItems.map((item) => (
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
value={`${item.itemCode} ${item.itemName}`}
|
||||
onSelect={() => {
|
||||
// TODO: 품목 선택 시 데이터 채우기 로직
|
||||
const isBendingPart = item.partType === 'BENDING';
|
||||
|
||||
setBomLines(
|
||||
bomLines.map((l) =>
|
||||
l.id === line.id
|
||||
? {
|
||||
...l,
|
||||
childItemCode: item.itemCode || '',
|
||||
childItemName: item.itemName || '',
|
||||
specification: item.specification || '',
|
||||
material: item.material || '',
|
||||
unit: item.unit || 'EA',
|
||||
unitPrice: 0, // TODO: pricing에서 가져오기
|
||||
isBending: isBendingPart,
|
||||
bendingDiagram: isBendingPart
|
||||
? item.bendingDiagram
|
||||
: undefined,
|
||||
}
|
||||
: l
|
||||
)
|
||||
);
|
||||
setBomSearchStates({
|
||||
...bomSearchStates,
|
||||
[line.id]: { searchValue: '', isOpen: false },
|
||||
});
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs bg-gray-100 px-2 py-0.5 rounded">
|
||||
{item.itemCode}
|
||||
</code>
|
||||
<span className="text-sm">{item.itemName}</span>
|
||||
{item.specification && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({item.specification})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{item.unit}
|
||||
</Badge>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{line.childItemName || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{line.specification || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
<Input
|
||||
value={line.material || ''}
|
||||
onChange={(e) => {
|
||||
setBomLines(
|
||||
bomLines.map((l) =>
|
||||
l.id === line.id ? { ...l, material: e.target.value } : l
|
||||
)
|
||||
);
|
||||
}}
|
||||
placeholder="재질"
|
||||
className="w-full text-xs"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={line.quantity}
|
||||
onChange={(e) => {
|
||||
setBomLines(
|
||||
bomLines.map((l) =>
|
||||
l.id === line.id ? { ...l, quantity: Number(e.target.value) } : l
|
||||
)
|
||||
);
|
||||
}}
|
||||
min="0"
|
||||
step="0.01"
|
||||
className="w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{line.unit}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={line.unitPrice || 0}
|
||||
onChange={(e) => {
|
||||
setBomLines(
|
||||
bomLines.map((l) =>
|
||||
l.id === line.id ? { ...l, unitPrice: Number(e.target.value) } : l
|
||||
)
|
||||
);
|
||||
}}
|
||||
min="0"
|
||||
className="w-full text-right"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={line.note || ''}
|
||||
onChange={(e) => {
|
||||
setBomLines(
|
||||
bomLines.map((l) =>
|
||||
l.id === line.id ? { ...l, note: e.target.value } : l
|
||||
)
|
||||
);
|
||||
}}
|
||||
placeholder="비고"
|
||||
className="w-full text-xs"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setBomLines(bomLines.filter((l) => l.id !== line.id));
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-600" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 절곡품인 경우 전개도 정보 표시 */}
|
||||
{line.isBending && line.bendingDiagram && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="bg-blue-50 p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="bg-blue-100 text-blue-700">
|
||||
절곡품 전개도 정보
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 전개도 이미지 */}
|
||||
<div>
|
||||
<Label className="text-xs mb-2 block">전개도 이미지</Label>
|
||||
<div className="border rounded-lg p-2 bg-white">
|
||||
<img
|
||||
src={line.bendingDiagram}
|
||||
alt="절곡 전개도"
|
||||
className="max-w-full h-auto max-h-[300px] object-contain mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
367
src/components/items/ItemForm/BendingDiagramSection.tsx
Normal file
367
src/components/items/ItemForm/BendingDiagramSection.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* BendingDiagramSection - 절곡품/조립품 전개도 섹션
|
||||
*/
|
||||
|
||||
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 { FileImage, Plus, Trash2, X } from 'lucide-react';
|
||||
import type { BendingDetail } from '@/types/item';
|
||||
import type { UseFormSetValue } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
|
||||
export interface BendingDiagramSectionProps {
|
||||
selectedPartType: string;
|
||||
bendingDiagramInputMethod: 'file' | 'drawing';
|
||||
setBendingDiagramInputMethod: (method: 'file' | 'drawing') => void;
|
||||
bendingDiagram: string;
|
||||
setBendingDiagram: (diagram: string) => void;
|
||||
setBendingDiagramFile: (file: File | null) => void;
|
||||
setIsDrawingOpen: (open: boolean) => void;
|
||||
bendingDetails: BendingDetail[];
|
||||
setBendingDetails: (details: BendingDetail[]) => void;
|
||||
setWidthSum: (sum: string) => void;
|
||||
setValue: UseFormSetValue<CreateItemFormData>;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export default function BendingDiagramSection({
|
||||
selectedPartType,
|
||||
bendingDiagramInputMethod,
|
||||
setBendingDiagramInputMethod,
|
||||
bendingDiagram,
|
||||
setBendingDiagram,
|
||||
setBendingDiagramFile,
|
||||
setIsDrawingOpen,
|
||||
bendingDetails,
|
||||
setBendingDetails,
|
||||
setWidthSum,
|
||||
setValue,
|
||||
isSubmitting,
|
||||
}: BendingDiagramSectionProps) {
|
||||
// 폭 합계 업데이트 헬퍼
|
||||
const updateWidthSum = (details: BendingDetail[]) => {
|
||||
const totalSum = details.reduce((acc, d) => {
|
||||
const calc = d.input + d.elongation;
|
||||
return acc + calc;
|
||||
}, 0);
|
||||
setWidthSum(totalSum.toString());
|
||||
setValue('length', totalSum.toString());
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileImage className="h-5 w-5" />
|
||||
{selectedPartType === 'ASSEMBLY' ? '조립품 전개도 (바라시)' : '절곡품 전개도 (바라시)'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 입력방식 선택 */}
|
||||
<div>
|
||||
<Label>입력방식 선택</Label>
|
||||
<div className="mt-2 flex gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
id="input-file"
|
||||
name="bendingInputMethod"
|
||||
value="file"
|
||||
checked={bendingDiagramInputMethod === 'file'}
|
||||
onChange={(e) => setBendingDiagramInputMethod(e.target.value as 'file')}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label htmlFor="input-file" className="cursor-pointer font-normal">
|
||||
파일 선택 (이미지 파일 업로드)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
id="input-drawing"
|
||||
name="bendingInputMethod"
|
||||
value="drawing"
|
||||
checked={bendingDiagramInputMethod === 'drawing'}
|
||||
onChange={(e) => setBendingDiagramInputMethod(e.target.value as 'drawing')}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label htmlFor="input-drawing" className="cursor-pointer font-normal">
|
||||
드로잉 (직접 그리기)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 전개도 이미지를 파일로 업로드하거나 직접 그릴 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 파일 선택 방식 */}
|
||||
{bendingDiagramInputMethod === 'file' && (
|
||||
<div>
|
||||
<Label>파일 선택</Label>
|
||||
<div className="mt-2 space-y-3">
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/*,.pdf"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file && typeof window !== 'undefined') {
|
||||
setBendingDiagramFile(file);
|
||||
const reader = new window.FileReader();
|
||||
reader.onloadend = () => {
|
||||
setBendingDiagram(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
* {selectedPartType === 'ASSEMBLY'
|
||||
? '조립품 전개도 이미지를 업로드하세요(JPG, PNG, PDF 등)'
|
||||
: '절곡품 전개도 이미지를 업로드하세요(JPG, PNG, PDF 등)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 전개도 이미지 미리보기 */}
|
||||
{bendingDiagram && (
|
||||
<div className="mt-4 p-4 border rounded-lg bg-gray-50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-medium">미리보기</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setBendingDiagram('');
|
||||
setBendingDiagramFile(null);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
전개도 삭제
|
||||
</Button>
|
||||
</div>
|
||||
<img
|
||||
src={bendingDiagram}
|
||||
alt="전개도 미리보기"
|
||||
className="max-w-full h-auto max-h-96 mx-auto border rounded bg-white"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 드로잉 방식 */}
|
||||
{bendingDiagramInputMethod === 'drawing' && (
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setIsDrawingOpen(true)}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
>
|
||||
<FileImage className="h-4 w-4 mr-2" />
|
||||
{bendingDiagram ? '전개도 수정' : '전개도 그리기'}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
* 캔버스에서 직접 전개도를 그릴 수 있습니다
|
||||
</p>
|
||||
|
||||
{/* 전개도 미리보기 */}
|
||||
{bendingDiagram && (
|
||||
<div className="mt-4 p-4 border rounded-lg bg-gray-50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-medium">미리보기</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setBendingDiagram('')}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
전개도 삭제
|
||||
</Button>
|
||||
</div>
|
||||
<img
|
||||
src={bendingDiagram}
|
||||
alt="전개도 미리보기"
|
||||
className="max-w-full h-auto max-h-96 mx-auto border rounded bg-white"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 전개도 상세 입력 (치수 계산) - BENDING 전용 */}
|
||||
{selectedPartType === 'BENDING' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>전개도 상세 입력 (치수 계산)</Label>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const newId = `detail-${Date.now()}`;
|
||||
const newRow: BendingDetail = {
|
||||
id: newId,
|
||||
no: bendingDetails.length + 1,
|
||||
input: 0,
|
||||
elongation: -1,
|
||||
calculated: 0,
|
||||
sum: 0,
|
||||
shaded: false,
|
||||
aAngle: undefined,
|
||||
};
|
||||
setBendingDetails([...bendingDetails, newRow]);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
행 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{bendingDetails.length > 0 ? (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-center w-16">번호</th>
|
||||
<th className="px-3 py-2 text-center">입력값</th>
|
||||
<th className="px-3 py-2 text-center">연신율</th>
|
||||
<th className="px-3 py-2 text-center">계산값</th>
|
||||
<th className="px-3 py-2 text-center w-20">음영</th>
|
||||
<th className="px-3 py-2 text-center">A각</th>
|
||||
<th className="px-3 py-2 text-center w-16">삭제</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bendingDetails.map((detail, index) => {
|
||||
const calculated = detail.input + detail.elongation;
|
||||
|
||||
return (
|
||||
<tr key={detail.id} className={detail.shaded ? 'bg-gray-100' : ''}>
|
||||
<td className="px-3 py-2 text-center border-b">{detail.no}</td>
|
||||
<td className="px-3 py-2 border-b">
|
||||
<Input
|
||||
type="number"
|
||||
value={detail.input}
|
||||
onChange={(e) => {
|
||||
const newDetails = [...bendingDetails];
|
||||
const value = e.target.value === '' ? 0 : parseFloat(e.target.value);
|
||||
newDetails[index] = {
|
||||
...detail,
|
||||
input: isNaN(value) ? 0 : value,
|
||||
};
|
||||
setBendingDetails(newDetails);
|
||||
updateWidthSum(newDetails);
|
||||
}}
|
||||
className="h-8 text-center"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 border-b">
|
||||
<Input
|
||||
type="number"
|
||||
value={detail.elongation}
|
||||
onChange={(e) => {
|
||||
const newDetails = [...bendingDetails];
|
||||
const value = e.target.value === '' ? -1 : parseFloat(e.target.value);
|
||||
newDetails[index] = {
|
||||
...detail,
|
||||
elongation: isNaN(value) ? -1 : value,
|
||||
};
|
||||
setBendingDetails(newDetails);
|
||||
updateWidthSum(newDetails);
|
||||
}}
|
||||
className="h-8 text-center"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center border-b bg-gray-50">
|
||||
{calculated.toFixed(1)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center border-b">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={detail.shaded}
|
||||
onChange={(e) => {
|
||||
const newDetails = [...bendingDetails];
|
||||
newDetails[index] = {
|
||||
...detail,
|
||||
shaded: e.target.checked,
|
||||
};
|
||||
setBendingDetails(newDetails);
|
||||
}}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 border-b">
|
||||
<Input
|
||||
type="number"
|
||||
value={detail.aAngle || ''}
|
||||
onChange={(e) => {
|
||||
const newDetails = [...bendingDetails];
|
||||
newDetails[index] = {
|
||||
...detail,
|
||||
aAngle: parseFloat(e.target.value) || undefined,
|
||||
};
|
||||
setBendingDetails(newDetails);
|
||||
}}
|
||||
className="h-8 text-center"
|
||||
placeholder="각도"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center border-b">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const newDetails = bendingDetails
|
||||
.filter((_, i) => i !== index)
|
||||
.map((d, i) => ({ ...d, no: i + 1 }));
|
||||
setBendingDetails(newDetails);
|
||||
updateWidthSum(newDetails);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot className="bg-gray-50 border-t-2">
|
||||
<tr>
|
||||
<td colSpan={3} className="px-3 py-2 text-right font-semibold">
|
||||
폭 합계:
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center font-bold text-blue-600">
|
||||
{bendingDetails.length > 0
|
||||
? bendingDetails.reduce((acc, d) => {
|
||||
const calc = d.input + d.elongation;
|
||||
return acc + calc;
|
||||
}, 0).toFixed(1)
|
||||
: '0.0'} mm
|
||||
</td>
|
||||
<td colSpan={3}></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground border rounded-lg bg-gray-50">
|
||||
전개도 상세 데이터가 없습니다. "행 추가" 버튼을 클릭하여 추가하세요.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
* 전개도의 각 구간별 치수를 입력하여 정확한 전개 길이를 계산할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
62
src/components/items/ItemForm/FormHeader.tsx
Normal file
62
src/components/items/ItemForm/FormHeader.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* FormHeader - 품목 폼 헤더 컴포넌트
|
||||
*/
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Package, Save, X } from 'lucide-react';
|
||||
import type { ItemType } from '@/types/item';
|
||||
|
||||
interface FormHeaderProps {
|
||||
mode: 'create' | 'edit';
|
||||
selectedItemType: ItemType | '';
|
||||
isSubmitting: boolean;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function FormHeader({
|
||||
mode,
|
||||
selectedItemType,
|
||||
isSubmitting,
|
||||
onCancel,
|
||||
}: FormHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg hidden md:block">
|
||||
<Package className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl md:text-2xl">
|
||||
{mode === 'create' ? '품목 등록' : '품목 수정'}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
품목 정보를 입력하세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 sm:gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
className="gap-1 sm:gap-2"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">취소</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={!selectedItemType || isSubmitting}
|
||||
className="gap-1 sm:gap-2"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{isSubmitting ? '저장 중...' : '저장'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/components/items/ItemForm/ValidationAlert.tsx
Normal file
50
src/components/items/ItemForm/ValidationAlert.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* ValidationAlert - 폼 검증 에러 표시 컴포넌트
|
||||
*/
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { FIELD_NAME_MAP } from './constants';
|
||||
import type { FieldErrors } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
|
||||
interface ValidationAlertProps {
|
||||
errors: FieldErrors<CreateItemFormData>;
|
||||
}
|
||||
|
||||
export default function ValidationAlert({ errors }: ValidationAlertProps) {
|
||||
const errorCount = Object.keys(errors).length;
|
||||
|
||||
if (errorCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({errorCount}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{Object.entries(errors).map(([field, error]) => {
|
||||
const fieldName = FIELD_NAME_MAP[field] || field;
|
||||
const errorMessage = error?.message || '입력 오류';
|
||||
|
||||
return (
|
||||
<li key={field} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>
|
||||
<strong>{fieldName}</strong>: {errorMessage}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
93
src/components/items/ItemForm/constants.ts
Normal file
93
src/components/items/ItemForm/constants.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* ItemForm 상수 정의
|
||||
*/
|
||||
|
||||
// 부품 유형별 분류 체계
|
||||
export const PART_TYPE_CATEGORIES = {
|
||||
ASSEMBLY: {
|
||||
label: "조립 부품 (Assembly Part)",
|
||||
categories: [
|
||||
{ value: "guide_rail", label: "가이드레일", code: "R" },
|
||||
{ value: "case", label: "케이스", code: "C" },
|
||||
{ value: "bottom_finish", label: "하단마감재", code: "B" },
|
||||
]
|
||||
},
|
||||
BENDING: {
|
||||
label: "절곡 부품 (Bending Part)",
|
||||
categories: [
|
||||
{ value: "guide_rail_wall", label: "가이드레일(벽면형)", code: "R" },
|
||||
{ value: "guide_rail_side", label: "가이드레일(측면형)", code: "S" },
|
||||
{ value: "case", label: "케이스", code: "C" },
|
||||
{ value: "bottom_finish_screen", label: "하단마감재(스크린)", code: "B" },
|
||||
{ value: "bottom_finish_steel", label: "하단마감재(철재)", code: "T" },
|
||||
{ value: "l_bar", label: "L-Bar", code: "L" },
|
||||
{ value: "smoke_barrier", label: "연기차단재", code: "G" },
|
||||
]
|
||||
},
|
||||
PURCHASED: {
|
||||
label: "구매 부품 (Purchased Part)",
|
||||
categories: [
|
||||
{ value: "electric_opener", label: "전동개폐기", code: "E" },
|
||||
{ value: "motor", label: "모터", code: "M" },
|
||||
{ value: "chain", label: "체인", code: "CH" },
|
||||
]
|
||||
}
|
||||
} as const;
|
||||
|
||||
// 부품 분류별 종류 옵션
|
||||
export const PART_ITEM_NAMES: Record<string, Array<{value: string, label: string, code: string}>> = {
|
||||
guide_rail_wall: [
|
||||
{ value: "RM", label: "분체", code: "M" },
|
||||
{ value: "RT", label: "분체(철재)", code: "T" },
|
||||
{ value: "RC", label: "C형", code: "C" },
|
||||
{ value: "RD", label: "D형", code: "D" },
|
||||
{ value: "RS", label: "SUS 마감재", code: "S" },
|
||||
{ value: "RM2", label: "분체티딩", code: "M" },
|
||||
],
|
||||
guide_rail_side: [
|
||||
{ value: "SC", label: "C형", code: "C" },
|
||||
{ value: "SD", label: "D형", code: "D" },
|
||||
{ value: "SS", label: "SUS 마감재①", code: "S" },
|
||||
{ value: "SU", label: "SUS 마감재②", code: "U" },
|
||||
{ value: "SF", label: "전면부", code: "F" },
|
||||
{ value: "SP", label: "점검구", code: "P" },
|
||||
],
|
||||
case: [
|
||||
{ value: "CF", label: "전면부", code: "F" },
|
||||
{ value: "CP", label: "점검구", code: "P" },
|
||||
{ value: "CL", label: "린텔부", code: "L" },
|
||||
{ value: "CB", label: "후면코너부", code: "B" },
|
||||
],
|
||||
bottom_finish_screen: [
|
||||
{ value: "BS", label: "SUS", code: "S" },
|
||||
{ value: "BE", label: "EGI", code: "E" },
|
||||
],
|
||||
bottom_finish_steel: [
|
||||
{ value: "TS", label: "SUS", code: "S" },
|
||||
{ value: "TE", label: "EGI", code: "E" },
|
||||
],
|
||||
l_bar: [
|
||||
{ value: "LA", label: "스크린용", code: "A" },
|
||||
],
|
||||
smoke_barrier: [
|
||||
{ value: "GI", label: "화이바원단(W50)", code: "I" },
|
||||
{ value: "GI2", label: "화이바원단(W80)", code: "I" },
|
||||
],
|
||||
};
|
||||
|
||||
// 필드명 한글 매핑 (에러 메시지용)
|
||||
export const FIELD_NAME_MAP: Record<string, string> = {
|
||||
'productName': '상품명',
|
||||
'itemName': '품목명',
|
||||
'itemType': '품목 유형',
|
||||
'partType': '부품 유형',
|
||||
'category1': '품목명',
|
||||
'material': '재질',
|
||||
'length': '폭 합계',
|
||||
'bendingLength': '모양&길이',
|
||||
'sideSpecWidth': '측면 규격 (가로)',
|
||||
'sideSpecHeight': '측면 규격 (세로)',
|
||||
'assemblyLength': '길이',
|
||||
'specification': '규격',
|
||||
'unit': '단위',
|
||||
};
|
||||
77
src/components/items/ItemForm/context/ItemFormContext.tsx
Normal file
77
src/components/items/ItemForm/context/ItemFormContext.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* ItemFormContext - 품목 폼 상태 컨텍스트
|
||||
*
|
||||
* 하위 컴포넌트에서 공유되는 폼 상태 관리
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, ReactNode } from 'react';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
import type { ItemType } from '@/types/item';
|
||||
import type { UseItemFormStateReturn } from '../hooks/useItemFormState';
|
||||
import type { UseBOMManagementReturn } from '../hooks/useBOMManagement';
|
||||
import type { UseBendingDetailsReturn } from '../hooks/useBendingDetails';
|
||||
|
||||
export interface ItemFormContextType {
|
||||
// React Hook Form
|
||||
form: UseFormReturn<CreateItemFormData>;
|
||||
|
||||
// 모드
|
||||
mode: 'create' | 'edit';
|
||||
|
||||
// 품목 유형
|
||||
selectedItemType: ItemType | '';
|
||||
setSelectedItemType: (type: ItemType | '') => void;
|
||||
|
||||
// 부품 유형
|
||||
selectedPartType: string;
|
||||
setSelectedPartType: (type: string) => void;
|
||||
|
||||
// 상태 훅
|
||||
formState: UseItemFormStateReturn;
|
||||
bomManagement: UseBOMManagementReturn;
|
||||
bendingDetails: UseBendingDetailsReturn;
|
||||
|
||||
// 품목코드 생성
|
||||
generateItemCode: () => string;
|
||||
|
||||
// 품목 유형 변경 핸들러
|
||||
handleItemTypeChange: (type: ItemType) => void;
|
||||
|
||||
// 제출 상태
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
const ItemFormContext = createContext<ItemFormContextType | null>(null);
|
||||
|
||||
export interface ItemFormProviderProps {
|
||||
children: ReactNode;
|
||||
value: ItemFormContextType;
|
||||
}
|
||||
|
||||
export function ItemFormProvider({ children, value }: ItemFormProviderProps) {
|
||||
return (
|
||||
<ItemFormContext.Provider value={value}>
|
||||
{children}
|
||||
</ItemFormContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useItemFormContext(): ItemFormContextType {
|
||||
const context = useContext(ItemFormContext);
|
||||
if (!context) {
|
||||
throw new Error('useItemFormContext must be used within an ItemFormProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택적으로 컨텍스트 사용 (컨텍스트가 없어도 에러 안 남)
|
||||
*/
|
||||
export function useOptionalItemFormContext(): ItemFormContextType | null {
|
||||
return useContext(ItemFormContext);
|
||||
}
|
||||
|
||||
export default ItemFormContext;
|
||||
12
src/components/items/ItemForm/context/index.ts
Normal file
12
src/components/items/ItemForm/context/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 품목 폼 컨텍스트 export
|
||||
*/
|
||||
|
||||
export {
|
||||
ItemFormProvider,
|
||||
useItemFormContext,
|
||||
useOptionalItemFormContext,
|
||||
default as ItemFormContext,
|
||||
} from './ItemFormContext';
|
||||
|
||||
export type { ItemFormContextType, ItemFormProviderProps } from './ItemFormContext';
|
||||
354
src/components/items/ItemForm/forms/MaterialForm.tsx
Normal file
354
src/components/items/ItemForm/forms/MaterialForm.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* 원자재/부자재/소모품 (RM/SM/CS) 폼 컴포넌트
|
||||
*/
|
||||
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { ItemType } from '@/types/item';
|
||||
import type { UseFormRegister, UseFormSetValue, UseFormGetValues, FieldErrors } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
|
||||
interface MaterialFormProps {
|
||||
selectedItemType: ItemType;
|
||||
itemName: string;
|
||||
setItemName: (value: string) => void;
|
||||
selectedSpecification: string;
|
||||
setSelectedSpecification: (value: string) => void;
|
||||
materialStatus: string;
|
||||
setMaterialStatus: (value: string) => void;
|
||||
selectedUnit: string;
|
||||
setSelectedUnit: (value: string) => void;
|
||||
register: UseFormRegister<CreateItemFormData>;
|
||||
setValue: UseFormSetValue<CreateItemFormData>;
|
||||
getValues: UseFormGetValues<CreateItemFormData>;
|
||||
errors: FieldErrors<CreateItemFormData>;
|
||||
}
|
||||
|
||||
export default function MaterialForm({
|
||||
selectedItemType,
|
||||
itemName,
|
||||
setItemName,
|
||||
selectedSpecification,
|
||||
setSelectedSpecification,
|
||||
materialStatus,
|
||||
setMaterialStatus,
|
||||
selectedUnit,
|
||||
setSelectedUnit,
|
||||
register,
|
||||
setValue,
|
||||
getValues,
|
||||
errors,
|
||||
}: MaterialFormProps) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="itemName">
|
||||
품목명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
{/* 원자재/부자재는 목록에서 선택, 소모품은 직접 입력 */}
|
||||
{selectedItemType === 'RM' ? (
|
||||
<>
|
||||
<Select
|
||||
value={itemName}
|
||||
onValueChange={(value) => {
|
||||
setItemName(value);
|
||||
setValue('itemName', value);
|
||||
// 품목명 변경 시 규격 초기화
|
||||
setSelectedSpecification('');
|
||||
setValue('specification', '');
|
||||
// 품목코드 자동생성
|
||||
const spec = getValues('specification') || '';
|
||||
setValue('itemCode', spec ? `${value}-${spec}` : value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={errors.itemName ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="품목명을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="철판">철판</SelectItem>
|
||||
<SelectItem value="알루미늄">알루미늄</SelectItem>
|
||||
<SelectItem value="스테인리스">스테인리스</SelectItem>
|
||||
<SelectItem value="아연도금강판">아연도금강판</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.itemName && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.itemName.message}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : selectedItemType === 'SM' ? (
|
||||
<>
|
||||
<Select
|
||||
value={itemName}
|
||||
onValueChange={(value) => {
|
||||
setItemName(value);
|
||||
setValue('itemName', value);
|
||||
// 품목명 변경 시 규격 초기화
|
||||
setSelectedSpecification('');
|
||||
setValue('specification', '');
|
||||
// 품목코드 자동생성
|
||||
const spec = getValues('specification') || '';
|
||||
setValue('itemCode', spec ? `${value}-${spec}` : value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={errors.itemName ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="품목명을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="볼트">볼트</SelectItem>
|
||||
<SelectItem value="너트">너트</SelectItem>
|
||||
<SelectItem value="와셔">와셔</SelectItem>
|
||||
<SelectItem value="나사">나사</SelectItem>
|
||||
<SelectItem value="앵커">앵커</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.itemName && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.itemName.message}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
id="itemName"
|
||||
placeholder="품목명을 입력하세요"
|
||||
value={itemName}
|
||||
onChange={(e) => {
|
||||
const newName = e.target.value;
|
||||
setItemName(newName);
|
||||
setValue('itemName', newName);
|
||||
// 품목코드 자동생성
|
||||
const spec = getValues('specification') || '';
|
||||
setValue('itemCode', spec ? `${newName}-${spec}` : newName);
|
||||
}}
|
||||
className={errors.itemName ? 'border-red-500' : ''}
|
||||
/>
|
||||
{errors.itemName && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.itemName.message}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 규격(사양) */}
|
||||
{selectedItemType === 'CS' ? (
|
||||
<div>
|
||||
<Label htmlFor="specification">
|
||||
규격(사양) <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="specification"
|
||||
placeholder="예: 면-L, 고급형, A4"
|
||||
{...register('specification', {
|
||||
onChange: (e) => {
|
||||
// 품목코드 자동생성
|
||||
const spec = e.target.value;
|
||||
const name = itemName || '';
|
||||
setValue('itemCode', name && spec ? `${name}-${spec}` : name);
|
||||
}
|
||||
})}
|
||||
className={errors.specification ? 'border-red-500' : ''}
|
||||
/>
|
||||
{errors.specification && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.specification.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="specification">
|
||||
규격 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedSpecification}
|
||||
onValueChange={(value) => {
|
||||
setSelectedSpecification(value);
|
||||
setValue('specification', value);
|
||||
// 품목코드 자동생성
|
||||
const name = itemName || '';
|
||||
setValue('itemCode', name && value ? `${name}-${value}` : name);
|
||||
}}
|
||||
disabled={!itemName}
|
||||
>
|
||||
<SelectTrigger id="specification" className={errors.specification ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder={itemName ? "규격을 선택하세요" : "품목명을 먼저 선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectedItemType === 'RM' && itemName === '철판' && (
|
||||
<>
|
||||
<SelectItem value="1.0T">1.0T</SelectItem>
|
||||
<SelectItem value="1.2T">1.2T</SelectItem>
|
||||
<SelectItem value="1.5T">1.5T</SelectItem>
|
||||
<SelectItem value="2.0T">2.0T</SelectItem>
|
||||
</>
|
||||
)}
|
||||
{selectedItemType === 'RM' && itemName === '알루미늄' && (
|
||||
<>
|
||||
<SelectItem value="0.8T">0.8T</SelectItem>
|
||||
<SelectItem value="1.0T">1.0T</SelectItem>
|
||||
<SelectItem value="1.5T">1.5T</SelectItem>
|
||||
</>
|
||||
)}
|
||||
{selectedItemType === 'RM' && itemName === '스테인리스' && (
|
||||
<>
|
||||
<SelectItem value="0.5T">0.5T</SelectItem>
|
||||
<SelectItem value="1.0T">1.0T</SelectItem>
|
||||
<SelectItem value="1.2T">1.2T</SelectItem>
|
||||
</>
|
||||
)}
|
||||
{selectedItemType === 'SM' && itemName === '볼트' && (
|
||||
<>
|
||||
<SelectItem value="M6x20">M6×20mm</SelectItem>
|
||||
<SelectItem value="M8x25">M8×25mm</SelectItem>
|
||||
<SelectItem value="M10x30">M10×30mm</SelectItem>
|
||||
<SelectItem value="M12x40">M12×40mm</SelectItem>
|
||||
<SelectItem value="M16x50">M16×50mm</SelectItem>
|
||||
</>
|
||||
)}
|
||||
{selectedItemType === 'SM' && itemName === '너트' && (
|
||||
<>
|
||||
<SelectItem value="M6">M6</SelectItem>
|
||||
<SelectItem value="M8">M8</SelectItem>
|
||||
<SelectItem value="M10">M10</SelectItem>
|
||||
<SelectItem value="M12">M12</SelectItem>
|
||||
<SelectItem value="M16">M16</SelectItem>
|
||||
</>
|
||||
)}
|
||||
{selectedItemType === 'SM' && itemName === '와셔' && (
|
||||
<>
|
||||
<SelectItem value="M6">M6</SelectItem>
|
||||
<SelectItem value="M8">M8</SelectItem>
|
||||
<SelectItem value="M10">M10</SelectItem>
|
||||
<SelectItem value="M12">M12</SelectItem>
|
||||
<SelectItem value="M16">M16</SelectItem>
|
||||
</>
|
||||
)}
|
||||
{selectedItemType === 'SM' && itemName === '나사' && (
|
||||
<>
|
||||
<SelectItem value="4x20">4×20mm</SelectItem>
|
||||
<SelectItem value="5x25">5×25mm</SelectItem>
|
||||
<SelectItem value="6x30">6×30mm</SelectItem>
|
||||
<SelectItem value="8x40">8×40mm</SelectItem>
|
||||
<SelectItem value="10x50">10×50mm</SelectItem>
|
||||
</>
|
||||
)}
|
||||
{selectedItemType === 'SM' && itemName === '앵커' && (
|
||||
<>
|
||||
<SelectItem value="6x30">6×30mm</SelectItem>
|
||||
<SelectItem value="8x40">8×40mm</SelectItem>
|
||||
<SelectItem value="10x50">10×50mm</SelectItem>
|
||||
<SelectItem value="12x60">12×60mm</SelectItem>
|
||||
<SelectItem value="16x80">16×80mm</SelectItem>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.specification && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.specification.message}
|
||||
</p>
|
||||
)}
|
||||
{!errors.specification && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 규격은 품목명 선택 시 자동으로 필터링됩니다
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 품목코드 (자동생성) */}
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="itemCode-auto">품목코드 (자동생성)</Label>
|
||||
<Input
|
||||
id="itemCode-auto"
|
||||
placeholder="품목명과 규격이 입력되면 자동으로 생성됩니다"
|
||||
value={(() => {
|
||||
const name = itemName || '';
|
||||
const spec = getValues('specification') || '';
|
||||
return spec ? `${name}-${spec}` : name;
|
||||
})()}
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 품목코드는 '품목명-규격' 형식으로 자동 생성됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 품목 상태 (RM/SM만) */}
|
||||
{(selectedItemType === 'RM' || selectedItemType === 'SM') && (
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="isActive">품목 상태</Label>
|
||||
<Select
|
||||
value={materialStatus}
|
||||
onValueChange={(value) => {
|
||||
setMaterialStatus(value);
|
||||
setValue('isActive', value === 'true');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="isActive">
|
||||
<SelectValue placeholder="품목 상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">활성</SelectItem>
|
||||
<SelectItem value="false">비활성</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 비활성 시 품목 사용이 제한됩니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 단위 (RM/SM/CS 공통) */}
|
||||
<div>
|
||||
<Label htmlFor="unit">
|
||||
단위 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedUnit}
|
||||
onValueChange={(value) => {
|
||||
setSelectedUnit(value);
|
||||
setValue('unit', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="unit" className={errors.unit ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="단위를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="M">M (미터)</SelectItem>
|
||||
<SelectItem value="mm">mm (밀리미터)</SelectItem>
|
||||
<SelectItem value="EA">EA (개)</SelectItem>
|
||||
<SelectItem value="SET">SET (세트)</SelectItem>
|
||||
<SelectItem value="KG">KG (킬로그램)</SelectItem>
|
||||
<SelectItem value="T">T (톤)</SelectItem>
|
||||
<SelectItem value="BOX">BOX (박스)</SelectItem>
|
||||
<SelectItem value="L">L (리터)</SelectItem>
|
||||
<SelectItem value="M2">M² (제곱미터)</SelectItem>
|
||||
<SelectItem value="M3">M³ (세제곱미터)</SelectItem>
|
||||
<SelectItem value="ROLL">ROLL (롤)</SelectItem>
|
||||
<SelectItem value="SHEET">SHEET (장)</SelectItem>
|
||||
<SelectItem value="PACK">PACK (팩)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.unit && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.unit.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
273
src/components/items/ItemForm/forms/PartForm.tsx
Normal file
273
src/components/items/ItemForm/forms/PartForm.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* 부품 (PT) 폼 컴포넌트
|
||||
* - ASSEMBLY (조립 부품)
|
||||
* - BENDING (절곡 부품)
|
||||
* - PURCHASED (구매 부품)
|
||||
*/
|
||||
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { UseFormRegister, UseFormSetValue, UseFormClearErrors, FieldErrors } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
import { AssemblyPartForm, BendingPartForm, PurchasedPartForm } from './parts';
|
||||
|
||||
export interface PartFormProps {
|
||||
// Part Type
|
||||
selectedPartType: string;
|
||||
setSelectedPartType: (value: string) => void;
|
||||
// Category
|
||||
selectedCategory1: string;
|
||||
setSelectedCategory1: (value: string) => void;
|
||||
selectedInstallationType: string;
|
||||
setSelectedInstallationType: (value: string) => void;
|
||||
// ASSEMBLY
|
||||
sideSpecWidth: string;
|
||||
setSideSpecWidth: (value: string) => void;
|
||||
sideSpecHeight: string;
|
||||
setSideSpecHeight: (value: string) => void;
|
||||
assemblyLength: string;
|
||||
setAssemblyLength: (value: string) => void;
|
||||
assemblyUnit: string;
|
||||
setAssemblyUnit: (value: string) => void;
|
||||
// BENDING
|
||||
selectedBendingItemType: string;
|
||||
setSelectedBendingItemType: (value: string) => void;
|
||||
material: string;
|
||||
setMaterial: (value: string) => void;
|
||||
widthSum: string;
|
||||
setWidthSum: (value: string) => void;
|
||||
bendingLength: string;
|
||||
setBendingLength: (value: string) => void;
|
||||
partUnit: string;
|
||||
setPartUnit: (value: string) => void;
|
||||
bendingDetailsLength: number;
|
||||
// PURCHASED
|
||||
electricOpenerPower: string;
|
||||
setElectricOpenerPower: (value: string) => void;
|
||||
electricOpenerCapacity: string;
|
||||
setElectricOpenerCapacity: (value: string) => void;
|
||||
motorVoltage: string;
|
||||
setMotorVoltage: (value: string) => void;
|
||||
chainSpec: string;
|
||||
setChainSpec: (value: string) => void;
|
||||
// Common
|
||||
partStatus: string;
|
||||
setPartStatus: (value: string) => void;
|
||||
needsBOM: boolean;
|
||||
setNeedsBOM: (value: boolean) => void;
|
||||
// Item Code Generator
|
||||
generateItemCode: () => string;
|
||||
// Form
|
||||
register: UseFormRegister<CreateItemFormData>;
|
||||
setValue: UseFormSetValue<CreateItemFormData>;
|
||||
clearErrors: UseFormClearErrors<CreateItemFormData>;
|
||||
errors: FieldErrors<CreateItemFormData>;
|
||||
}
|
||||
|
||||
export default function PartForm({
|
||||
selectedPartType,
|
||||
setSelectedPartType,
|
||||
selectedCategory1,
|
||||
setSelectedCategory1,
|
||||
selectedInstallationType,
|
||||
setSelectedInstallationType,
|
||||
sideSpecWidth,
|
||||
setSideSpecWidth,
|
||||
sideSpecHeight,
|
||||
setSideSpecHeight,
|
||||
assemblyLength,
|
||||
setAssemblyLength,
|
||||
assemblyUnit,
|
||||
setAssemblyUnit,
|
||||
selectedBendingItemType,
|
||||
setSelectedBendingItemType,
|
||||
material,
|
||||
setMaterial,
|
||||
widthSum,
|
||||
setWidthSum,
|
||||
bendingLength,
|
||||
setBendingLength,
|
||||
partUnit,
|
||||
setPartUnit,
|
||||
bendingDetailsLength,
|
||||
electricOpenerPower,
|
||||
setElectricOpenerPower,
|
||||
electricOpenerCapacity,
|
||||
setElectricOpenerCapacity,
|
||||
motorVoltage,
|
||||
setMotorVoltage,
|
||||
chainSpec,
|
||||
setChainSpec,
|
||||
partStatus,
|
||||
setPartStatus,
|
||||
needsBOM,
|
||||
setNeedsBOM,
|
||||
generateItemCode,
|
||||
register,
|
||||
setValue,
|
||||
clearErrors,
|
||||
errors,
|
||||
}: PartFormProps) {
|
||||
// 부품 유형 변경 시 필드 초기화 핸들러
|
||||
const handlePartTypeChange = (value: string) => {
|
||||
setSelectedPartType(value);
|
||||
setValue('partType', value);
|
||||
clearErrors('partType');
|
||||
|
||||
// 공통 필드 초기화
|
||||
setSelectedCategory1('');
|
||||
setValue('category1', undefined);
|
||||
setPartUnit('EA');
|
||||
setValue('unit', 'EA');
|
||||
|
||||
// ASSEMBLY 부품 전용 필드 초기화
|
||||
setSelectedInstallationType('');
|
||||
setValue('installationType', undefined);
|
||||
setSideSpecWidth('');
|
||||
setValue('sideSpecWidth', '');
|
||||
setSideSpecHeight('');
|
||||
setValue('sideSpecHeight', '');
|
||||
setAssemblyLength('');
|
||||
setValue('assemblyLength', '');
|
||||
setAssemblyUnit('EA');
|
||||
|
||||
// BENDING 부품 전용 필드 초기화
|
||||
setSelectedBendingItemType('');
|
||||
setValue('category2', undefined);
|
||||
setMaterial('');
|
||||
setValue('material', '');
|
||||
setWidthSum('');
|
||||
setValue('length', '');
|
||||
setBendingLength('');
|
||||
setValue('bendingLength', '');
|
||||
|
||||
// PURCHASED 부품 전용 필드 초기화
|
||||
setElectricOpenerPower('');
|
||||
setValue('electricOpenerPower', '');
|
||||
setElectricOpenerCapacity('');
|
||||
setValue('electricOpenerCapacity', '');
|
||||
setMotorVoltage('');
|
||||
setValue('motorVoltage', '');
|
||||
setChainSpec('');
|
||||
setValue('chainSpec', '');
|
||||
|
||||
// BOM 설정 (절곡 부품은 BOM 없음, 조립 부품은 BOM 기본 true)
|
||||
setNeedsBOM(value === 'BENDING' ? false : value === 'ASSEMBLY' ? true : needsBOM);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 부품 유형 선택 - 항상 표시 */}
|
||||
<div>
|
||||
<Label>
|
||||
부품 유형 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedPartType}
|
||||
onValueChange={handlePartTypeChange}
|
||||
>
|
||||
<SelectTrigger className={errors.partType ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="부품 유형을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ASSEMBLY">조립 부품 (Assembly Part)</SelectItem>
|
||||
<SelectItem value="BENDING">절곡 부품 (Bending Part) - 전개도만 사용</SelectItem>
|
||||
<SelectItem value="PURCHASED">구매 부품 (Purchased Part)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.partType && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.partType.message}
|
||||
</p>
|
||||
)}
|
||||
{!errors.partType && selectedPartType === 'BENDING' && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 절곡 부품은 전개도(바라시)만 있으며, 부품 구성(BOM)은 사용하지 않습니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ASSEMBLY 부품인 경우 */}
|
||||
{selectedPartType === 'ASSEMBLY' && (
|
||||
<AssemblyPartForm
|
||||
selectedCategory1={selectedCategory1}
|
||||
setSelectedCategory1={setSelectedCategory1}
|
||||
selectedInstallationType={selectedInstallationType}
|
||||
setSelectedInstallationType={setSelectedInstallationType}
|
||||
sideSpecWidth={sideSpecWidth}
|
||||
setSideSpecWidth={setSideSpecWidth}
|
||||
sideSpecHeight={sideSpecHeight}
|
||||
setSideSpecHeight={setSideSpecHeight}
|
||||
assemblyLength={assemblyLength}
|
||||
setAssemblyLength={setAssemblyLength}
|
||||
assemblyUnit={assemblyUnit}
|
||||
setAssemblyUnit={setAssemblyUnit}
|
||||
partStatus={partStatus}
|
||||
setPartStatus={setPartStatus}
|
||||
needsBOM={needsBOM}
|
||||
setNeedsBOM={setNeedsBOM}
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
errors={errors}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* BENDING 부품인 경우 */}
|
||||
{selectedPartType === 'BENDING' && (
|
||||
<BendingPartForm
|
||||
selectedCategory1={selectedCategory1}
|
||||
setSelectedCategory1={setSelectedCategory1}
|
||||
selectedBendingItemType={selectedBendingItemType}
|
||||
setSelectedBendingItemType={setSelectedBendingItemType}
|
||||
material={material}
|
||||
setMaterial={setMaterial}
|
||||
widthSum={widthSum}
|
||||
setWidthSum={setWidthSum}
|
||||
bendingLength={bendingLength}
|
||||
setBendingLength={setBendingLength}
|
||||
partUnit={partUnit}
|
||||
setPartUnit={setPartUnit}
|
||||
bendingDetailsLength={bendingDetailsLength}
|
||||
partStatus={partStatus}
|
||||
setPartStatus={setPartStatus}
|
||||
generateItemCode={generateItemCode}
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
clearErrors={clearErrors}
|
||||
errors={errors}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* PURCHASED 부품인 경우 */}
|
||||
{selectedPartType === 'PURCHASED' && (
|
||||
<PurchasedPartForm
|
||||
selectedCategory1={selectedCategory1}
|
||||
setSelectedCategory1={setSelectedCategory1}
|
||||
electricOpenerPower={electricOpenerPower}
|
||||
setElectricOpenerPower={setElectricOpenerPower}
|
||||
electricOpenerCapacity={electricOpenerCapacity}
|
||||
setElectricOpenerCapacity={setElectricOpenerCapacity}
|
||||
motorVoltage={motorVoltage}
|
||||
setMotorVoltage={setMotorVoltage}
|
||||
chainSpec={chainSpec}
|
||||
setChainSpec={setChainSpec}
|
||||
partUnit={partUnit}
|
||||
setPartUnit={setPartUnit}
|
||||
partStatus={partStatus}
|
||||
setPartStatus={setPartStatus}
|
||||
needsBOM={needsBOM}
|
||||
setNeedsBOM={setNeedsBOM}
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
errors={errors}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
337
src/components/items/ItemForm/forms/ProductForm.tsx
Normal file
337
src/components/items/ItemForm/forms/ProductForm.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* 제품 (FG) 폼 컴포넌트
|
||||
*/
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { X } from 'lucide-react';
|
||||
import type { UseFormRegister, UseFormSetValue, UseFormGetValues, FieldErrors } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
|
||||
interface ProductFormProps {
|
||||
productName: string;
|
||||
setProductName: (value: string) => void;
|
||||
productStatus: string;
|
||||
setProductStatus: (value: string) => void;
|
||||
remarks: string;
|
||||
setRemarks: (value: string) => void;
|
||||
needsBOM: boolean;
|
||||
setNeedsBOM: (value: boolean) => void;
|
||||
specificationFile: File | null;
|
||||
setSpecificationFile: (file: File | null) => void;
|
||||
certificationFile: File | null;
|
||||
setCertificationFile: (file: File | null) => void;
|
||||
isSubmitting: boolean;
|
||||
register: UseFormRegister<CreateItemFormData>;
|
||||
setValue: UseFormSetValue<CreateItemFormData>;
|
||||
getValues: UseFormGetValues<CreateItemFormData>;
|
||||
errors: FieldErrors<CreateItemFormData>;
|
||||
}
|
||||
|
||||
export default function ProductForm({
|
||||
productName,
|
||||
setProductName,
|
||||
productStatus,
|
||||
setProductStatus,
|
||||
remarks,
|
||||
setRemarks,
|
||||
needsBOM,
|
||||
setNeedsBOM,
|
||||
specificationFile,
|
||||
setSpecificationFile,
|
||||
certificationFile,
|
||||
setCertificationFile,
|
||||
isSubmitting,
|
||||
register,
|
||||
setValue,
|
||||
getValues,
|
||||
errors,
|
||||
}: ProductFormProps) {
|
||||
return (
|
||||
<>
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<Label htmlFor="productName">
|
||||
상품명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="productName"
|
||||
placeholder="상품명을 입력하세요 (예: 프리미엄 스크린)"
|
||||
value={productName}
|
||||
onChange={(e) => {
|
||||
const newName = e.target.value;
|
||||
setProductName(newName);
|
||||
setValue('productName', newName);
|
||||
}}
|
||||
className={errors.productName ? 'border-red-500' : ''}
|
||||
/>
|
||||
{errors.productName && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.productName.message}
|
||||
</p>
|
||||
)}
|
||||
{!errors.productName && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
상품명을 입력해주세요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="itemName">
|
||||
품목명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="itemName"
|
||||
placeholder="품목명을 입력하세요"
|
||||
{...register('itemName')}
|
||||
className={errors.itemName ? 'border-red-500' : ''}
|
||||
/>
|
||||
{errors.itemName && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.itemName.message}
|
||||
</p>
|
||||
)}
|
||||
{!errors.itemName && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
품목명을 입력해주세요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<Label>품목코드 (자동생성)</Label>
|
||||
<Input
|
||||
value={(() => {
|
||||
const pName = productName || '';
|
||||
const iName = getValues('itemName') || '';
|
||||
return pName && iName ? `${pName}-${iName}` : '';
|
||||
})()}
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
placeholder="상품명과 품목명을 입력하면 자동으로 생성됩니다"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 품목코드는 '상품명-품목명' 형식으로 자동 생성됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>로트 약자</Label>
|
||||
<Input
|
||||
placeholder="로트 약자를 입력하세요"
|
||||
{...register('lotAbbreviation')}
|
||||
maxLength={10}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 로트 번호 생성 시 사용되는 약자 (선택사항)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>품목 상태</Label>
|
||||
<Select
|
||||
value={productStatus}
|
||||
onValueChange={(value) => {
|
||||
setProductStatus(value);
|
||||
setValue('isActive', value === 'true');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="품목 상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">활성</SelectItem>
|
||||
<SelectItem value="false">비활성</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 비활성 시 품목 사용이 제한됩니다
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FG 인정 정보 섹션 컴포넌트
|
||||
*/
|
||||
export function ProductCertificationSection({
|
||||
remarks,
|
||||
setRemarks,
|
||||
needsBOM,
|
||||
setNeedsBOM,
|
||||
specificationFile,
|
||||
setSpecificationFile,
|
||||
certificationFile,
|
||||
setCertificationFile,
|
||||
isSubmitting,
|
||||
register,
|
||||
}: Pick<ProductFormProps,
|
||||
| 'remarks'
|
||||
| 'setRemarks'
|
||||
| 'needsBOM'
|
||||
| 'setNeedsBOM'
|
||||
| 'specificationFile'
|
||||
| 'setSpecificationFile'
|
||||
| 'certificationFile'
|
||||
| 'setCertificationFile'
|
||||
| 'isSubmitting'
|
||||
| 'register'
|
||||
>) {
|
||||
return (
|
||||
<div className="pt-6 mt-6 border-t space-y-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold mb-4">인정 정보</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
제품 인정서 및 시방서를 관리합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 인정번호, 유효기간, 파일 업로드, 비고 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="certificationNumber">인정번호</Label>
|
||||
<Input
|
||||
id="certificationNumber"
|
||||
placeholder="인정번호를 입력하세요"
|
||||
{...register('certificationNumber')}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="certificationStartDate">인정 유효기간 시작일</Label>
|
||||
<Input
|
||||
id="certificationStartDate"
|
||||
type="date"
|
||||
{...register('certificationStartDate')}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="certificationEndDate">인정 유효기간 종료일</Label>
|
||||
<Input
|
||||
id="certificationEndDate"
|
||||
type="date"
|
||||
{...register('certificationEndDate')}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 시방서 파일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>시방서 (PDF, DOCX, HWP, JPG, PNG / 최대 20MB)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="file"
|
||||
accept=".pdf,.docx,.hwp,.jpg,.jpeg,.png"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setSpecificationFile(file);
|
||||
}
|
||||
}}
|
||||
className="flex-1"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{specificationFile && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSpecificationFile(null)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{specificationFile && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
첨부됨: {specificationFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 인정서 파일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>인정서 (PDF, DOCX, HWP, JPG, PNG / 최대 20MB)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="file"
|
||||
accept=".pdf,.docx,.hwp,.jpg,.jpeg,.png"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setCertificationFile(file);
|
||||
}
|
||||
}}
|
||||
className="flex-1"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{certificationFile && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCertificationFile(null)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{certificationFile && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
첨부됨: {certificationFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
<div className="md:col-span-2">
|
||||
<Label>비고</Label>
|
||||
<Textarea
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.target.value)}
|
||||
placeholder="비고 사항을 입력하세요"
|
||||
rows={3}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 추가 설명이나 특이사항을 기록할 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 부품구성 (BOM) 필요 여부 - FG 전용, 인정 정보 카드 내부 */}
|
||||
<div className="md:col-span-2 pt-6 mt-6 border-t">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="needsBOM-fg"
|
||||
checked={needsBOM}
|
||||
onCheckedChange={(checked) => setNeedsBOM(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="needsBOM-fg" className="cursor-pointer">
|
||||
부품구성 (BOM) 필요
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-6">
|
||||
* 이 제품이 하위 구성품을 포함하는 경우 체크하세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
src/components/items/ItemForm/forms/index.ts
Normal file
7
src/components/items/ItemForm/forms/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 품목 유형별 폼 컴포넌트 export
|
||||
*/
|
||||
|
||||
export { default as MaterialForm } from './MaterialForm';
|
||||
export { default as ProductForm, ProductCertificationSection } from './ProductForm';
|
||||
export { default as PartForm } from './PartForm';
|
||||
336
src/components/items/ItemForm/forms/parts/AssemblyPartForm.tsx
Normal file
336
src/components/items/ItemForm/forms/parts/AssemblyPartForm.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* 조립 부품 (ASSEMBLY) 폼 컴포넌트
|
||||
* - 가이드레일, 케이스, 하단마감재
|
||||
*/
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { UseFormRegister, UseFormSetValue, FieldErrors } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
|
||||
export interface AssemblyPartFormProps {
|
||||
selectedCategory1: string;
|
||||
setSelectedCategory1: (value: string) => void;
|
||||
selectedInstallationType: string;
|
||||
setSelectedInstallationType: (value: string) => void;
|
||||
sideSpecWidth: string;
|
||||
setSideSpecWidth: (value: string) => void;
|
||||
sideSpecHeight: string;
|
||||
setSideSpecHeight: (value: string) => void;
|
||||
assemblyLength: string;
|
||||
setAssemblyLength: (value: string) => void;
|
||||
assemblyUnit: string;
|
||||
setAssemblyUnit: (value: string) => void;
|
||||
partStatus: string;
|
||||
setPartStatus: (value: string) => void;
|
||||
needsBOM: boolean;
|
||||
setNeedsBOM: (value: boolean) => void;
|
||||
register: UseFormRegister<CreateItemFormData>;
|
||||
setValue: UseFormSetValue<CreateItemFormData>;
|
||||
errors: FieldErrors<CreateItemFormData>;
|
||||
}
|
||||
|
||||
export default function AssemblyPartForm({
|
||||
selectedCategory1,
|
||||
setSelectedCategory1,
|
||||
selectedInstallationType,
|
||||
setSelectedInstallationType,
|
||||
sideSpecWidth,
|
||||
setSideSpecWidth,
|
||||
sideSpecHeight,
|
||||
setSideSpecHeight,
|
||||
assemblyLength,
|
||||
setAssemblyLength,
|
||||
assemblyUnit,
|
||||
setAssemblyUnit,
|
||||
partStatus,
|
||||
setPartStatus,
|
||||
needsBOM,
|
||||
setNeedsBOM,
|
||||
register,
|
||||
setValue,
|
||||
errors,
|
||||
}: AssemblyPartFormProps) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Label>
|
||||
품목명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedCategory1}
|
||||
onValueChange={(value) => {
|
||||
setSelectedCategory1(value);
|
||||
setValue('category1', value);
|
||||
if (value === 'guide_rail') setValue('itemName', '가이드레일');
|
||||
else if (value === 'case') setValue('itemName', '케이스');
|
||||
else if (value === 'bottom_finish') setValue('itemName', '하단마감재');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={errors.category1 ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="품목명을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="guide_rail">가이드레일</SelectItem>
|
||||
<SelectItem value="case">케이스</SelectItem>
|
||||
<SelectItem value="bottom_finish">하단마감재</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.category1 && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.category1.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 가이드레일: 설치 유형 */}
|
||||
{selectedCategory1 === 'guide_rail' && (
|
||||
<div>
|
||||
<Label>
|
||||
설치 유형 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedInstallationType}
|
||||
onValueChange={(value) => {
|
||||
setSelectedInstallationType(value);
|
||||
setValue('installationType', value);
|
||||
setValue('category2', value === 'wall' ? 'R' : 'S');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="설치 유형을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="wall">벽면형 (R)</SelectItem>
|
||||
<SelectItem value="side">측면형 (S)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 케이스: 설치 유형 */}
|
||||
{selectedCategory1 === 'case' && (
|
||||
<div>
|
||||
<Label>
|
||||
설치 유형 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedInstallationType}
|
||||
onValueChange={(value) => {
|
||||
setSelectedInstallationType(value);
|
||||
setValue('installationType', value);
|
||||
setValue('category2', 'C');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="설치 유형을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard">표준형 (C)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 하단마감재: 설치 유형 */}
|
||||
{selectedCategory1 === 'bottom_finish' && (
|
||||
<div>
|
||||
<Label>
|
||||
설치 유형 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedInstallationType}
|
||||
onValueChange={(value) => {
|
||||
setSelectedInstallationType(value);
|
||||
setValue('installationType', value);
|
||||
setValue('category2', value === 'steel' ? 'B' : 'T');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="설치 유형을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="steel">스크린 (B)</SelectItem>
|
||||
<SelectItem value="iron">철재 (T)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ASSEMBLY 공통: 단위, 비고, 측면규격 및 길이 */}
|
||||
<div>
|
||||
<Label>
|
||||
단위 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={assemblyUnit}
|
||||
onValueChange={(value) => {
|
||||
setAssemblyUnit(value);
|
||||
setValue('unit', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="단위를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="EA">EA (개)</SelectItem>
|
||||
<SelectItem value="M">M (미터)</SelectItem>
|
||||
<SelectItem value="mm">mm (밀리미터)</SelectItem>
|
||||
<SelectItem value="SET">SET (세트)</SelectItem>
|
||||
<SelectItem value="KG">KG (킬로그램)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<Label>비고</Label>
|
||||
<Input
|
||||
placeholder="비고 사항을 입력하세요"
|
||||
{...register('note')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 측면 규격 및 길이 */}
|
||||
<div className="col-span-2 border-t pt-4">
|
||||
<h4 className="text-sm font-semibold mb-3">측면 규격 및 길이</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Label>
|
||||
측면 규격 (가로) <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="예: 50"
|
||||
value={sideSpecWidth}
|
||||
onChange={(e) => {
|
||||
setSideSpecWidth(e.target.value);
|
||||
setValue('sideSpecWidth', e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>
|
||||
측면 규격 (세로) <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="예: 100"
|
||||
value={sideSpecHeight}
|
||||
onChange={(e) => {
|
||||
setSideSpecHeight(e.target.value);
|
||||
setValue('sideSpecHeight', e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>
|
||||
길이 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={assemblyLength}
|
||||
onValueChange={(value) => {
|
||||
setAssemblyLength(value);
|
||||
setValue('assemblyLength', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="길이를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1219">1219mm</SelectItem>
|
||||
<SelectItem value="2438">2438mm</SelectItem>
|
||||
<SelectItem value="3000">3000mm</SelectItem>
|
||||
<SelectItem value="3500">3500mm</SelectItem>
|
||||
<SelectItem value="4000">4000mm</SelectItem>
|
||||
<SelectItem value="4150">4150mm</SelectItem>
|
||||
<SelectItem value="4200">4200mm</SelectItem>
|
||||
<SelectItem value="4300">4300mm</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
* 품목코드: {(() => {
|
||||
const itemName = selectedCategory1 === 'guide_rail' ? '가이드레일' :
|
||||
selectedCategory1 === 'case' ? '케이스' :
|
||||
selectedCategory1 === 'bottom_finish' ? '하단마감재' : '';
|
||||
const installationTypeMap: Record<string, string> = {
|
||||
"standard": "표준형",
|
||||
"wall": "벽면형",
|
||||
"side": "측면형",
|
||||
"steel": "스크린",
|
||||
"iron": "철재"
|
||||
};
|
||||
const installTypeText = installationTypeMap[selectedInstallationType] || selectedInstallationType;
|
||||
const length = assemblyLength ? parseInt(assemblyLength) : 0;
|
||||
let lengthCode = "";
|
||||
if (length === 1219) lengthCode = "12";
|
||||
else if (length === 2438) lengthCode = "24";
|
||||
else if (length === 3000) lengthCode = "30";
|
||||
else if (length === 3500) lengthCode = "35";
|
||||
else if (length === 4000) lengthCode = "40";
|
||||
else if (length === 4150) lengthCode = "41";
|
||||
else if (length === 4200) lengthCode = "42";
|
||||
else if (length === 4300) lengthCode = "43";
|
||||
else lengthCode = Math.floor(length / 100).toString().padStart(2, '0');
|
||||
|
||||
if (itemName && installTypeText && sideSpecWidth && sideSpecHeight && assemblyLength) {
|
||||
return `${itemName} ${installTypeText}-${sideSpecWidth}*${sideSpecHeight}*${lengthCode}`;
|
||||
}
|
||||
return "품목명 설치유형-?*?*?";
|
||||
})()}
|
||||
</p>
|
||||
|
||||
{/* 품목 상태 */}
|
||||
<div className="mt-4">
|
||||
<Label>품목 상태</Label>
|
||||
<Select
|
||||
value={partStatus}
|
||||
onValueChange={(value) => {
|
||||
setPartStatus(value);
|
||||
setValue('isActive', value === 'true');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="품목 상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">활성</SelectItem>
|
||||
<SelectItem value="false">비활성</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 비활성 시 품목 사용이 제한됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 부품구성 (BOM) 필요 여부 - ASSEMBLY 전용 */}
|
||||
{selectedCategory1 && (
|
||||
<div className="pt-6 mt-6 border-t">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="needsBOM-assembly"
|
||||
checked={needsBOM}
|
||||
onCheckedChange={(checked) => setNeedsBOM(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="needsBOM-assembly" className="cursor-pointer">
|
||||
부품구성 (BOM) 필요
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-6">
|
||||
* 이 부품이 하위 구성품을 포함하는 경우 체크하세요
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
302
src/components/items/ItemForm/forms/parts/BendingPartForm.tsx
Normal file
302
src/components/items/ItemForm/forms/parts/BendingPartForm.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* 절곡 부품 (BENDING) 폼 컴포넌트
|
||||
* - 가이드레일(벽면/측면), 케이스, 하단마감재 등
|
||||
*/
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { UseFormRegister, UseFormSetValue, UseFormClearErrors, FieldErrors } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
import { PART_TYPE_CATEGORIES, PART_ITEM_NAMES } from '../../constants';
|
||||
|
||||
export interface BendingPartFormProps {
|
||||
selectedCategory1: string;
|
||||
setSelectedCategory1: (value: string) => void;
|
||||
selectedBendingItemType: string;
|
||||
setSelectedBendingItemType: (value: string) => void;
|
||||
material: string;
|
||||
setMaterial: (value: string) => void;
|
||||
widthSum: string;
|
||||
setWidthSum: (value: string) => void;
|
||||
bendingLength: string;
|
||||
setBendingLength: (value: string) => void;
|
||||
partUnit: string;
|
||||
setPartUnit: (value: string) => void;
|
||||
bendingDetailsLength: number;
|
||||
partStatus: string;
|
||||
setPartStatus: (value: string) => void;
|
||||
generateItemCode: () => string;
|
||||
register: UseFormRegister<CreateItemFormData>;
|
||||
setValue: UseFormSetValue<CreateItemFormData>;
|
||||
clearErrors: UseFormClearErrors<CreateItemFormData>;
|
||||
errors: FieldErrors<CreateItemFormData>;
|
||||
}
|
||||
|
||||
export default function BendingPartForm({
|
||||
selectedCategory1,
|
||||
setSelectedCategory1,
|
||||
selectedBendingItemType,
|
||||
setSelectedBendingItemType,
|
||||
material,
|
||||
setMaterial,
|
||||
widthSum,
|
||||
setWidthSum,
|
||||
bendingLength,
|
||||
setBendingLength,
|
||||
partUnit,
|
||||
setPartUnit,
|
||||
bendingDetailsLength,
|
||||
partStatus,
|
||||
setPartStatus,
|
||||
generateItemCode,
|
||||
register,
|
||||
setValue,
|
||||
clearErrors,
|
||||
errors,
|
||||
}: BendingPartFormProps) {
|
||||
return (
|
||||
<>
|
||||
{/* 품목명 선택 */}
|
||||
<div>
|
||||
<Label>
|
||||
품목명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedCategory1}
|
||||
onValueChange={(val) => {
|
||||
setSelectedCategory1(val);
|
||||
setValue('category1', val);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="품목명을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PART_TYPE_CATEGORIES.BENDING?.categories.map((cat) => (
|
||||
<SelectItem key={cat.value} value={cat.value}>
|
||||
{cat.label} ({cat.code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 종류 선택 */}
|
||||
{selectedCategory1 && PART_ITEM_NAMES[selectedCategory1] && (
|
||||
<div>
|
||||
<Label>
|
||||
종류 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedBendingItemType}
|
||||
onValueChange={(value) => {
|
||||
setSelectedBendingItemType(value);
|
||||
const selected = PART_ITEM_NAMES[selectedCategory1].find(item => item.label === value);
|
||||
if (selected) {
|
||||
setValue('itemName', selected.label);
|
||||
setValue('category2', selected.code);
|
||||
clearErrors('category2');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="종류를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PART_ITEM_NAMES[selectedCategory1].map((item) => (
|
||||
<SelectItem key={item.value} value={item.label}>
|
||||
{item.label} ({item.code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 재질, 폭 합계, 모양&길이 (Purple Section) */}
|
||||
{selectedBendingItemType && (
|
||||
<div className="md:col-span-2 grid grid-cols-1 md:grid-cols-3 gap-4 p-4 bg-purple-50 rounded-lg border border-purple-200">
|
||||
<div>
|
||||
<Label>
|
||||
재질 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={material}
|
||||
onValueChange={(value) => {
|
||||
setMaterial(value);
|
||||
setValue('material', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={errors.material ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="재질을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="EGI 1.15T">EGI 1.15T</SelectItem>
|
||||
<SelectItem value="EGI 1.55T">EGI 1.55T</SelectItem>
|
||||
<SelectItem value="SUS 1.2T">SUS 1.2T</SelectItem>
|
||||
<SelectItem value="SUS 1.5T">SUS 1.5T</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.material && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.material.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
폭 합계 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={widthSum}
|
||||
onChange={(e) => {
|
||||
setWidthSum(e.target.value);
|
||||
setValue('length', e.target.value);
|
||||
}}
|
||||
placeholder="전개도 상세를 입력해주세요"
|
||||
readOnly={bendingDetailsLength > 0}
|
||||
className={`${bendingDetailsLength > 0 ? "bg-blue-50 font-medium" : ""} ${errors.length ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">mm</span>
|
||||
</div>
|
||||
{errors.length && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.length.message}
|
||||
</p>
|
||||
)}
|
||||
{!errors.length && bendingDetailsLength > 0 && (
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
* 전개도 상세 입력의 합계가 자동 반영됩니다
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
모양&길이 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={bendingLength}
|
||||
onValueChange={(value) => {
|
||||
setBendingLength(value);
|
||||
setValue('bendingLength', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={errors.bendingLength ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="모양&길이를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="W50x3000">W50×3000mm</SelectItem>
|
||||
<SelectItem value="W50x4000">W50×4000mm</SelectItem>
|
||||
<SelectItem value="W80x3000">W80×3000mm</SelectItem>
|
||||
<SelectItem value="W80x4000">W80×4000mm</SelectItem>
|
||||
<SelectItem value="1219">1219mm</SelectItem>
|
||||
<SelectItem value="2438">2438mm</SelectItem>
|
||||
<SelectItem value="3000">3000mm</SelectItem>
|
||||
<SelectItem value="3500">3500mm</SelectItem>
|
||||
<SelectItem value="4000">4000mm</SelectItem>
|
||||
<SelectItem value="4150">4150mm</SelectItem>
|
||||
<SelectItem value="4200">4200mm</SelectItem>
|
||||
<SelectItem value="4300">4300mm</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.bendingLength && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.bendingLength.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 단위, 비고 (종류 선택 후 표시) */}
|
||||
{selectedBendingItemType && (
|
||||
<>
|
||||
<div>
|
||||
<Label>
|
||||
단위 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={partUnit}
|
||||
onValueChange={(value) => {
|
||||
setPartUnit(value);
|
||||
setValue('unit', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="단위를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="EA">EA (개)</SelectItem>
|
||||
<SelectItem value="M">M (미터)</SelectItem>
|
||||
<SelectItem value="mm">mm (밀리미터)</SelectItem>
|
||||
<SelectItem value="KG">KG (킬로그램)</SelectItem>
|
||||
<SelectItem value="L">L (리터)</SelectItem>
|
||||
<SelectItem value="SET">SET (세트)</SelectItem>
|
||||
<SelectItem value="BOX">BOX (박스)</SelectItem>
|
||||
<SelectItem value="ROLL">ROLL (롤)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>비고</Label>
|
||||
<Input
|
||||
placeholder="비고 사항을 입력하세요"
|
||||
{...register('note')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 품목코드 자동생성 */}
|
||||
<div className="md:col-span-2">
|
||||
<Label>품목코드 (자동생성)</Label>
|
||||
<Input
|
||||
value={generateItemCode()}
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
placeholder="품목명과 규격이 입력되면 자동으로 생성됩니다"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{(selectedCategory1 === "guide_rail_wall" || selectedCategory1 === "guide_rail_side")
|
||||
? "* 가이드레일 품목코드는 '제품구분(R/S)+종류(M/T/C/D/S/U)+모양&길이' 형식으로 자동 생성됩니다 (예: RD30, SM53)"
|
||||
: "* 절곡 부품 품목코드는 '품목명+종류+길이축약' 형식으로 자동 생성됩니다 (예: 케이스후면부30)"}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 품목 상태 */}
|
||||
<div className="md:col-span-2">
|
||||
<Label>품목 상태</Label>
|
||||
<Select
|
||||
value={partStatus}
|
||||
onValueChange={(value) => {
|
||||
setPartStatus(value);
|
||||
setValue('isActive', value === 'true');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="품목 상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">활성</SelectItem>
|
||||
<SelectItem value="false">비활성</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 비활성 시 품목 사용이 제한됩니다
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
318
src/components/items/ItemForm/forms/parts/PurchasedPartForm.tsx
Normal file
318
src/components/items/ItemForm/forms/parts/PurchasedPartForm.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* 구매 부품 (PURCHASED) 폼 컴포넌트
|
||||
* - 전동개폐기, 모터, 체인 등
|
||||
*/
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { UseFormRegister, UseFormSetValue, FieldErrors } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
import { PART_TYPE_CATEGORIES } from '../../constants';
|
||||
|
||||
export interface PurchasedPartFormProps {
|
||||
selectedCategory1: string;
|
||||
setSelectedCategory1: (value: string) => void;
|
||||
electricOpenerPower: string;
|
||||
setElectricOpenerPower: (value: string) => void;
|
||||
electricOpenerCapacity: string;
|
||||
setElectricOpenerCapacity: (value: string) => void;
|
||||
motorVoltage: string;
|
||||
setMotorVoltage: (value: string) => void;
|
||||
chainSpec: string;
|
||||
setChainSpec: (value: string) => void;
|
||||
partUnit: string;
|
||||
setPartUnit: (value: string) => void;
|
||||
partStatus: string;
|
||||
setPartStatus: (value: string) => void;
|
||||
needsBOM: boolean;
|
||||
setNeedsBOM: (value: boolean) => void;
|
||||
register: UseFormRegister<CreateItemFormData>;
|
||||
setValue: UseFormSetValue<CreateItemFormData>;
|
||||
errors: FieldErrors<CreateItemFormData>;
|
||||
}
|
||||
|
||||
export default function PurchasedPartForm({
|
||||
selectedCategory1,
|
||||
setSelectedCategory1,
|
||||
electricOpenerPower,
|
||||
setElectricOpenerPower,
|
||||
electricOpenerCapacity,
|
||||
setElectricOpenerCapacity,
|
||||
motorVoltage,
|
||||
setMotorVoltage,
|
||||
chainSpec,
|
||||
setChainSpec,
|
||||
partUnit,
|
||||
setPartUnit,
|
||||
partStatus,
|
||||
setPartStatus,
|
||||
needsBOM,
|
||||
setNeedsBOM,
|
||||
register,
|
||||
setValue,
|
||||
errors,
|
||||
}: PurchasedPartFormProps) {
|
||||
return (
|
||||
<>
|
||||
{/* 품목명 선택 */}
|
||||
<div>
|
||||
<Label>
|
||||
품목명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedCategory1}
|
||||
onValueChange={(val) => {
|
||||
setSelectedCategory1(val);
|
||||
setValue('category1', val);
|
||||
const cat = PART_TYPE_CATEGORIES.PURCHASED?.categories.find(c => c.value === val);
|
||||
if (cat) {
|
||||
setValue('category2', cat.code);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="품목명을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PART_TYPE_CATEGORIES.PURCHASED?.categories.map((cat) => (
|
||||
<SelectItem key={cat.value} value={cat.value}>
|
||||
{cat.label} ({cat.code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 전동개폐기 전용 필드 */}
|
||||
{selectedCategory1 === 'electric_opener' && (
|
||||
<>
|
||||
<div>
|
||||
<Label>
|
||||
전원 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={electricOpenerPower}
|
||||
onValueChange={(value) => {
|
||||
setElectricOpenerPower(value);
|
||||
setValue('electricOpenerPower', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={errors.electricOpenerPower ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="전원을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="220V">220V</SelectItem>
|
||||
<SelectItem value="380V">380V</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.electricOpenerPower && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.electricOpenerPower.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>
|
||||
용량 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={electricOpenerCapacity}
|
||||
onValueChange={(value) => {
|
||||
setElectricOpenerCapacity(value);
|
||||
setValue('electricOpenerCapacity', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={errors.electricOpenerCapacity ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="용량을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="150">150 KG</SelectItem>
|
||||
<SelectItem value="300">300 KG</SelectItem>
|
||||
<SelectItem value="400">400 KG</SelectItem>
|
||||
<SelectItem value="500">500 KG</SelectItem>
|
||||
<SelectItem value="600">600 KG</SelectItem>
|
||||
<SelectItem value="800">800 KG</SelectItem>
|
||||
<SelectItem value="1000">1000 KG</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.electricOpenerCapacity && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.electricOpenerCapacity.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 모터 전용 필드 */}
|
||||
{selectedCategory1 === 'motor' && (
|
||||
<div className="md:col-span-2 grid grid-cols-2 gap-4 p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<div>
|
||||
<Label>모터 용량 (kg) *</Label>
|
||||
<Input type="number" placeholder="예: 1.5" step="0.1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>전압 (V) *</Label>
|
||||
<Select
|
||||
value={motorVoltage}
|
||||
onValueChange={(value) => {
|
||||
setMotorVoltage(value);
|
||||
setValue('motorVoltage', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={errors.motorVoltage ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="전압을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="220">220V</SelectItem>
|
||||
<SelectItem value="380">380V</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.motorVoltage && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.motorVoltage.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 체인 전용 필드 */}
|
||||
{selectedCategory1 === 'chain' && (
|
||||
<div className="md:col-span-2 grid grid-cols-2 gap-4 p-4 bg-yellow-50 rounded-lg border border-yellow-200">
|
||||
<div>
|
||||
<Label>체인 규격 *</Label>
|
||||
<Select
|
||||
value={chainSpec}
|
||||
onValueChange={(value) => {
|
||||
setChainSpec(value);
|
||||
setValue('chainSpec', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={errors.chainSpec ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="규격을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="40">40</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="60">60</SelectItem>
|
||||
<SelectItem value="80">80</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.chainSpec && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.chainSpec.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>길이 (링크 수) *</Label>
|
||||
<Input type="number" placeholder="예: 100" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 품목명 선택 후에만 단위, 비고 표시 */}
|
||||
{selectedCategory1 && (
|
||||
<>
|
||||
<div>
|
||||
<Label>
|
||||
단위 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={partUnit}
|
||||
onValueChange={(value) => {
|
||||
setPartUnit(value);
|
||||
setValue('unit', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="단위를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="EA">EA (개)</SelectItem>
|
||||
<SelectItem value="M">M (미터)</SelectItem>
|
||||
<SelectItem value="mm">mm (밀리미터)</SelectItem>
|
||||
<SelectItem value="KG">KG (킬로그램)</SelectItem>
|
||||
<SelectItem value="L">L (리터)</SelectItem>
|
||||
<SelectItem value="SET">SET (세트)</SelectItem>
|
||||
<SelectItem value="BOX">BOX (박스)</SelectItem>
|
||||
<SelectItem value="ROLL">ROLL (롤)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>비고</Label>
|
||||
<Input
|
||||
placeholder="비고 사항을 입력하세요"
|
||||
{...register('note')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 품목코드 자동생성 */}
|
||||
<div className="md:col-span-2">
|
||||
<Label>품목코드 (자동생성)</Label>
|
||||
<Input
|
||||
value=""
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
placeholder="품목명과 규격이 입력되면 자동으로 생성됩니다"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 품목코드는 '품목명-규격' 형식으로 자동 생성됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 품목 상태 */}
|
||||
<div className="md:col-span-2">
|
||||
<Label>품목 상태</Label>
|
||||
<Select
|
||||
value={partStatus}
|
||||
onValueChange={(value) => {
|
||||
setPartStatus(value);
|
||||
setValue('isActive', value === 'true');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="품목 상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">활성</SelectItem>
|
||||
<SelectItem value="false">비활성</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 비활성 시 품목 사용이 제한됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 부품구성 (BOM) 필요 여부 */}
|
||||
<div className="md:col-span-2 pt-6 mt-6 border-t">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="needsBOM-purchased"
|
||||
checked={needsBOM}
|
||||
onCheckedChange={(checked) => setNeedsBOM(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="needsBOM-purchased" className="cursor-pointer">
|
||||
부품구성 (BOM) 필요
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-6">
|
||||
* 이 부품이 하위 구성품을 포함하는 경우 체크하세요
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
src/components/items/ItemForm/forms/parts/index.ts
Normal file
12
src/components/items/ItemForm/forms/parts/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 부품 유형별 폼 컴포넌트 export
|
||||
*/
|
||||
|
||||
export { default as AssemblyPartForm } from './AssemblyPartForm';
|
||||
export type { AssemblyPartFormProps } from './AssemblyPartForm';
|
||||
|
||||
export { default as BendingPartForm } from './BendingPartForm';
|
||||
export type { BendingPartFormProps } from './BendingPartForm';
|
||||
|
||||
export { default as PurchasedPartForm } from './PurchasedPartForm';
|
||||
export type { PurchasedPartFormProps } from './PurchasedPartForm';
|
||||
12
src/components/items/ItemForm/hooks/index.ts
Normal file
12
src/components/items/ItemForm/hooks/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 품목 폼 커스텀 훅 export
|
||||
*/
|
||||
|
||||
export { useItemFormState } from './useItemFormState';
|
||||
export type { UseItemFormStateProps, ItemFormState, UseItemFormStateReturn } from './useItemFormState';
|
||||
|
||||
export { useBOMManagement } from './useBOMManagement';
|
||||
export type { BOMSearchState, UseBOMManagementProps, UseBOMManagementReturn } from './useBOMManagement';
|
||||
|
||||
export { useBendingDetails } from './useBendingDetails';
|
||||
export type { UseBendingDetailsProps, UseBendingDetailsReturn } from './useBendingDetails';
|
||||
221
src/components/items/ItemForm/hooks/useBOMManagement.ts
Normal file
221
src/components/items/ItemForm/hooks/useBOMManagement.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* BOM 관리 커스텀 훅
|
||||
*
|
||||
* BOM 라인 추가, 수정, 삭제 및 검색 상태 관리
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { BOMLine } from '@/types/item';
|
||||
|
||||
export interface BOMSearchState {
|
||||
searchValue: string;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export interface UseBOMManagementProps {
|
||||
initialBomLines?: BOMLine[];
|
||||
}
|
||||
|
||||
export function useBOMManagement({ initialBomLines = [] }: UseBOMManagementProps = {}) {
|
||||
const [bomLines, setBomLines] = useState<BOMLine[]>(initialBomLines);
|
||||
const [bomSearchStates, setBomSearchStates] = useState<Record<string, BOMSearchState>>({});
|
||||
|
||||
/**
|
||||
* 새 BOM 라인 추가
|
||||
*/
|
||||
const addBomLine = useCallback(() => {
|
||||
const newLine: BOMLine = {
|
||||
id: `bom-${Date.now()}`,
|
||||
childItemCode: '',
|
||||
childItemName: '',
|
||||
quantity: 1,
|
||||
unit: 'EA',
|
||||
};
|
||||
setBomLines((prev) => [...prev, newLine]);
|
||||
return newLine;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* BOM 라인 삭제
|
||||
*/
|
||||
const removeBomLine = useCallback((lineId: string) => {
|
||||
setBomLines((prev) => prev.filter((line) => line.id !== lineId));
|
||||
// 검색 상태도 제거
|
||||
setBomSearchStates((prev) => {
|
||||
const newStates = { ...prev };
|
||||
delete newStates[lineId];
|
||||
return newStates;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* BOM 라인 업데이트
|
||||
*/
|
||||
const updateBomLine = useCallback((lineId: string, updates: Partial<BOMLine>) => {
|
||||
setBomLines((prev) =>
|
||||
prev.map((line) =>
|
||||
line.id === lineId ? { ...line, ...updates } : line
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 품목 선택 시 BOM 라인 데이터 채우기
|
||||
*/
|
||||
const selectItemForBomLine = useCallback((
|
||||
lineId: string,
|
||||
item: {
|
||||
itemCode?: string;
|
||||
itemName?: string;
|
||||
specification?: string;
|
||||
material?: string;
|
||||
unit?: string;
|
||||
partType?: string;
|
||||
bendingDiagram?: string;
|
||||
}
|
||||
) => {
|
||||
const isBendingPart = item.partType === 'BENDING';
|
||||
|
||||
setBomLines((prev) =>
|
||||
prev.map((line) =>
|
||||
line.id === lineId
|
||||
? {
|
||||
...line,
|
||||
childItemCode: item.itemCode || '',
|
||||
childItemName: item.itemName || '',
|
||||
specification: item.specification || '',
|
||||
material: item.material || '',
|
||||
unit: item.unit || 'EA',
|
||||
unitPrice: 0, // TODO: pricing에서 가져오기
|
||||
isBending: isBendingPart,
|
||||
bendingDiagram: isBendingPart ? item.bendingDiagram : undefined,
|
||||
}
|
||||
: line
|
||||
)
|
||||
);
|
||||
|
||||
// 검색 팝오버 닫기
|
||||
setBomSearchStates((prev) => ({
|
||||
...prev,
|
||||
[lineId]: { searchValue: '', isOpen: false },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 검색 상태 업데이트
|
||||
*/
|
||||
const updateSearchState = useCallback((lineId: string, updates: Partial<BOMSearchState>) => {
|
||||
setBomSearchStates((prev) => ({
|
||||
...prev,
|
||||
[lineId]: { ...(prev[lineId] || { searchValue: '', isOpen: false }), ...updates },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 검색 팝오버 열기
|
||||
*/
|
||||
const openSearch = useCallback((lineId: string) => {
|
||||
setBomSearchStates((prev) => ({
|
||||
...prev,
|
||||
[lineId]: { searchValue: '', isOpen: true },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 검색 팝오버 닫기
|
||||
*/
|
||||
const closeSearch = useCallback((lineId: string) => {
|
||||
setBomSearchStates((prev) => ({
|
||||
...prev,
|
||||
[lineId]: { ...(prev[lineId] || { searchValue: '' }), isOpen: false },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 모든 BOM 라인 초기화
|
||||
*/
|
||||
const resetBomLines = useCallback(() => {
|
||||
setBomLines([]);
|
||||
setBomSearchStates({});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* BOM 라인 수량 변경
|
||||
*/
|
||||
const updateQuantity = useCallback((lineId: string, quantity: number) => {
|
||||
updateBomLine(lineId, { quantity });
|
||||
}, [updateBomLine]);
|
||||
|
||||
/**
|
||||
* BOM 라인 단가 변경
|
||||
*/
|
||||
const updateUnitPrice = useCallback((lineId: string, unitPrice: number) => {
|
||||
updateBomLine(lineId, { unitPrice });
|
||||
}, [updateBomLine]);
|
||||
|
||||
/**
|
||||
* BOM 라인 재질 변경
|
||||
*/
|
||||
const updateMaterial = useCallback((lineId: string, material: string) => {
|
||||
updateBomLine(lineId, { material });
|
||||
}, [updateBomLine]);
|
||||
|
||||
/**
|
||||
* BOM 라인 비고 변경
|
||||
*/
|
||||
const updateNote = useCallback((lineId: string, note: string) => {
|
||||
updateBomLine(lineId, { note });
|
||||
}, [updateBomLine]);
|
||||
|
||||
/**
|
||||
* 특정 라인의 검색 상태 가져오기
|
||||
*/
|
||||
const getSearchState = useCallback((lineId: string): BOMSearchState => {
|
||||
return bomSearchStates[lineId] || { searchValue: '', isOpen: false };
|
||||
}, [bomSearchStates]);
|
||||
|
||||
/**
|
||||
* BOM 합계 계산
|
||||
*/
|
||||
const calculateTotal = useCallback(() => {
|
||||
return bomLines.reduce((total, line) => {
|
||||
return total + (line.quantity * (line.unitPrice || 0));
|
||||
}, 0);
|
||||
}, [bomLines]);
|
||||
|
||||
return {
|
||||
// 상태
|
||||
bomLines,
|
||||
setBomLines,
|
||||
bomSearchStates,
|
||||
setBomSearchStates,
|
||||
|
||||
// BOM 라인 조작
|
||||
addBomLine,
|
||||
removeBomLine,
|
||||
updateBomLine,
|
||||
selectItemForBomLine,
|
||||
|
||||
// 필드 업데이트 헬퍼
|
||||
updateQuantity,
|
||||
updateUnitPrice,
|
||||
updateMaterial,
|
||||
updateNote,
|
||||
|
||||
// 검색 상태 관리
|
||||
updateSearchState,
|
||||
openSearch,
|
||||
closeSearch,
|
||||
getSearchState,
|
||||
|
||||
// 유틸리티
|
||||
resetBomLines,
|
||||
calculateTotal,
|
||||
|
||||
// 상태 체크
|
||||
hasBomLines: bomLines.length > 0,
|
||||
bomLinesCount: bomLines.length,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseBOMManagementReturn = ReturnType<typeof useBOMManagement>;
|
||||
182
src/components/items/ItemForm/hooks/useBendingDetails.ts
Normal file
182
src/components/items/ItemForm/hooks/useBendingDetails.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 전개도 상세 관리 커스텀 훅
|
||||
*
|
||||
* BENDING 부품의 전개도 치수 계산 및 관리
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type { BendingDetail } from '@/types/item';
|
||||
import type { UseFormSetValue } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
|
||||
export interface UseBendingDetailsProps {
|
||||
initialDetails?: BendingDetail[];
|
||||
setValue?: UseFormSetValue<CreateItemFormData>;
|
||||
}
|
||||
|
||||
export function useBendingDetails({ initialDetails = [], setValue }: UseBendingDetailsProps = {}) {
|
||||
const [bendingDetails, setBendingDetails] = useState<BendingDetail[]>(initialDetails);
|
||||
const [widthSum, setWidthSum] = useState<string>('');
|
||||
|
||||
/**
|
||||
* 폭 합계 계산
|
||||
*/
|
||||
const calculateWidthSum = useCallback((details: BendingDetail[]): number => {
|
||||
return details.reduce((acc, d) => {
|
||||
const calc = d.input + d.elongation;
|
||||
return acc + calc;
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 폭 합계 업데이트 (상태 + form setValue)
|
||||
*/
|
||||
const updateWidthSum = useCallback((details: BendingDetail[]) => {
|
||||
const totalSum = calculateWidthSum(details);
|
||||
const sumStr = totalSum.toFixed(1);
|
||||
setWidthSum(sumStr);
|
||||
setValue?.('length', sumStr);
|
||||
}, [calculateWidthSum, setValue]);
|
||||
|
||||
/**
|
||||
* bendingDetails 변경 시 자동 폭 합계 업데이트
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (bendingDetails.length > 0) {
|
||||
updateWidthSum(bendingDetails);
|
||||
}
|
||||
}, [bendingDetails, updateWidthSum]);
|
||||
|
||||
/**
|
||||
* 새 행 추가
|
||||
*/
|
||||
const addDetail = useCallback(() => {
|
||||
const newId = `detail-${Date.now()}`;
|
||||
const newRow: BendingDetail = {
|
||||
id: newId,
|
||||
no: bendingDetails.length + 1,
|
||||
input: 0,
|
||||
elongation: -1,
|
||||
calculated: 0,
|
||||
sum: 0,
|
||||
shaded: false,
|
||||
aAngle: undefined,
|
||||
};
|
||||
const newDetails = [...bendingDetails, newRow];
|
||||
setBendingDetails(newDetails);
|
||||
return newRow;
|
||||
}, [bendingDetails]);
|
||||
|
||||
/**
|
||||
* 행 삭제
|
||||
*/
|
||||
const removeDetail = useCallback((index: number) => {
|
||||
const newDetails = bendingDetails
|
||||
.filter((_, i) => i !== index)
|
||||
.map((d, i) => ({ ...d, no: i + 1 })); // 번호 재정렬
|
||||
setBendingDetails(newDetails);
|
||||
updateWidthSum(newDetails);
|
||||
}, [bendingDetails, updateWidthSum]);
|
||||
|
||||
/**
|
||||
* 행 업데이트
|
||||
*/
|
||||
const updateDetail = useCallback((index: number, updates: Partial<BendingDetail>) => {
|
||||
const newDetails = [...bendingDetails];
|
||||
newDetails[index] = { ...newDetails[index], ...updates };
|
||||
setBendingDetails(newDetails);
|
||||
updateWidthSum(newDetails);
|
||||
}, [bendingDetails, updateWidthSum]);
|
||||
|
||||
/**
|
||||
* 입력값 변경
|
||||
*/
|
||||
const updateInput = useCallback((index: number, value: number) => {
|
||||
updateDetail(index, { input: isNaN(value) ? 0 : value });
|
||||
}, [updateDetail]);
|
||||
|
||||
/**
|
||||
* 연신율 변경
|
||||
*/
|
||||
const updateElongation = useCallback((index: number, value: number) => {
|
||||
updateDetail(index, { elongation: isNaN(value) ? -1 : value });
|
||||
}, [updateDetail]);
|
||||
|
||||
/**
|
||||
* 음영 토글
|
||||
*/
|
||||
const toggleShaded = useCallback((index: number) => {
|
||||
const detail = bendingDetails[index];
|
||||
if (detail) {
|
||||
updateDetail(index, { shaded: !detail.shaded });
|
||||
}
|
||||
}, [bendingDetails, updateDetail]);
|
||||
|
||||
/**
|
||||
* A각 변경
|
||||
*/
|
||||
const updateAAngle = useCallback((index: number, value: number | undefined) => {
|
||||
updateDetail(index, { aAngle: value });
|
||||
}, [updateDetail]);
|
||||
|
||||
/**
|
||||
* 특정 행의 계산값 가져오기
|
||||
*/
|
||||
const getCalculatedValue = useCallback((index: number): number => {
|
||||
const detail = bendingDetails[index];
|
||||
if (!detail) return 0;
|
||||
return detail.input + detail.elongation;
|
||||
}, [bendingDetails]);
|
||||
|
||||
/**
|
||||
* 모든 상세 초기화
|
||||
*/
|
||||
const resetDetails = useCallback(() => {
|
||||
setBendingDetails([]);
|
||||
setWidthSum('');
|
||||
setValue?.('length', '');
|
||||
}, [setValue]);
|
||||
|
||||
/**
|
||||
* 상세 데이터 설정 (외부에서 초기화 시)
|
||||
*/
|
||||
const setDetails = useCallback((details: BendingDetail[]) => {
|
||||
setBendingDetails(details);
|
||||
if (details.length > 0) {
|
||||
updateWidthSum(details);
|
||||
}
|
||||
}, [updateWidthSum]);
|
||||
|
||||
return {
|
||||
// 상태
|
||||
bendingDetails,
|
||||
setBendingDetails: setDetails,
|
||||
widthSum,
|
||||
setWidthSum,
|
||||
|
||||
// 행 조작
|
||||
addDetail,
|
||||
removeDetail,
|
||||
updateDetail,
|
||||
|
||||
// 필드 업데이트
|
||||
updateInput,
|
||||
updateElongation,
|
||||
toggleShaded,
|
||||
updateAAngle,
|
||||
|
||||
// 계산
|
||||
calculateWidthSum,
|
||||
getCalculatedValue,
|
||||
updateWidthSum,
|
||||
|
||||
// 유틸리티
|
||||
resetDetails,
|
||||
|
||||
// 상태 체크
|
||||
hasDetails: bendingDetails.length > 0,
|
||||
detailsCount: bendingDetails.length,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseBendingDetailsReturn = ReturnType<typeof useBendingDetails>;
|
||||
364
src/components/items/ItemForm/hooks/useItemFormState.ts
Normal file
364
src/components/items/ItemForm/hooks/useItemFormState.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* 품목 폼 상태 관리 커스텀 훅
|
||||
*
|
||||
* 25개 이상의 useState를 통합 관리
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { ItemMaster, ItemType, BendingDetail, BOMLine } from '@/types/item';
|
||||
import type { UseFormSetValue, UseFormClearErrors } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
|
||||
export interface UseItemFormStateProps {
|
||||
mode: 'create' | 'edit';
|
||||
initialData?: Partial<ItemMaster>;
|
||||
}
|
||||
|
||||
export interface ItemFormState {
|
||||
// 기본 상태
|
||||
isSubmitting: boolean;
|
||||
selectedItemType: ItemType | '';
|
||||
|
||||
// 파일 상태
|
||||
specificationFile: File | null;
|
||||
certificationFile: File | null;
|
||||
bendingDiagramFile: File | null;
|
||||
bendingDiagram: string;
|
||||
bendingDiagramInputMethod: 'file' | 'drawing';
|
||||
isDrawingOpen: boolean;
|
||||
|
||||
// FG(제품) 상태
|
||||
productName: string;
|
||||
productStatus: string;
|
||||
|
||||
// PT(부품) 상태
|
||||
selectedPartType: string;
|
||||
partStatus: string;
|
||||
|
||||
// SM/RM/CS 상태
|
||||
itemName: string;
|
||||
selectedCategory1: string;
|
||||
selectedInstallationType: string;
|
||||
materialStatus: string;
|
||||
selectedSpecification: string;
|
||||
selectedUnit: string;
|
||||
|
||||
// ASSEMBLY 부품 상태
|
||||
sideSpecWidth: string;
|
||||
sideSpecHeight: string;
|
||||
assemblyLength: string;
|
||||
assemblyUnit: string;
|
||||
|
||||
// 전동개폐기 상태
|
||||
electricOpenerPower: string;
|
||||
electricOpenerCapacity: string;
|
||||
|
||||
// 모터/체인 상태
|
||||
motorVoltage: string;
|
||||
chainSpec: string;
|
||||
|
||||
// BENDING 부품 상태
|
||||
selectedBendingItemType: string;
|
||||
material: string;
|
||||
bendingLength: string;
|
||||
widthSum: string;
|
||||
partUnit: string;
|
||||
bendingDetails: BendingDetail[];
|
||||
|
||||
// BOM 상태
|
||||
bomLines: BOMLine[];
|
||||
bomSearchStates: Record<string, { searchValue: string; isOpen: boolean }>;
|
||||
needsBOM: boolean;
|
||||
|
||||
// 비고
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
export function useItemFormState({ mode, initialData }: UseItemFormStateProps) {
|
||||
// 기본 상태
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [selectedItemType, setSelectedItemType] = useState<ItemType | ''>(
|
||||
mode === 'edit' ? (initialData?.itemType || 'FG') : ''
|
||||
);
|
||||
|
||||
// BOM 상태
|
||||
const [bomLines, setBomLines] = useState<BOMLine[]>(initialData?.bom || []);
|
||||
const [bomSearchStates, setBomSearchStates] = useState<Record<string, { searchValue: string; isOpen: boolean }>>({});
|
||||
|
||||
// 파일 상태
|
||||
const [specificationFile, setSpecificationFile] = useState<File | null>(null);
|
||||
const [certificationFile, setCertificationFile] = useState<File | null>(null);
|
||||
const [bendingDiagramFile, setBendingDiagramFile] = useState<File | null>(null);
|
||||
const [bendingDiagram, setBendingDiagram] = useState<string>(initialData?.bendingDiagram || '');
|
||||
const [bendingDiagramInputMethod, setBendingDiagramInputMethod] = useState<'file' | 'drawing'>('file');
|
||||
const [isDrawingOpen, setIsDrawingOpen] = useState(false);
|
||||
|
||||
// FG(제품) 상태
|
||||
const [productName, setProductName] = useState<string>(initialData?.itemName || '');
|
||||
const [productStatus, setProductStatus] = useState<string>(
|
||||
initialData?.isActive !== undefined ? String(initialData.isActive) : 'true'
|
||||
);
|
||||
|
||||
// PT(부품) 상태
|
||||
const [selectedPartType, setSelectedPartType] = useState<string>(initialData?.partType || '');
|
||||
const [partStatus, setPartStatus] = useState<string>(
|
||||
initialData?.isActive !== undefined ? String(initialData.isActive) : 'true'
|
||||
);
|
||||
|
||||
// SM/RM/CS 상태
|
||||
const [itemName, setItemName] = useState<string>(initialData?.itemName || '');
|
||||
const [selectedCategory1, setSelectedCategory1] = useState<string>(initialData?.category1 || '');
|
||||
const [selectedInstallationType, setSelectedInstallationType] = useState<string>(
|
||||
initialData?.installationType || ''
|
||||
);
|
||||
const [materialStatus, setMaterialStatus] = useState<string>(
|
||||
initialData?.isActive !== undefined ? String(initialData.isActive) : 'true'
|
||||
);
|
||||
const [selectedSpecification, setSelectedSpecification] = useState<string>(initialData?.specification || '');
|
||||
const [selectedUnit, setSelectedUnit] = useState<string>(initialData?.unit || '');
|
||||
|
||||
// ASSEMBLY 부품 상태
|
||||
const [sideSpecWidth, setSideSpecWidth] = useState<string>(initialData?.sideSpecWidth || '');
|
||||
const [sideSpecHeight, setSideSpecHeight] = useState<string>(initialData?.sideSpecHeight || '');
|
||||
const [assemblyLength, setAssemblyLength] = useState<string>(initialData?.assemblyLength || '');
|
||||
const [assemblyUnit, setAssemblyUnit] = useState<string>(initialData?.unit || 'EA');
|
||||
|
||||
// 전동개폐기 상태
|
||||
const [electricOpenerPower, setElectricOpenerPower] = useState<string>('');
|
||||
const [electricOpenerCapacity, setElectricOpenerCapacity] = useState<string>('');
|
||||
|
||||
// 모터/체인 상태
|
||||
const [motorVoltage, setMotorVoltage] = useState<string>('');
|
||||
const [chainSpec, setChainSpec] = useState<string>('');
|
||||
|
||||
// BENDING 부품 상태
|
||||
const [selectedBendingItemType, setSelectedBendingItemType] = useState<string>('');
|
||||
const [material, setMaterial] = useState<string>(initialData?.material || '');
|
||||
const [bendingLength, setBendingLength] = useState<string>(initialData?.bendingLength || '');
|
||||
const [widthSum, setWidthSum] = useState<string>(initialData?.length || '');
|
||||
const [partUnit, setPartUnit] = useState<string>(initialData?.unit || 'EA');
|
||||
const [bendingDetails, setBendingDetails] = useState<BendingDetail[]>(
|
||||
initialData?.bendingDetails || []
|
||||
);
|
||||
|
||||
// BOM 필요 여부
|
||||
const [needsBOM, setNeedsBOM] = useState<boolean>(false);
|
||||
|
||||
// 비고 (FG 전용)
|
||||
const [remarks, setRemarks] = useState<string>(initialData?.note || '');
|
||||
|
||||
// 품목 유형 변경 시 모든 상태 초기화
|
||||
const resetAllStates = useCallback((
|
||||
setValue: UseFormSetValue<CreateItemFormData>,
|
||||
clearErrors: UseFormClearErrors<CreateItemFormData>,
|
||||
type: ItemType
|
||||
) => {
|
||||
// FG(제품) 상태 초기화
|
||||
setProductName('');
|
||||
setProductStatus('true');
|
||||
|
||||
// PT(부품) 상태 초기화
|
||||
setSelectedPartType('');
|
||||
setPartStatus('true');
|
||||
|
||||
// SM/RM/CS 상태 초기화
|
||||
setItemName('');
|
||||
setSelectedCategory1('');
|
||||
setSelectedInstallationType('');
|
||||
setMaterialStatus('true');
|
||||
setSelectedSpecification('');
|
||||
setSelectedUnit('');
|
||||
|
||||
// ASSEMBLY 부품 상태 초기화
|
||||
setSideSpecWidth('');
|
||||
setSideSpecHeight('');
|
||||
setAssemblyLength('');
|
||||
setAssemblyUnit('EA');
|
||||
|
||||
// 전동개폐기 상태 초기화
|
||||
setElectricOpenerPower('');
|
||||
setElectricOpenerCapacity('');
|
||||
|
||||
// 모터/체인 상태 초기화
|
||||
setMotorVoltage('');
|
||||
setChainSpec('');
|
||||
|
||||
// BENDING 부품 상태 초기화
|
||||
setSelectedBendingItemType('');
|
||||
setMaterial('');
|
||||
setBendingLength('');
|
||||
setWidthSum('');
|
||||
setPartUnit('EA');
|
||||
setBendingDetails([]);
|
||||
|
||||
// BOM 및 파일 초기화
|
||||
setNeedsBOM(false);
|
||||
setBomLines([]);
|
||||
setSpecificationFile(null);
|
||||
setCertificationFile(null);
|
||||
setBendingDiagramFile(null);
|
||||
setBendingDiagram('');
|
||||
|
||||
// react-hook-form 필드 초기화
|
||||
setValue('itemCode', '');
|
||||
setValue('itemName', '');
|
||||
setValue('unit', (type === 'SM' || type === 'RM' || type === 'CS') ? '' : 'EA');
|
||||
setValue('specification', '');
|
||||
setValue('purchasePrice', 0);
|
||||
setValue('salesPrice', 0);
|
||||
setValue('processingCost', 0);
|
||||
setValue('laborCost', 0);
|
||||
setValue('installCost', 0);
|
||||
setValue('isActive', true);
|
||||
|
||||
// 검증 에러 초기화
|
||||
clearErrors();
|
||||
}, []);
|
||||
|
||||
// 부품 유형 변경 시 부품 관련 상태만 초기화
|
||||
const resetPartStates = useCallback((
|
||||
setValue: UseFormSetValue<CreateItemFormData>
|
||||
) => {
|
||||
// 공통 필드 초기화
|
||||
setSelectedCategory1('');
|
||||
setValue('category1', undefined);
|
||||
setPartUnit('EA');
|
||||
setValue('unit', 'EA');
|
||||
|
||||
// ASSEMBLY 부품 전용 필드 초기화
|
||||
setSelectedInstallationType('');
|
||||
setValue('installationType', undefined);
|
||||
setSideSpecWidth('');
|
||||
setValue('sideSpecWidth', '');
|
||||
setSideSpecHeight('');
|
||||
setValue('sideSpecHeight', '');
|
||||
setAssemblyLength('');
|
||||
setValue('assemblyLength', '');
|
||||
setAssemblyUnit('EA');
|
||||
|
||||
// BENDING 부품 전용 필드 초기화
|
||||
setSelectedBendingItemType('');
|
||||
setValue('category2', undefined);
|
||||
setMaterial('');
|
||||
setValue('material', '');
|
||||
setWidthSum('');
|
||||
setValue('length', '');
|
||||
setBendingLength('');
|
||||
setValue('bendingLength', '');
|
||||
setBendingDetails([]);
|
||||
|
||||
// PURCHASED 부품 전용 필드 초기화
|
||||
setElectricOpenerPower('');
|
||||
setValue('electricOpenerPower', '');
|
||||
setElectricOpenerCapacity('');
|
||||
setValue('electricOpenerCapacity', '');
|
||||
setMotorVoltage('');
|
||||
setValue('motorVoltage', '');
|
||||
setChainSpec('');
|
||||
setValue('chainSpec', '');
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 기본 상태
|
||||
isSubmitting,
|
||||
setIsSubmitting,
|
||||
selectedItemType,
|
||||
setSelectedItemType,
|
||||
|
||||
// BOM 상태
|
||||
bomLines,
|
||||
setBomLines,
|
||||
bomSearchStates,
|
||||
setBomSearchStates,
|
||||
|
||||
// 파일 상태
|
||||
specificationFile,
|
||||
setSpecificationFile,
|
||||
certificationFile,
|
||||
setCertificationFile,
|
||||
bendingDiagramFile,
|
||||
setBendingDiagramFile,
|
||||
bendingDiagram,
|
||||
setBendingDiagram,
|
||||
bendingDiagramInputMethod,
|
||||
setBendingDiagramInputMethod,
|
||||
isDrawingOpen,
|
||||
setIsDrawingOpen,
|
||||
|
||||
// FG(제품) 상태
|
||||
productName,
|
||||
setProductName,
|
||||
productStatus,
|
||||
setProductStatus,
|
||||
|
||||
// PT(부품) 상태
|
||||
selectedPartType,
|
||||
setSelectedPartType,
|
||||
partStatus,
|
||||
setPartStatus,
|
||||
|
||||
// SM/RM/CS 상태
|
||||
itemName,
|
||||
setItemName,
|
||||
selectedCategory1,
|
||||
setSelectedCategory1,
|
||||
selectedInstallationType,
|
||||
setSelectedInstallationType,
|
||||
materialStatus,
|
||||
setMaterialStatus,
|
||||
selectedSpecification,
|
||||
setSelectedSpecification,
|
||||
selectedUnit,
|
||||
setSelectedUnit,
|
||||
|
||||
// ASSEMBLY 부품 상태
|
||||
sideSpecWidth,
|
||||
setSideSpecWidth,
|
||||
sideSpecHeight,
|
||||
setSideSpecHeight,
|
||||
assemblyLength,
|
||||
setAssemblyLength,
|
||||
assemblyUnit,
|
||||
setAssemblyUnit,
|
||||
|
||||
// 전동개폐기 상태
|
||||
electricOpenerPower,
|
||||
setElectricOpenerPower,
|
||||
electricOpenerCapacity,
|
||||
setElectricOpenerCapacity,
|
||||
|
||||
// 모터/체인 상태
|
||||
motorVoltage,
|
||||
setMotorVoltage,
|
||||
chainSpec,
|
||||
setChainSpec,
|
||||
|
||||
// BENDING 부품 상태
|
||||
selectedBendingItemType,
|
||||
setSelectedBendingItemType,
|
||||
material,
|
||||
setMaterial,
|
||||
bendingLength,
|
||||
setBendingLength,
|
||||
widthSum,
|
||||
setWidthSum,
|
||||
partUnit,
|
||||
setPartUnit,
|
||||
bendingDetails,
|
||||
setBendingDetails,
|
||||
|
||||
// BOM 필요 여부
|
||||
needsBOM,
|
||||
setNeedsBOM,
|
||||
|
||||
// 비고
|
||||
remarks,
|
||||
setRemarks,
|
||||
|
||||
// 헬퍼 함수
|
||||
resetAllStates,
|
||||
resetPartStates,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseItemFormStateReturn = ReturnType<typeof useItemFormState>;
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user