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:
byeongcheolryu
2025-11-27 22:19:50 +09:00
parent b73603822b
commit 65a8510c0b
130 changed files with 11031 additions and 2287 deletions

7
.gitignore vendored
View File

@@ -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/

View 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 작성*

View 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`

View File

@@ -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
View 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`에 새 문서 추가 시 테이블 업데이트

View File

@@ -304,3 +304,17 @@ API Key 설정 완료 후:
- **API Key 발급**: PHP 백엔드 팀
- **기술 지원**: 프론트엔드 팀
- **보안 문제**: 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 제외 설정

View File

@@ -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`

View File

@@ -325,3 +325,18 @@ 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 설정

View File

@@ -418,3 +418,19 @@ interface RegisterData {
---
**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 설정

View File

@@ -496,3 +496,21 @@ if (typeof feature === 'undefined') {
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 에러 해결

View File

@@ -91,3 +91,16 @@ useEffect(() => {
## 참고 문서
- Next.js SSR/Hydration: https://nextjs.org/docs/messages/react-hydration-error
- 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 패턴)

View File

@@ -1024,3 +1024,22 @@ const response = await fetch(`/api/tenants/350/item-master-config`);
**문서 버전**: 1.1 (tenant.id 반영)
**마지막 업데이트**: 2025-11-19
**다음 리뷰**: 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 에러 해결

View File

@@ -843,3 +843,25 @@ 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` - 멀티테넌시 구현

View File

@@ -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`

View File

@@ -308,3 +308,21 @@ const user = await bearerClient.login({
- [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)
---
## 관련 파일
### 프론트엔드
- `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` - 환경 변수 템플릿

View File

@@ -489,3 +489,20 @@ headers: { 'Authorization': `Bearer ${token}` }
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 설정

View File

@@ -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 호출은 프록시를 통해 자동 갱신되므로 직접 사용할 필요 없음
---
## 사용 예시

View File

@@ -375,3 +375,17 @@ NEXT_PUBLIC_AUTH_MODE=sanctum
- HttpOnly 쿠키 동작 확인
- 비로그인 상태 차단 확인
- 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)

View File

@@ -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` - 인증 구현 가이드

View File

@@ -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` - 대시보드 통합 완료 보고서

View File

@@ -581,3 +581,16 @@ const normalizedPath = pathname.replace(/^\/(ko|en|ja)/, '');
**작성일:** 2025-11-11
**작성자:** Claude Code
**마지막 수정:** 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` - 미들웨어 아키텍처

View File

@@ -401,3 +401,16 @@ useEffect(() => {
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` - 브라우저 지원 정책

View File

@@ -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` - 대시보드 통합 완료

View File

@@ -410,3 +410,17 @@
- **v1.0** (2025-11-17): 초안 작성, 4가지 방법론 정의
- **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` - 대용량 파일 작업 워크플로우

View File

@@ -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 마이그레이션 워크플로우

View File

@@ -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` - 폼 및 유효성 검증 가이드

View File

@@ -736,3 +736,23 @@ export default function ClientComponent() {
**문서 작성일**: 2025-11-06
**작성자**: Claude Code
**프로젝트**: 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` - 아키텍처 통합 위험 분석

View File

@@ -1018,3 +1018,24 @@ describe('loginSchema', () => {
**다음 업데이트**: 새로운 폼 패턴 추가 시
**작성자**: 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 유효성 검증 문제 해결

View File

@@ -704,3 +704,26 @@ export default function Error() {
## 마무리
이 가이드를 바탕으로 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` - 다국어 설정 가이드

File diff suppressed because it is too large Load Diff

View File

@@ -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 요구사항

View File

@@ -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 요청서
---

View File

@@ -1018,3 +1018,17 @@ public function create(int $pageId, array $data): ItemSection
**프론트엔드 담당**: [담당자명]
**작성일**: 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` - 프론트엔드 설계 문서

View File

@@ -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

View File

@@ -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` - 훅 리팩토링 문서
---

View 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`의 의존성 배열 확인 → 두 상태가 모두 업데이트되어야 재계산됨

View File

@@ -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 작업 시작

View File

@@ -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 통합 여부 결정
- [ ] 테스트: 마스터 항목 수정 → 연결된 필드 동기화 확인

View File

@@ -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 추가/삭제 테스트

View File

@@ -425,3 +425,22 @@ ItemMasterDataManagement/
├── 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 연동 작업 체크리스트

View File

@@ -916,3 +916,22 @@ GET /api/files/{file_id}/download
- 테넌트별 파일 저장 경로 구조 명시
- 파일명 처리 방식 명시 (난수 저장명 + 원본명 보존)
- 차단 확장자 목록 추가 (보안)
---
## 관련 파일
### 프론트엔드
- `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
View File

@@ -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",

View File

@@ -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",

View File

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

View File

@@ -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">

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

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

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

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

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

View 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': '단위',
};

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

View File

@@ -0,0 +1,12 @@
/**
* 품목 폼 컨텍스트 export
*/
export {
ItemFormProvider,
useItemFormContext,
useOptionalItemFormContext,
default as ItemFormContext,
} from './ItemFormContext';
export type { ItemFormContextType, ItemFormProviderProps } from './ItemFormContext';

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

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

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

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

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

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

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

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

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

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

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

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

Some files were not shown because too many files have changed in this diff Show More