diff --git a/claudedocs/architecture/[DESIGN-2025-12-20] item-master-zustand-refactoring.md b/claudedocs/architecture/[DESIGN-2025-12-20] item-master-zustand-refactoring.md new file mode 100644 index 00000000..2639c3f2 --- /dev/null +++ b/claudedocs/architecture/[DESIGN-2025-12-20] item-master-zustand-refactoring.md @@ -0,0 +1,538 @@ +# 품목기준관리 Zustand 리팩토링 설계서 + +> **핵심 목표**: 모든 기능을 100% 동일하게 유지하면서, 수정 절차를 간단화 + +## 📌 핵심 원칙 + +``` +⚠️ 중요: 모든 품목기준관리 기능을 그대로 가져와야 함 +⚠️ 중요: 수정 절차 간단화가 핵심 (3방향 동기화 → 1곳 수정) +⚠️ 중요: 모든 기능이 정확히 동일하게 작동해야 함 +``` + +## 🔴 최종 검증 기준 (가장 중요!) + +### 페이지 관계도 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ [DB / API] │ +│ (단일 진실 공급원) │ +└─────────────────────────────────────────────────────────────┘ + ↑ ↑ ↓ + │ │ │ +┌───────┴───────┐ ┌────────┴────────┐ ┌────────┴────────┐ +│ 품목기준관리 │ │ 품목기준관리 │ │ 품목관리 │ +│ 테스트 페이지 │ │ 페이지 (기존) │ │ 페이지 │ +│ (Zustand) │ │ (Context) │ │ (동적 폼 렌더링) │ +└───────────────┘ └──────────────────┘ └──────────────────┘ + [신규] [기존] [최종 사용처] +``` + +### 검증 시나리오 + +``` +1. 테스트 페이지에서 섹션/필드 수정 + ↓ +2. API 호출 → DB 저장 + ↓ +3. 품목기준관리 페이지 (기존)에서 동일하게 표시되어야 함 + ↓ +4. 품목관리 페이지에서 동적 폼이 변경된 구조로 렌더링되어야 함 +``` + +### 필수 검증 항목 + +| # | 검증 항목 | 설명 | +|---|----------|------| +| 1 | **API 동일성** | 테스트 페이지가 기존 페이지와 동일한 API 엔드포인트 사용 | +| 2 | **데이터 동일성** | API 응답/요청 데이터 형식 100% 동일 | +| 3 | **기존 페이지 반영** | 테스트 페이지에서 수정 → 기존 품목기준관리 페이지에 반영 | +| 4 | **품목관리 반영** | 테스트 페이지에서 수정 → 품목관리 동적 폼에 반영 | + +### 왜 이게 중요한가? + +``` +테스트 페이지 (Zustand) ──┐ + ├──→ 같은 API ──→ 같은 DB ──→ 품목관리 페이지 +기존 페이지 (Context) ────┘ + +→ 상태 관리 방식만 다르고, API/DB는 공유 +→ 테스트 페이지에서 수정한 내용이 품목관리 페이지에 그대로 적용되어야 함 +→ 이것이 성공하면 Zustand 리팩토링이 완전히 검증된 것 +``` + +--- + +## 1. 현재 문제점 분석 + +### 1.1 중복 상태 관리 (3방향 동기화) + +현재 `ItemMasterContext.tsx`에서 섹션 수정 시: + +```typescript +// updateSection() 함수 내부 (Line 1464-1486) +setItemPages(...) // 1. 계층구조 탭 +setSectionTemplates(...) // 2. 섹션 탭 +setIndependentSections(...) // 3. 독립 섹션 +``` + +**문제점**: +- 같은 데이터를 3곳에서 중복 관리 +- 한 곳 업데이트 누락 시 데이터 불일치 +- 모든 CRUD 함수에 동일 패턴 반복 +- 새 기능 추가 시 3곳 모두 수정 필요 + +### 1.2 현재 상태 변수 목록 (16개) + +| # | 상태 변수 | 설명 | 중복 여부 | +|---|----------|------|----------| +| 1 | `itemMasters` | 품목 마스터 | - | +| 2 | `specificationMasters` | 규격 마스터 | - | +| 3 | `materialItemNames` | 자재 품목명 | - | +| 4 | `itemCategories` | 품목 분류 | - | +| 5 | `itemUnits` | 단위 | - | +| 6 | `itemMaterials` | 재질 | - | +| 7 | `surfaceTreatments` | 표면처리 | - | +| 8 | `partTypeOptions` | 부품유형 옵션 | - | +| 9 | `partUsageOptions` | 부품용도 옵션 | - | +| 10 | `guideRailOptions` | 가이드레일 옵션 | - | +| 11 | `sectionTemplates` | 섹션 템플릿 | ⚠️ 중복 | +| 12 | `itemMasterFields` | 필드 마스터 | ⚠️ 중복 | +| 13 | `itemPages` | 페이지 (섹션/필드 포함) | ⚠️ 중복 | +| 14 | `independentSections` | 독립 섹션 | ⚠️ 중복 | +| 15 | `independentFields` | 독립 필드 | ⚠️ 중복 | +| 16 | `independentBomItems` | 독립 BOM | ⚠️ 중복 | + +**중복 문제가 있는 엔티티**: +- **섹션**: `sectionTemplates`, `itemPages.sections`, `independentSections` +- **필드**: `itemMasterFields`, `itemPages.sections.fields`, `independentFields` +- **BOM**: `itemPages.sections.bom_items`, `independentBomItems` + +--- + +## 2. 리팩토링 설계 + +### 2.1 정규화된 상태 구조 (Normalized State) + +```typescript +// stores/useItemMasterStore.ts +interface ItemMasterState { + // ===== 정규화된 엔티티 (ID 기반 딕셔너리) ===== + entities: { + pages: Record; + sections: Record; + fields: Record; + bomItems: Record; + }; + + // ===== ID 목록 (순서 관리) ===== + ids: { + pages: number[]; + independentSections: number[]; // page_id가 null인 섹션 + independentFields: number[]; // section_id가 null인 필드 + independentBomItems: number[]; // section_id가 null인 BOM + }; + + // ===== 참조 데이터 (중복 없음) ===== + references: { + itemMasters: ItemMaster[]; + specificationMasters: SpecificationMaster[]; + materialItemNames: MaterialItemName[]; + itemCategories: ItemCategory[]; + itemUnits: ItemUnit[]; + itemMaterials: ItemMaterial[]; + surfaceTreatments: SurfaceTreatment[]; + partTypeOptions: PartTypeOption[]; + partUsageOptions: PartUsageOption[]; + guideRailOptions: GuideRailOption[]; + }; + + // ===== UI 상태 ===== + ui: { + isLoading: boolean; + error: string | null; + selectedPageId: number | null; + selectedSectionId: number | null; + }; +} +``` + +### 2.2 엔티티 구조 + +```typescript +// 페이지 엔티티 (섹션 ID만 참조) +interface PageEntity { + id: number; + page_name: string; + item_type: string; + description?: string; + order_no: number; + is_active: boolean; + sectionIds: number[]; // 섹션 객체 대신 ID만 저장 + created_at?: string; + updated_at?: string; +} + +// 섹션 엔티티 (필드/BOM ID만 참조) +interface SectionEntity { + id: number; + title: string; + page_id: number | null; // null이면 독립 섹션 + order_no: number; + is_collapsible: boolean; + default_open: boolean; + fieldIds: number[]; // 필드 ID 목록 + bomItemIds: number[]; // BOM ID 목록 + created_at?: string; + updated_at?: string; +} + +// 필드 엔티티 +interface FieldEntity { + id: number; + field_key: string; + field_name: string; + field_type: string; + section_id: number | null; // null이면 독립 필드 + order_no: number; + is_required: boolean; + options?: any; + default_value?: any; + created_at?: string; + updated_at?: string; +} + +// BOM 엔티티 +interface BOMItemEntity { + id: number; + section_id: number | null; // null이면 독립 BOM + child_item_code: string; + child_item_name: string; + quantity: number; + unit: string; + order_no: number; + created_at?: string; + updated_at?: string; +} +``` + +### 2.3 수정 절차 비교 + +#### Before (현재): 3방향 동기화 + +```typescript +const updateSection = async (sectionId, updates) => { + const response = await api.update(sectionId, updates); + + // 1. itemPages 업데이트 + setItemPages(prev => prev.map(page => ({ + ...page, + sections: page.sections.map(s => s.id === sectionId ? {...s, ...updates} : s) + }))); + + // 2. sectionTemplates 업데이트 + setSectionTemplates(prev => prev.map(t => + t.id === sectionId ? {...t, ...updates} : t + )); + + // 3. independentSections 업데이트 + setIndependentSections(prev => prev.map(s => + s.id === sectionId ? {...s, ...updates} : s + )); +}; +``` + +#### After (Zustand): 1곳만 수정 + +```typescript +const updateSection = async (sectionId, updates) => { + const response = await api.update(sectionId, updates); + + // 딱 1곳만 수정하면 끝! + set((state) => ({ + entities: { + ...state.entities, + sections: { + ...state.entities.sections, + [sectionId]: { ...state.entities.sections[sectionId], ...updates } + } + } + })); +}; +``` + +### 2.4 파생 상태 (Selectors) + +```typescript +// 계층구조 탭용: 페이지 + 섹션 + 필드 조합 +const usePageWithDetails = (pageId: number) => { + return useItemMasterStore((state) => { + const page = state.entities.pages[pageId]; + if (!page) return null; + + return { + ...page, + sections: page.sectionIds.map(sId => { + const section = state.entities.sections[sId]; + return { + ...section, + fields: section.fieldIds.map(fId => state.entities.fields[fId]), + bom_items: section.bomItemIds.map(bId => state.entities.bomItems[bId]), + }; + }), + }; + }); +}; + +// 섹션 탭용: 모든 섹션 (페이지 연결 여부 무관) +const useAllSections = () => { + return useItemMasterStore((state) => + Object.values(state.entities.sections) + ); +}; + +// 독립 섹션만 +const useIndependentSections = () => { + return useItemMasterStore((state) => + state.ids.independentSections.map(id => state.entities.sections[id]) + ); +}; +``` + +--- + +## 3. 기능 매핑 체크리스트 + +### 3.1 페이지 관리 + +| 기존 함수 | 새 함수 | 상태 | +|----------|--------|------| +| `loadItemPages` | `loadPages` | ⬜ | +| `addItemPage` | `createPage` | ⬜ | +| `updateItemPage` | `updatePage` | ⬜ | +| `deleteItemPage` | `deletePage` | ⬜ | + +### 3.2 섹션 관리 + +| 기존 함수 | 새 함수 | 상태 | +|----------|--------|------| +| `loadSectionTemplates` | `loadSections` | ⬜ | +| `loadIndependentSections` | (loadSections에 통합) | ⬜ | +| `addSectionTemplate` | `createSection` | ⬜ | +| `addSectionToPage` | `createSectionInPage` | ⬜ | +| `createIndependentSection` | `createSection` (page_id: null) | ⬜ | +| `updateSectionTemplate` | `updateSection` | ⬜ | +| `updateSection` | `updateSection` | ⬜ | +| `deleteSectionTemplate` | `deleteSection` | ⬜ | +| `deleteSection` | `deleteSection` | ⬜ | +| `linkSectionToPage` | `linkSectionToPage` | ⬜ | +| `unlinkSectionFromPage` | `unlinkSectionFromPage` | ⬜ | +| `getSectionUsage` | `getSectionUsage` | ⬜ | + +### 3.3 필드 관리 + +| 기존 함수 | 새 함수 | 상태 | +|----------|--------|------| +| `loadItemMasterFields` | `loadFields` | ⬜ | +| `loadIndependentFields` | (loadFields에 통합) | ⬜ | +| `addItemMasterField` | `createField` | ⬜ | +| `addFieldToSection` | `createFieldInSection` | ⬜ | +| `createIndependentField` | `createField` (section_id: null) | ⬜ | +| `updateItemMasterField` | `updateField` | ⬜ | +| `updateField` | `updateField` | ⬜ | +| `deleteItemMasterField` | `deleteField` | ⬜ | +| `deleteField` | `deleteField` | ⬜ | +| `linkFieldToSection` | `linkFieldToSection` | ⬜ | +| `unlinkFieldFromSection` | `unlinkFieldFromSection` | ⬜ | +| `getFieldUsage` | `getFieldUsage` | ⬜ | + +### 3.4 BOM 관리 + +| 기존 함수 | 새 함수 | 상태 | +|----------|--------|------| +| `loadIndependentBomItems` | `loadBomItems` | ⬜ | +| `addBOMItem` | `createBomItem` | ⬜ | +| `createIndependentBomItem` | `createBomItem` (section_id: null) | ⬜ | +| `updateBOMItem` | `updateBomItem` | ⬜ | +| `deleteBOMItem` | `deleteBomItem` | ⬜ | + +### 3.5 참조 데이터 관리 + +| 기존 함수 | 새 함수 | 상태 | +|----------|--------|------| +| `addItemMaster` / `updateItemMaster` / `deleteItemMaster` | `itemMasterActions` | ⬜ | +| `addSpecificationMaster` / `updateSpecificationMaster` / `deleteSpecificationMaster` | `specificationActions` | ⬜ | +| `addMaterialItemName` / `updateMaterialItemName` / `deleteMaterialItemName` | `materialItemNameActions` | ⬜ | +| `addItemCategory` / `updateItemCategory` / `deleteItemCategory` | `categoryActions` | ⬜ | +| `addItemUnit` / `updateItemUnit` / `deleteItemUnit` | `unitActions` | ⬜ | +| `addItemMaterial` / `updateItemMaterial` / `deleteItemMaterial` | `materialActions` | ⬜ | +| `addSurfaceTreatment` / `updateSurfaceTreatment` / `deleteSurfaceTreatment` | `surfaceTreatmentActions` | ⬜ | +| `addPartTypeOption` / `updatePartTypeOption` / `deletePartTypeOption` | `partTypeActions` | ⬜ | +| `addPartUsageOption` / `updatePartUsageOption` / `deletePartUsageOption` | `partUsageActions` | ⬜ | +| `addGuideRailOption` / `updateGuideRailOption` / `deleteGuideRailOption` | `guideRailActions` | ⬜ | + +--- + +## 4. 구현 계획 + +### Phase 1: 기반 구축 ✅ 완료 (2025-12-20) + +- [x] Zustand, Immer 설치 +- [x] 테스트 페이지 라우트 생성 (`/items-management-test`) +- [x] 기본 스토어 구조 생성 (`useItemMasterStore.ts`) +- [x] 타입 정의 (`types.ts`) + +### Phase 2: API 연동 ✅ 완료 (2025-12-20) + +- [x] 기존 API 구조 분석 (`item-master.ts`) +- [x] API 응답 → 정규화 상태 변환 함수 (`normalizers.ts`) +- [x] 스토어에 `initFromApi()` 함수 구현 +- [x] 테스트 페이지에서 실제 API 데이터 로드 기능 추가 + +**생성된 파일**: +- `src/stores/item-master/normalizers.ts` - API 응답 정규화 함수 + +**테스트 페이지 기능**: +- "실제 API 로드" 버튼 - 백엔드 API에서 실제 데이터 로드 +- "테스트 데이터 로드" 버튼 - 하드코딩된 테스트 데이터 로드 +- 데이터 소스 표시 (API/테스트/없음) + +### Phase 3: 핵심 엔티티 구현 + +- [x] 페이지 CRUD 구현 (로컬 상태) +- [x] 섹션 CRUD 구현 (로컬 상태) +- [x] 필드 CRUD 구현 (로컬 상태) +- [x] BOM CRUD 구현 (로컬 상태) +- [x] link/unlink 기능 구현 (로컬 상태) +- [ ] API 연동 CRUD (DB 저장) - **다음 단계** + +### Phase 3: 참조 데이터 구현 + +- [ ] 품목 마스터 관리 +- [ ] 규격 마스터 관리 +- [ ] 분류/단위/재질 등 옵션 관리 + +### Phase 4: 파생 상태 & 셀렉터 + +- [ ] 계층구조 뷰용 셀렉터 +- [ ] 섹션 탭용 셀렉터 +- [ ] 필드 탭용 셀렉터 +- [ ] 독립 항목 셀렉터 + +### Phase 5: UI 연동 + +- [ ] 테스트 페이지 컴포넌트 생성 +- [ ] 기존 컴포넌트 재사용 (스토어만 교체) +- [ ] 동작 검증 + +### Phase 6: 검증 & 마이그레이션 + +- [ ] 기존 페이지와 1:1 동작 비교 +- [ ] 엣지 케이스 테스트 +- [ ] 성능 비교 +- [ ] 기존 페이지 마이그레이션 결정 + +--- + +## 5. 파일 구조 + +``` +src/ +├── stores/ +│ └── item-master/ +│ ├── useItemMasterStore.ts # 메인 스토어 +│ ├── slices/ +│ │ ├── pageSlice.ts # 페이지 액션 +│ │ ├── sectionSlice.ts # 섹션 액션 +│ │ ├── fieldSlice.ts # 필드 액션 +│ │ ├── bomSlice.ts # BOM 액션 +│ │ └── referenceSlice.ts # 참조 데이터 액션 +│ ├── selectors/ +│ │ ├── pageSelectors.ts # 페이지 파생 상태 +│ │ ├── sectionSelectors.ts # 섹션 파생 상태 +│ │ └── fieldSelectors.ts # 필드 파생 상태 +│ └── types.ts # 타입 정의 +│ +├── app/[locale]/(protected)/ +│ └── items-management-test/ +│ └── page.tsx # 테스트 페이지 +``` + +--- + +## 6. 테스트 시나리오 + +### 6.1 섹션 수정 동기화 테스트 + +``` +시나리오: 섹션 이름 수정 +1. 계층구조 탭에서 섹션 선택 +2. 섹션 이름 "기본정보" → "기본 정보" 수정 +3. 검증: + - [ ] 계층구조 탭에 반영 + - [ ] 섹션 탭에 반영 + - [ ] 독립 섹션(연결 해제 시) 반영 + - [ ] API 호출 1회만 발생 +``` + +### 6.2 필드 이동 테스트 + +``` +시나리오: 필드를 다른 섹션으로 이동 +1. 섹션 A에서 필드 선택 +2. 섹션 B로 이동 (unlink → link) +3. 검증: + - [ ] 섹션 A에서 필드 제거 + - [ ] 섹션 B에 필드 추가 + - [ ] 계층구조 탭 반영 + - [ ] 필드 탭에서 section_id 변경 +``` + +### 6.3 독립 → 연결 테스트 + +``` +시나리오: 독립 섹션을 페이지에 연결 +1. 독립 섹션 선택 +2. 페이지에 연결 (linkSectionToPage) +3. 검증: + - [ ] 독립 섹션 목록에서 제거 + - [ ] 페이지의 섹션 목록에 추가 + - [ ] 섹션 탭에서 page_id 변경 +``` + +--- + +## 7. 롤백 계획 + +문제 발생 시: +1. 테스트 페이지 라우트 제거 +2. 스토어 코드 삭제 +3. 기존 `ItemMasterContext` 그대로 사용 + +**리스크 최소화**: +- 기존 코드 수정 없음 +- 새 코드만 추가 +- 언제든 롤백 가능 + +--- + +## 8. 성공 기준 + +| 항목 | 기준 | +|-----|------| +| **기능 동등성** | 기존 모든 기능 100% 동작 | +| **동기화** | 1곳 수정으로 모든 뷰 업데이트 | +| **코드량** | CRUD 함수 코드 50% 이상 감소 | +| **버그** | 데이터 불일치 버그 0건 | +| **성능** | 기존 대비 동등 또는 향상 | + +--- + +## 변경 이력 + +| 날짜 | 작성자 | 내용 | +|-----|--------|------| +| 2025-12-20 | Claude | 초안 작성 | +| 2025-12-20 | Claude | Phase 1 완료 - 기반 구축 | +| 2025-12-20 | Claude | Phase 2 완료 - API 연동 (normalizers.ts, initFromApi) | \ No newline at end of file diff --git a/claudedocs/architecture/[NEXT-2025-12-20] zustand-refactoring-session-context.md b/claudedocs/architecture/[NEXT-2025-12-20] zustand-refactoring-session-context.md new file mode 100644 index 00000000..a2a38694 --- /dev/null +++ b/claudedocs/architecture/[NEXT-2025-12-20] zustand-refactoring-session-context.md @@ -0,0 +1,344 @@ +# 품목기준관리 Zustand 리팩토링 - 세션 컨텍스트 + +> 다음 세션에서 이 문서를 먼저 읽고 작업 이어가기 + +## 🎯 프로젝트 목표 + +**핵심 목표:** +1. 품목기준관리 100% 동일 기능 구현 +2. **더 유연한 데이터 관리** (Zustand 정규화 구조) +3. **개선된 UX** (Context 3방향 동기화 → Zustand 1곳 수정) + +**접근 방식:** +- 기존 컴포넌트 재사용 ❌ +- 테스트 페이지에서 완전히 새로 구현 ✅ +- 분리된 상태 유지 → 복구 시나리오 보장 + +--- + +## 세션 요약 (2025-12-22 - 11차 세션) + +### ✅ 오늘 완료된 작업 + +1. **기존 품목기준관리와 상세 기능 비교** + - 구현 완료율: 약 72% + - 핵심 CRUD 기능 모두 구현 확인 + +2. **누락된 핵심 기능 식별** + - 🔴 절대경로(absolute_path) 수정 - PathEditDialog + - 🔴 페이지 복제 - handleDuplicatePage + - 🔴 필드 조건부 표시 - ConditionalDisplayUI + - 🟡 칼럼 관리 - ColumnManageDialog + - 🟡 섹션/필드 사용 현황 표시 + +3. **브랜치 분리 완료** + - `feature/item-master-zustand` 브랜치 생성 + - 29개 파일, 8,248줄 커밋 + - master와 분리 관리 가능 + +--- + +## 세션 요약 (2025-12-21 - 10차 세션) + +### ✅ 오늘 완료된 작업 + +1. **기존 품목기준관리와 기능 비교 분석** + - 기존 페이지의 모든 핵심 기능 구현 확인 + - 커스텀 탭 관리는 기존 페이지에서도 비활성화(주석 처리)됨 + - 탭 관리 기능은 로컬 상태만 사용 (백엔드 미연동, 새로고침 시 초기화) + +2. **Phase D-2 (커스텀 탭 관리) 분석 결과** + - 기존 페이지의 "탭 관리" 버튼: 주석 처리됨 (미사용) + - 속성 하위 탭 관리: 로컬 상태로만 동작 (영속성 없음) + - **결론**: 선택적 기능으로 분류, 핵심 기능 구현 완료 + +--- + +## 세션 요약 (2025-12-21 - 9차 세션) + +### ✅ 완료된 작업 + +1. **속성 CRUD API 연동 완료** + - `types.ts`: PropertyActions 인터페이스 추가 + - `useItemMasterStore.ts`: addUnit, updateUnit, deleteUnit, addMaterial, updateMaterial, deleteMaterial, addTreatment, updateTreatment, deleteTreatment 구현 + - `item-master-api.ts`: UnitOptionRequest/Response 타입 수정 (unit_code, unit_name 사용) + +2. **Import 기능 구현 완료** + - `ImportSectionDialog.tsx`: 독립 섹션 목록에서 선택하여 페이지에 연결 + - `ImportFieldDialog.tsx`: 독립 필드 목록에서 선택하여 섹션에 연결 + - `dialogs/index.ts`: Import 다이얼로그 export 추가 + - `HierarchyTab.tsx`: 불러오기 버튼에 Import 다이얼로그 연결 + +3. **섹션 복제 API 연동 완료** + - `SectionsTab.tsx`: handleCloneSection 함수 구현 (API 연동 + toast 알림) + +4. **타입 수정** + - `transformers.ts`: transformUnitOptionResponse 수정 (unit_name, unit_code 사용) + - `useFormStructure.ts`: 단위 옵션 매핑 수정 (unit_name, unit_code 사용) + +--- + +### ✅ 완료된 Phase + +| Phase | 내용 | 상태 | +|-------|------|------| +| Phase 1 | Zustand 스토어 기본 구조 | ✅ | +| Phase 2 | API 연동 (initFromApi) | ✅ | +| Phase 3 | API CRUD 연동 (update 함수들) | ✅ | +| Phase A-1 | 계층구조 기본 표시 | ✅ | +| Phase A-2 | 드래그앤드롭 순서 변경 | ✅ | +| Phase A-3 | 인라인 편집 (페이지/섹션/경로) | ✅ | +| Phase B-1 | 페이지 CRUD 다이얼로그 | ✅ | +| Phase B-2 | 섹션 CRUD 다이얼로그 | ✅ | +| Phase B-3 | 필드 CRUD 다이얼로그 | ✅ | +| Phase B-4 | BOM 관리 UI | ✅ | +| Phase C-1 | 섹션 탭 구현 (SectionsTab.tsx) | ✅ | +| Phase C-2 | 항목 탭 구현 (FieldsTab.tsx) | ✅ | +| Phase D-1 | 속성 탭 기본 구조 (PropertiesTab.tsx) | ✅ | +| Phase E | Import 기능 (섹션/필드 불러오기) | ✅ | + +### ✅ 현재 상태: 핵심 기능 구현 완료 + +**Phase D-2 (커스텀 탭 관리)**: 선택적 기능으로 분류됨 +- 기존 페이지에서도 "탭 관리" 버튼은 주석 처리 (미사용) +- 속성 하위 탭 관리도 로컬 상태로만 동작 (백엔드 미연동) +- 필요 시 추후 구현 가능 + +--- + +## 📋 기능 비교 결과 + +### ✅ 구현 완료된 핵심 기능 + +| 기능 | 테스트 페이지 | 기존 페이지 | +|------|-------------|------------| +| 계층구조 관리 | ✅ | ✅ | +| 페이지 CRUD | ✅ | ✅ | +| 섹션 CRUD | ✅ | ✅ | +| 필드 CRUD | ✅ | ✅ | +| BOM 관리 | ✅ | ✅ | +| 드래그앤드롭 순서 변경 | ✅ | ✅ | +| 인라인 편집 | ✅ | ✅ | +| Import (섹션/필드) | ✅ | ✅ | +| 섹션 복제 | ✅ | ✅ | +| 단위/재질/표면처리 CRUD | ✅ | ✅ | +| 검색/필터 | ✅ | ✅ | + +### ⚠️ 선택적 기능 (기존 페이지에서도 제한적 사용) + +| 기능 | 상태 | 비고 | +|------|------|------| +| 커스텀 메인 탭 관리 | 미구현 | 기존 페이지에서 주석 처리됨 | +| 속성 하위 탭 관리 | 미구현 | 로컬 상태만 (영속성 없음) | +| 칼럼 관리 | 미구현 | 로컬 상태만 (영속성 없음) | + +--- + +## 📋 전체 기능 체크리스트 + +### Phase A: 기본 UI 구조 (계층구조 탭 완성) ✅ + +#### A-1. 계층구조 기본 표시 ✅ 완료 +- [x] 페이지 목록 표시 (좌측 패널) +- [x] 페이지 선택 시 섹션 목록 표시 (우측 패널) +- [x] 섹션 내부 필드 목록 표시 +- [x] 필드 타입별 뱃지 표시 +- [x] BOM 타입 섹션 구분 표시 + +#### A-2. 드래그앤드롭 순서 변경 ✅ 완료 +- [x] 섹션 드래그앤드롭 순서 변경 +- [x] 필드 드래그앤드롭 순서 변경 +- [x] 스토어 reorderSections 함수 구현 +- [x] 스토어 reorderFields 함수 구현 +- [x] DraggableSection 컴포넌트 생성 +- [x] DraggableField 컴포넌트 생성 + +#### A-3. 인라인 편집 ✅ 완료 +- [x] InlineEdit 재사용 컴포넌트 생성 +- [x] 페이지 이름 더블클릭 인라인 수정 +- [x] 섹션 제목 더블클릭 인라인 수정 +- [x] 절대경로 인라인 수정 + +--- + +### Phase B: CRUD 다이얼로그 ✅ + +#### B-1. 페이지 관리 ✅ 완료 +- [x] PageDialog 컴포넌트 (페이지 추가/수정) +- [x] DeleteConfirmDialog (재사용 가능한 삭제 확인) +- [x] 페이지 추가 버튼 연결 +- [x] 페이지 삭제 버튼 연결 + +#### B-2. 섹션 관리 ✅ 완료 +- [x] SectionDialog 컴포넌트 (섹션 추가/수정) +- [x] 섹션 삭제 다이얼로그 +- [x] 섹션 연결해제 다이얼로그 +- [x] 섹션 추가 버튼 연결 +- [x] ImportSectionDialog (섹션 불러오기) ✅ + +#### B-3. 필드 관리 ✅ 완료 +- [x] FieldDialog 컴포넌트 (필드 추가/수정) +- [x] 드롭다운 옵션 동적 관리 +- [x] 필드 삭제 다이얼로그 +- [x] 필드 연결해제 다이얼로그 +- [x] 필드 추가 버튼 연결 +- [x] ImportFieldDialog (필드 불러오기) ✅ + +#### B-4. BOM 관리 ✅ 완료 +- [x] BOMDialog 컴포넌트 (BOM 추가/수정) +- [x] BOM 항목 삭제 다이얼로그 +- [x] BOM 추가 버튼 연결 +- [x] BOM 수정 버튼 연결 + +--- + +### Phase C: 섹션 탭 + 항목 탭 ✅ + +#### C-1. 섹션 탭 ✅ 완료 +- [x] 모든 섹션 목록 표시 (연결된 + 독립) +- [x] 섹션 상세 정보 표시 +- [x] 섹션 내부 필드 표시 (확장/축소) +- [x] 일반 섹션 / BOM 섹션 탭 분리 +- [x] 페이지 연결 상태 표시 +- [x] 섹션 추가/수정/삭제 다이얼로그 연동 +- [x] 섹션 복제 기능 (API 연동 완료) ✅ + +#### C-2. 항목 탭 (마스터 필드) ✅ 완료 +- [x] 모든 필드 목록 표시 +- [x] 필드 상세 정보 표시 +- [x] 검색 기능 (필드명, 필드키, 타입) +- [x] 필터 기능 (전체/독립/연결된 필드) +- [x] 필드 추가/수정/삭제 다이얼로그 연동 +- [x] 독립 필드 → 섹션 연결 기능 + +--- + +### Phase D: 속성 탭 (진행 중) + +#### D-1. 속성 관리 ✅ 완료 +- [x] PropertiesTab.tsx 기본 구조 +- [x] 단위 관리 (CRUD) - API 연동 완료 +- [x] 재질 관리 (CRUD) - API 연동 완료 +- [x] 표면처리 관리 (CRUD) - API 연동 완료 +- [x] PropertyDialog (속성 옵션 추가) + +#### D-2. 탭 관리 (예정) +- [ ] 커스텀 탭 추가/수정/삭제 +- [ ] 속성 하위 탭 추가/수정/삭제 +- [ ] 탭 순서 변경 + +--- + +### Phase E: Import 기능 ✅ + +- [x] ImportSectionDialog (섹션 불러오기) +- [x] ImportFieldDialog (필드 불러오기) +- [x] HierarchyTab 불러오기 버튼 연결 + +--- + +## 📁 파일 구조 + +``` +src/stores/item-master/ +├── types.ts # 정규화된 엔티티 타입 + PropertyActions +├── useItemMasterStore.ts # Zustand 스토어 +├── normalizers.ts # API 응답 정규화 + +src/app/[locale]/(protected)/items-management-test/ +├── page.tsx # 테스트 페이지 메인 +├── components/ # 테스트 페이지 전용 컴포넌트 +│ ├── HierarchyTab.tsx # 계층구조 탭 ✅ +│ ├── DraggableSection.tsx # 드래그 섹션 ✅ +│ ├── DraggableField.tsx # 드래그 필드 ✅ +│ ├── InlineEdit.tsx # 인라인 편집 컴포넌트 ✅ +│ ├── SectionsTab.tsx # 섹션 탭 ✅ (복제 기능 추가) +│ ├── FieldsTab.tsx # 항목 탭 ✅ +│ ├── PropertiesTab.tsx # 속성 탭 ✅ +│ └── dialogs/ # 다이얼로그 컴포넌트 ✅ +│ ├── index.ts # 인덱스 ✅ +│ ├── DeleteConfirmDialog.tsx # 삭제 확인 ✅ +│ ├── PageDialog.tsx # 페이지 다이얼로그 ✅ +│ ├── SectionDialog.tsx # 섹션 다이얼로그 ✅ +│ ├── FieldDialog.tsx # 필드 다이얼로그 ✅ +│ ├── BOMDialog.tsx # BOM 다이얼로그 ✅ +│ ├── PropertyDialog.tsx # 속성 다이얼로그 ✅ +│ ├── ImportSectionDialog.tsx # 섹션 불러오기 ✅ +│ └── ImportFieldDialog.tsx # 필드 불러오기 ✅ +``` + +--- + +## 핵심 파일 위치 + +| 파일 | 용도 | +|-----|------| +| `claudedocs/architecture/[DESIGN-2025-12-20] item-master-zustand-refactoring.md` | 📋 설계 문서 | +| `src/stores/item-master/useItemMasterStore.ts` | 🏪 Zustand 스토어 | +| `src/stores/item-master/types.ts` | 📝 타입 정의 | +| `src/stores/item-master/normalizers.ts` | 🔄 API 응답 정규화 | +| `src/app/[locale]/(protected)/items-management-test/page.tsx` | 🧪 테스트 페이지 | +| `src/components/items/ItemMasterDataManagement.tsx` | 📚 기존 페이지 (참조용) | + +--- + +## 테스트 페이지 접속 + +``` +http://localhost:3000/ko/items-management-test +``` + +--- + +## 브랜치 정보 + +| 항목 | 값 | +|------|-----| +| 작업 브랜치 | `feature/item-master-zustand` | +| 기본 브랜치 | `master` (테스트 페이지 없음) | + +### 브랜치 작업 명령어 + +```bash +# 테스트 페이지 작업 시 +git checkout feature/item-master-zustand + +# master 최신 내용 반영 +git merge master + +# 테스트 완료 후 master에 합치기 +git checkout master +git merge feature/item-master-zustand +``` + +--- + +## 다음 세션 시작 명령 + +``` +누락된 기능 구현해줘 - 절대경로 수정부터 +``` + +또는 + +``` +테스트 페이지 실사용 테스트하고 버그 수정해줘 +``` + +--- + +## 남은 작업 + +### 🔴 누락된 핵심 기능 (100% 구현 위해 필요) +1. **절대경로(absolute_path) 수정** - PathEditDialog +2. **페이지 복제** - handleDuplicatePage +3. **필드 조건부 표시** - ConditionalDisplayUI + +### 🟡 추가 기능 +4. **칼럼 관리** - ColumnManageDialog +5. **섹션/필드 사용 현황 표시** + +### 🟢 마이그레이션 +6. **실사용 테스트**: 테스트 페이지에서 실제 데이터로 CRUD 테스트 +7. **버그 수정**: 발견되는 버그 즉시 수정 +8. **마이그레이션**: 테스트 완료 후 기존 페이지 대체 \ No newline at end of file diff --git a/claudedocs/item-master/[IMPL-2025-12-24] item-master-test-and-zustand.md b/claudedocs/item-master/[IMPL-2025-12-24] item-master-test-and-zustand.md new file mode 100644 index 00000000..d24f87dd --- /dev/null +++ b/claudedocs/item-master/[IMPL-2025-12-24] item-master-test-and-zustand.md @@ -0,0 +1,132 @@ +# 품목기준관리 테스트 및 Zustand 도입 체크리스트 + +> **브랜치**: `feature/item-master-zustand` +> **작성일**: 2025-12-24 +> **목표**: 훅 분리 완료 후 수동 테스트 → Zustand 도입 + +--- + +## 현재 상태 + +| 항목 | 상태 | +|------|------| +| 훅 분리 작업 | ✅ 완료 (2025-12-24) | +| 메인 컴포넌트 줄 수 | 1,799줄 → 971줄 (46% 감소) | +| 타입 에러 수정 | ✅ 완료 (55개 → 0개) | +| 무한 로딩 버그 수정 | ✅ 완료 | +| **Zustand 연동** | ✅ 완료 (2025-12-24) | +| 빌드 | ✅ 성공 | + +--- + +## Phase 1: 훅 분리 작업 ✅ + +### 완료된 훅 (11개) + +| # | 훅 이름 | 용도 | 상태 | +|---|--------|------|------| +| 1 | usePageManagement | 페이지 CRUD | ✅ 기존 | +| 2 | useSectionManagement | 섹션 CRUD | ✅ 기존 | +| 3 | useFieldManagement | 필드 CRUD | ✅ 기존 | +| 4 | useMasterFieldManagement | 마스터 필드 | ✅ 기존 | +| 5 | useTemplateManagement | 템플릿 관리 | ✅ 기존 | +| 6 | useAttributeManagement | 속성/옵션 관리 | ✅ 기존 | +| 7 | useTabManagement | 탭 관리 | ✅ 기존 | +| 8 | useInitialDataLoading | 초기 데이터 로딩 | ✅ 신규 | +| 9 | useImportManagement | 섹션/필드 불러오기 | ✅ 신규 | +| 10 | useReorderManagement | 순서 변경 | ✅ 신규 | +| 11 | useDeleteManagement | 삭제 관리 | ✅ 신규 | + +### UI 컴포넌트 분리 (1개) + +| # | 컴포넌트 | 용도 | 상태 | +|---|---------|------|------| +| 1 | AttributeTabContent | 속성 탭 (~500줄) | ✅ 완료 | + +--- + +## Phase 2: Zustand 연동 ✅ + +### 2.1 구조 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ useInitialDataLoading │ +│ ┌─────────────────────┐ ┌─────────────────────────────┐│ +│ │ Context 로드 │ AND │ Zustand Store 로드 ││ +│ │ (기존 호환성 유지) │ │ (정규화된 상태) ││ +│ └─────────────────────┘ └─────────────────────────────┘│ +│ ↓ ↓ │ +│ 기존 컴포넌트 → Context 새 컴포넌트 → useItemMasterStore│ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.2 연동 방식: 병행 운영 + +- **Context**: 기존 컴포넌트 호환성 유지 +- **Zustand**: 새 컴포넌트에서 직접 사용 가능 +- **점진적 마이그레이션**: Context → Zustand로 단계적 전환 + +### 2.3 수정된 파일 + +| 파일 | 변경 내용 | +|------|----------| +| `useInitialDataLoading.ts` | `useItemMasterStore` import, `initFromApi()` 호출 | + +### 2.4 Zustand Store 기능 + +`src/stores/item-master/useItemMasterStore.ts` (1,139줄) + +| 영역 | 기능 | +|------|------| +| 페이지 | loadPages, createPage, updatePage, deletePage | +| 섹션 | loadSections, createSection, updateSection, deleteSection, reorderSections | +| 필드 | loadFields, createField, updateField, deleteField, reorderFields | +| BOM | loadBomItems, createBomItem, updateBomItem, deleteBomItem | +| 속성 | addUnit, updateUnit, deleteUnit, addMaterial, updateMaterial, deleteMaterial | +| API | initFromApi() - API 호출 후 정규화된 상태 저장 | + +--- + +## Phase 3: 테스트 (다음 단계) + +### 3.1 품목 유형별 등록 테스트 + +| # | 품목 유형 | 테스트 URL | 상태 | +|---|----------|-----------|------| +| 1 | FG (제품) | `/ko/items/create` → 제품 선택 | ⬜ | +| 2 | PT (부품) - 절곡 | `/ko/items/create` → 부품 → 절곡부품 | ⬜ | +| 3 | PT (부품) - 조립 | `/ko/items/create` → 부품 → 조립부품 | ⬜ | +| 4 | PT (부품) - 구매 | `/ko/items/create` → 부품 → 구매부품 | ⬜ | +| 5 | SM (부자재) | `/ko/items/create` → 부자재 선택 | ⬜ | +| 6 | RM (원자재) | `/ko/items/create` → 원자재 선택 | ⬜ | +| 7 | CS (소모품) | `/ko/items/create` → 소모품 선택 | ⬜ | + +--- + +## 작업 로그 + +| 날짜 | 작업 내용 | 커밋 | +|------|----------|------| +| 2025-12-24 | Phase 1+2 훅/컴포넌트 분리 | `a823ae0` | +| 2025-12-24 | unused 코드 정리 및 import 최적화 | `1664599` | +| 2025-12-24 | 타입 에러 및 무한 로딩 버그 수정 | `028932d` | +| 2025-12-24 | **Zustand 연동 완료** | (현재) | + +--- + +## 다음 단계 + +1. 수동 테스트 진행 +2. 새 컴포넌트에서 `useItemMasterStore` 직접 사용 +3. Context 의존성 점진적 제거 +4. 동적 페이지 생성 구현 + +--- + +## 참고 문서 + +- `[PLAN-2025-12-24] hook-extraction-plan.md` - 훅 분리 계획서 +- `src/stores/item-master/useItemMasterStore.ts` - Zustand Store +- `src/stores/item-master/types.ts` - Store 타입 정의 +- `src/stores/item-master/normalizers.ts` - API 응답 정규화 diff --git a/claudedocs/item-master/[NEXT-2025-12-24] item-master-refactoring-session.md b/claudedocs/item-master/[NEXT-2025-12-24] item-master-refactoring-session.md new file mode 100644 index 00000000..32d16fe9 --- /dev/null +++ b/claudedocs/item-master/[NEXT-2025-12-24] item-master-refactoring-session.md @@ -0,0 +1,134 @@ +# 품목기준관리 리팩토링 세션 컨텍스트 + +> **브랜치**: `feature/item-master-zustand` +> **날짜**: 2025-12-24 +> **상태**: Phase 2 완료, 커밋 대기 + +--- + +## 세션 요약 (12차 세션) + +### 완료된 작업 +- [x] 브랜치 상태 확인 (`feature/item-master-zustand`) +- [x] 기존 작업 혼동 정리 (품목관리 CRUD vs 품목기준관리 설정) +- [x] 작업 대상 파일 확인 (`ItemMasterDataManagement.tsx` - 1,799줄) +- [x] 기존 훅 분리 상태 파악 (7개 훅 이미 존재) +- [x] `ItemMasterDataManagement.tsx` 상세 분석 완료 +- [x] 훅 분리 계획서 작성 (`[PLAN-2025-12-24] hook-extraction-plan.md`) +- [x] **Phase 1: 신규 훅 4개 생성** + - `useInitialDataLoading.ts` - 초기 데이터 로딩 (~130줄) + - `useImportManagement.ts` - 섹션/필드 Import (~100줄) + - `useReorderManagement.ts` - 드래그앤드롭 순서 변경 (~80줄) + - `useDeleteManagement.ts` - 삭제/언링크 핸들러 (~100줄) +- [x] **Phase 2: UI 컴포넌트 2개 생성** + - `AttributeTabContent.tsx` - 속성 탭 콘텐츠 (~340줄) + - `ItemMasterDialogs.tsx` - 다이얼로그 통합 (~540줄) +- [x] 빌드 테스트 통과 + +### 현재 상태 +- **메인 컴포넌트**: 1,799줄 → ~1,478줄 (약 320줄 감소) +- **신규 훅**: 4개 생성 및 통합 +- **신규 UI 컴포넌트**: 2개 생성 (향후 추가 통합 가능) +- **빌드**: 통과 + +### 다음 TODO (커밋 후) +1. Git 커밋 (Phase 1, 2 변경사항) +2. Phase 3: 추가 코드 정리 (선택적) + - 속성 탭 내용을 `AttributeTabContent`로 완전 대체 (추가 ~500줄 감소 가능) + - 다이얼로그들을 `ItemMasterDialogs`로 완전 대체 +3. Zustand 도입 (3방향 동기화 문제 해결) + +--- + +## 핵심 정보 + +### 페이지 구분 (중요!) + +| 페이지 | URL | 컴포넌트 | 상태 | +|--------|-----|----------|------| +| 품목관리 CRUD | `/items/` | `DynamicItemForm` | ✅ 훅 분리 완료 (master 적용됨) | +| **품목기준관리 설정** | `/master-data/item-master-data-management` | `ItemMasterDataManagement` | ⏳ **훅 분리 진행 중** | + +### 현재 파일 구조 + +``` +src/components/items/ItemMasterDataManagement/ +├── ItemMasterDataManagement.tsx ← ~1,478줄 (리팩토링 후) +├── hooks/ (11개 - 7개 기존 + 4개 신규) +│ ├── usePageManagement.ts +│ ├── useSectionManagement.ts +│ ├── useFieldManagement.ts +│ ├── useMasterFieldManagement.ts +│ ├── useTemplateManagement.ts +│ ├── useAttributeManagement.ts +│ ├── useTabManagement.ts +│ ├── useInitialDataLoading.ts ← NEW +│ ├── useImportManagement.ts ← NEW +│ ├── useReorderManagement.ts ← NEW +│ └── useDeleteManagement.ts ← NEW +├── components/ (5개 - 3개 기존 + 2개 신규) +│ ├── DraggableSection.tsx +│ ├── DraggableField.tsx +│ ├── ConditionalDisplayUI.tsx +│ ├── AttributeTabContent.tsx ← NEW +│ └── ItemMasterDialogs.tsx ← NEW +├── services/ (6개) +├── dialogs/ (13개) +├── tabs/ (4개) +└── utils/ (1개) +``` + +### 브랜치 상태 + +``` +master (원본 보존) + │ + └── feature/item-master-zustand (현재) + ├── Zustand 테스트 페이지 (/items-management-test/) - 놔둠 + ├── Zustand 스토어 (stores/item-master/) - 나중에 사용 + └── 기존 품목기준관리 페이지 - 훅 분리 진행 중 +``` + +### 작업 진행률 + +``` +시작: ItemMasterDataManagement.tsx 1,799줄 + ↓ Phase 1: 훅 분리 (4개 신규 훅) +현재: ~1,478줄 (-321줄, -18%) + ↓ Phase 2: UI 컴포넌트 분리 (2개 신규 컴포넌트 생성) + ↓ Phase 3: 추가 통합 (선택적) +목표: ~500줄 (메인 컴포넌트) + ↓ Zustand 적용 +최종: 3방향 동기화 문제 해결 +``` + +--- + +## 생성된 파일 목록 + +### 신규 훅 (Phase 1) +1. `hooks/useInitialDataLoading.ts` - 초기 데이터 로딩, 에러 처리 +2. `hooks/useImportManagement.ts` - 섹션/필드 Import 다이얼로그 상태 및 핸들러 +3. `hooks/useReorderManagement.ts` - 드래그앤드롭 순서 변경 +4. `hooks/useDeleteManagement.ts` - 삭제, 언링크, 초기화 핸들러 + +### 신규 UI 컴포넌트 (Phase 2) +1. `components/AttributeTabContent.tsx` - 속성 탭 전체 UI +2. `components/ItemMasterDialogs.tsx` - 모든 다이얼로그 통합 렌더링 + +--- + +## 참고 문서 + +- `[PLAN-2025-12-24] hook-extraction-plan.md` - 훅 분리 계획서 (상세) +- `[DESIGN-2025-12-20] item-master-zustand-refactoring.md` - Zustand 설계서 +- `[IMPL-2025-12-24] item-master-test-and-zustand.md` - 테스트 체크리스트 + +--- + +## 다음 세션 시작 명령 + +``` +품목기준관리 설정 페이지(ItemMasterDataManagement.tsx) 추가 리팩토링 또는 Zustand 도입 진행해줘. +[NEXT-2025-12-24] item-master-refactoring-session.md 문서 확인하고 시작해. +``` diff --git a/claudedocs/item-master/[PLAN-2025-12-24] hook-extraction-plan.md b/claudedocs/item-master/[PLAN-2025-12-24] hook-extraction-plan.md new file mode 100644 index 00000000..b4a6bcc1 --- /dev/null +++ b/claudedocs/item-master/[PLAN-2025-12-24] hook-extraction-plan.md @@ -0,0 +1,270 @@ +# ItemMasterDataManagement 훅 분리 계획서 + +> **날짜**: 2025-12-24 +> **대상 파일**: `src/components/items/ItemMasterDataManagement.tsx` (1,799줄) +> **목표**: ~500줄로 축소 + +--- + +## 현재 구조 분석 + +### 파일 구성 + +| 구간 | 줄 수 | 내용 | +|------|-------|------| +| Import | 1-61 | React, UI, 다이얼로그, 훅 import | +| 상수 | 63-91 | ITEM_TYPE_OPTIONS, INPUT_TYPE_OPTIONS | +| Context 구조분해 | 94-124 | useItemMaster에서 20+개 함수/상태 | +| 훅 초기화 | 127-286 | 7개 훅 + 150+개 상태 구조분해 | +| useMemo | 298-372 | sectionsAsTemplates 변환 | +| useState/useEffect | 374-504 | 로딩, 에러, 모바일, Import 상태 | +| 핸들러 | 519-743 | Import, Clone, Delete, Reorder | +| UI 렌더링 | 746-1799 | Tabs + 13개 다이얼로그 | + +### 기존 훅 (7개) + +``` +src/components/items/ItemMasterDataManagement/hooks/ +├── usePageManagement.ts - 페이지 CRUD +├── useSectionManagement.ts - 섹션 CRUD +├── useFieldManagement.ts - 필드 CRUD +├── useMasterFieldManagement.ts - 마스터 필드 CRUD +├── useTemplateManagement.ts - 템플릿 관리 +├── useAttributeManagement.ts - 속성 관리 +└── useTabManagement.ts - 탭 관리 +``` + +--- + +## 분리 계획 + +### Phase 1: 신규 훅 생성 (4개) + +#### 1. `useInitialDataLoading` (~100줄 분리) + +**분리 대상:** +- 초기 데이터 로딩 useEffect (387-492줄) +- 로딩/에러 상태 (isInitialLoading, error) +- transformers 호출 로직 + +**반환값:** +```typescript +{ + isInitialLoading: boolean; + error: string | null; + reload: () => Promise; +} +``` + +#### 2. `useImportManagement` (~80줄 분리) + +**분리 대상:** +- Import 다이얼로그 상태 (512-516줄) +- handleImportSection (519-530줄) +- handleImportField (540-559줄) +- handleCloneSection (562-570줄) + +**반환값:** +```typescript +{ + // 상태 + isImportSectionDialogOpen, setIsImportSectionDialogOpen, + isImportFieldDialogOpen, setIsImportFieldDialogOpen, + selectedImportSectionId, setSelectedImportSectionId, + selectedImportFieldId, setSelectedImportFieldId, + importFieldTargetSectionId, setImportFieldTargetSectionId, + // 핸들러 + handleImportSection, + handleImportField, + handleCloneSection, +} +``` + +#### 3. `useReorderManagement` (~60줄 분리) + +**분리 대상:** +- moveSection (650-668줄) +- moveField (672-702줄) + +**반환값:** +```typescript +{ + moveSection: (dragIndex: number, hoverIndex: number) => Promise; + moveField: (sectionId: number, dragFieldId: number, hoverFieldId: number) => Promise; +} +``` + +#### 4. `useDeleteManagement` (~50줄 분리) + +**분리 대상:** +- handleDeletePageWithTracking (582-588줄) +- handleDeleteSectionWithTracking (591-597줄) +- handleUnlinkFieldWithTracking (601-609줄) +- handleResetAllData (705-743줄) + +**반환값:** +```typescript +{ + handleDeletePage: (pageId: number) => void; + handleDeleteSection: (pageId: number, sectionId: number) => void; + handleUnlinkField: (pageId: string, sectionId: string, fieldId: string) => Promise; + handleResetAllData: () => void; +} +``` + +### Phase 2: UI 컴포넌트 분리 (2개) + +#### 1. `AttributeTabContent` (~400줄 분리) + +**분리 대상:** +- 속성 탭 내용 (807-1331줄) +- 단위/재질/표면처리 반복 UI 통합 + +**Props:** +```typescript +interface AttributeTabContentProps { + activeAttributeTab: string; + attributeSubTabs: AttributeSubTab[]; + unitOptions: UnitOption[]; + materialOptions: MaterialOption[]; + surfaceTreatmentOptions: SurfaceTreatmentOption[]; + customAttributeOptions: Record; + attributeColumns: Record; + itemMasterFields: ItemMasterField[]; + // 핸들러들... +} +``` + +#### 2. `ItemMasterDialogs` (~280줄 분리) + +**분리 대상:** +- 13개 다이얼로그 렌더링 (1442-1797줄) + +**Props:** +```typescript +interface ItemMasterDialogsProps { + // 모든 다이얼로그 관련 props +} +``` + +### Phase 3: 코드 정리 + +1. **래퍼 함수 제거** (~30줄) + - `handleAddSectionWrapper` 등을 훅 내부로 이동 + - `selectedPage`를 훅 파라미터로 전달 + +2. **unused 변수 정리** + - `_mounted`, `_isLoading` 등 제거 + +3. **Import 최적화** + - 사용하지 않는 import 제거 + +--- + +## 예상 결과 + +### 줄 수 변화 + +| 항목 | 현재 | 분리 후 | +|------|------|---------| +| Import | 61 | 40 | +| 상수 | 28 | 28 | +| 훅 사용 | 160 | 60 | +| useMemo | 75 | 75 | +| 상태/Effect | 130 | 20 | +| 핸들러 | 225 | 30 | +| UI 렌더링 | 1,053 | 300 | +| **합계** | **1,799** | **~550** | + +### 새 파일 구조 + +``` +src/components/items/ItemMasterDataManagement/ +├── ItemMasterDataManagement.tsx ← ~550줄 (메인) +├── hooks/ +│ ├── index.ts +│ ├── usePageManagement.ts (기존) +│ ├── useSectionManagement.ts (기존) +│ ├── useFieldManagement.ts (기존) +│ ├── useMasterFieldManagement.ts (기존) +│ ├── useTemplateManagement.ts (기존) +│ ├── useAttributeManagement.ts (기존) +│ ├── useTabManagement.ts (기존) +│ ├── useInitialDataLoading.ts ← NEW +│ ├── useImportManagement.ts ← NEW +│ ├── useReorderManagement.ts ← NEW +│ └── useDeleteManagement.ts ← NEW +├── components/ +│ ├── AttributeTabContent.tsx ← NEW +│ └── ItemMasterDialogs.tsx ← NEW +├── dialogs/ (기존 13개) +├── tabs/ (기존 4개) +├── services/ (기존 6개) +└── utils/ (기존 1개) +``` + +--- + +## 작업 순서 + +### Step 1: useInitialDataLoading 훅 생성 +- [ ] 훅 파일 생성 +- [ ] 로딩/에러 상태 이동 +- [ ] useEffect 이동 +- [ ] 메인 컴포넌트에서 사용 + +### Step 2: useImportManagement 훅 생성 +- [ ] 훅 파일 생성 +- [ ] Import 상태 이동 +- [ ] 핸들러 이동 +- [ ] 메인 컴포넌트에서 사용 + +### Step 3: useReorderManagement 훅 생성 +- [ ] 훅 파일 생성 +- [ ] moveSection, moveField 이동 +- [ ] 메인 컴포넌트에서 사용 + +### Step 4: useDeleteManagement 훅 생성 +- [ ] 훅 파일 생성 +- [ ] Delete/Unlink 핸들러 이동 +- [ ] 메인 컴포넌트에서 사용 + +### Step 5: AttributeTabContent 컴포넌트 분리 +- [ ] 컴포넌트 파일 생성 +- [ ] 속성 탭 UI 이동 +- [ ] 반복 코드 통합 + +### Step 6: ItemMasterDialogs 컴포넌트 분리 +- [ ] 컴포넌트 파일 생성 +- [ ] 13개 다이얼로그 이동 + +### Step 7: 정리 및 테스트 +- [ ] 래퍼 함수 정리 +- [ ] unused 코드 제거 +- [ ] 빌드 확인 +- [ ] 수동 테스트 + +--- + +## 리스크 및 주의사항 + +1. **Context 의존성**: 훅들이 `useItemMaster` Context에 의존 + - 해결: 필요한 함수만 훅 파라미터로 전달 + +2. **상태 공유**: 여러 훅에서 동일 상태 사용 + - 해결: 공통 상태는 메인 컴포넌트에서 관리 + +3. **타입 호환성**: 기존 훅의 setter 타입 문제 + - 해결: `as any` 임시 사용 또는 타입 수정 + +4. **테스트**: 페이지 기능이 많아 수동 테스트 필요 + - 해결: 체크리스트 작성하여 순차 테스트 + +--- + +## 다음 단계 + +1. 이 계획서 확인 후 작업 시작 +2. Step 1부터 순차 진행 +3. 각 Step 완료 후 빌드 확인 +4. 최종 수동 테스트 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 04168ed2..bb423423 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", + "immer": "^11.0.1", "lucide-react": "^0.552.0", "next": "^15.5.9", "next-intl": "^4.4.0", @@ -52,7 +53,7 @@ "tailwind-merge": "^3.3.1", "vaul": "^1.1.2", "zod": "^4.1.12", - "zustand": "^5.0.8" + "zustand": "^5.0.9" }, "devDependencies": { "@playwright/test": "^1.57.0", @@ -3443,6 +3444,16 @@ } } }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@remirror/core-constants": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", @@ -6912,9 +6923,9 @@ } }, "node_modules/immer": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", - "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.0.1.tgz", + "integrity": "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==", "license": "MIT", "funding": { "type": "opencollective", @@ -8887,6 +8898,16 @@ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/recharts/node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -10104,9 +10125,9 @@ } }, "node_modules/zustand": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", - "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", + "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==", "license": "MIT", "engines": { "node": ">=12.20.0" diff --git a/package.json b/package.json index 4038c0c6..2d225644 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", + "immer": "^11.0.1", "lucide-react": "^0.552.0", "next": "^15.5.9", "next-intl": "^4.4.0", @@ -56,7 +57,7 @@ "tailwind-merge": "^3.3.1", "vaul": "^1.1.2", "zod": "^4.1.12", - "zustand": "^5.0.8" + "zustand": "^5.0.9" }, "devDependencies": { "@playwright/test": "^1.57.0", diff --git a/src/components/items/DynamicItemForm/hooks/useFormStructure.ts b/src/components/items/DynamicItemForm/hooks/useFormStructure.ts index a25f3ba4..47d3c662 100644 --- a/src/components/items/DynamicItemForm/hooks/useFormStructure.ts +++ b/src/components/items/DynamicItemForm/hooks/useFormStructure.ts @@ -36,8 +36,8 @@ export function useFormStructure( // 단위 옵션 저장 (SimpleUnitOption 형식으로 변환) console.log('[useFormStructure] API initData.unitOptions:', initData.unitOptions); const simpleUnitOptions: SimpleUnitOption[] = (initData.unitOptions || []).map((u) => ({ - label: u.label, - value: u.value, + label: u.unit_name, + value: u.unit_code, })); console.log('[useFormStructure] Processed unitOptions:', simpleUnitOptions.length, 'items'); setUnitOptions(simpleUnitOptions); diff --git a/src/components/items/ItemMasterDataManagement.tsx b/src/components/items/ItemMasterDataManagement.tsx index eb869cf3..06dc106e 100644 --- a/src/components/items/ItemMasterDataManagement.tsx +++ b/src/components/items/ItemMasterDataManagement.tsx @@ -6,48 +6,34 @@ import { PageHeader } from '@/components/organisms/PageHeader'; import { useItemMaster } from '@/contexts/ItemMasterContext'; import type { SectionTemplate, BOMItem, TemplateField } from '@/contexts/ItemMasterContext'; import { MasterFieldTab, HierarchyTab, SectionsTab } from './ItemMasterDataManagement/tabs'; -import { FieldDialog } from './ItemMasterDataManagement/dialogs/FieldDialog'; -// ConditionalFieldConfig type removed - not currently used -import { FieldDrawer } from './ItemMasterDataManagement/dialogs/FieldDrawer'; +import { LoadingSpinner } from '@/components/ui/loading-spinner'; +import { ServerErrorPage } from '@/components/common/ServerErrorPage'; +// 2025-12-24: Phase 2 UI 컴포넌트 분리 +import { AttributeTabContent } from './ItemMasterDataManagement/components'; +import { + Database, + FileText, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; + +// 다이얼로그 컴포넌트 import import { TabManagementDialogs } from './ItemMasterDataManagement/dialogs/TabManagementDialogs'; import { OptionDialog } from './ItemMasterDataManagement/dialogs/OptionDialog'; import { ColumnManageDialog } from './ItemMasterDataManagement/dialogs/ColumnManageDialog'; import { PathEditDialog } from './ItemMasterDataManagement/dialogs/PathEditDialog'; import { PageDialog } from './ItemMasterDataManagement/dialogs/PageDialog'; import { SectionDialog } from './ItemMasterDataManagement/dialogs/SectionDialog'; +import { FieldDialog } from './ItemMasterDataManagement/dialogs/FieldDialog'; +import { FieldDrawer } from './ItemMasterDataManagement/dialogs/FieldDrawer'; +import { ColumnDialog } from './ItemMasterDataManagement/dialogs/ColumnDialog'; import { MasterFieldDialog } from './ItemMasterDataManagement/dialogs/MasterFieldDialog'; +import { SectionTemplateDialog } from './ItemMasterDataManagement/dialogs/SectionTemplateDialog'; import { TemplateFieldDialog } from './ItemMasterDataManagement/dialogs/TemplateFieldDialog'; import { LoadTemplateDialog } from './ItemMasterDataManagement/dialogs/LoadTemplateDialog'; -import { ColumnDialog } from './ItemMasterDataManagement/dialogs/ColumnDialog'; -import { SectionTemplateDialog } from './ItemMasterDataManagement/dialogs/SectionTemplateDialog'; import { ImportSectionDialog } from './ItemMasterDataManagement/dialogs/ImportSectionDialog'; import { ImportFieldDialog } from './ItemMasterDataManagement/dialogs/ImportFieldDialog'; -import { itemMasterApi } from '@/lib/api/item-master'; -import { getErrorMessage, ApiError } from '@/lib/api/error-handler'; -import { LoadingSpinner } from '@/components/ui/loading-spinner'; -import { ServerErrorPage } from '@/components/common/ServerErrorPage'; -import { - transformPagesResponse, - transformSectionsResponse, - transformSectionTemplatesResponse, - transformFieldsResponse, - transformCustomTabsResponse, - transformUnitOptionsResponse, - transformSectionTemplateFromSection, -} from '@/lib/api/transformers'; -import { - Database, - Plus, - Trash2, - FileText, - Settings, - Package, -} from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Badge } from '@/components/ui/badge'; -import { toast } from 'sonner'; // 커스텀 훅 import import { @@ -58,8 +44,16 @@ import { useTemplateManagement, useAttributeManagement, useTabManagement, + // 2025-12-24: 신규 훅 추가 + useInitialDataLoading, + useImportManagement, + useReorderManagement, + useDeleteManagement, } from './ItemMasterDataManagement/hooks'; +// 에러 알림 Context +import { ErrorAlertProvider } from './ItemMasterDataManagement/contexts'; + const ITEM_TYPE_OPTIONS = [ { value: 'FG', label: '제품 (FG)' }, { value: 'PT', label: '부품 (PT)' }, @@ -77,46 +71,43 @@ const INPUT_TYPE_OPTIONS = [ { value: 'textarea', label: '텍스트영역' } ]; -// 입력 타입 라벨 변환 헬퍼 함수 (중복 코드 제거) -const getInputTypeLabel = (inputType: string | undefined): string => { - const labels: Record = { - textbox: '텍스트박스', - number: '숫자', - dropdown: '드롭다운', - checkbox: '체크박스', - date: '날짜', - textarea: '텍스트영역', - }; - return labels[inputType || ''] || '텍스트박스'; -}; - +// Wrapper 컴포넌트: ErrorAlertProvider를 먼저 제공 export function ItemMasterDataManagement() { + return ( + + + + ); +} + +// 실제 로직을 담는 내부 컴포넌트 +function ItemMasterDataManagementContent() { const { itemPages, - loadItemPages, + loadItemPages: _loadItemPages, updateItemPage, - deleteItemPage, + deleteItemPage: _deleteItemPage, updateSection, - deleteSection, - reorderFields, + deleteSection: _deleteSection, + reorderFields: _reorderFields, itemMasterFields, - loadItemMasterFields, + loadItemMasterFields: _loadItemMasterFields, sectionTemplates, - loadSectionTemplates, - resetAllData, + loadSectionTemplates: _loadSectionTemplates, + resetAllData: _resetAllData, // 2025-11-26 추가: 독립 엔티티 관리 independentSections, - loadIndependentSections, - loadIndependentFields, + loadIndependentSections: _loadIndependentSections, + loadIndependentFields: _loadIndependentFields, refreshIndependentSections, refreshIndependentFields, - linkSectionToPage, - linkFieldToSection, - unlinkFieldFromSection, + linkSectionToPage: _linkSectionToPage, + linkFieldToSection: _linkFieldToSection, + unlinkFieldFromSection: _unlinkFieldFromSection, getSectionUsage, getFieldUsage, - cloneSection, - reorderSections, + cloneSection: _cloneSection, + reorderSections: _reorderSections, // 2025-11-27 추가: BOM 항목 API 함수 addBOMItem, updateBOMItem, @@ -133,6 +124,85 @@ export function ItemMasterDataManagement() { const attributeManagement = useAttributeManagement(); const tabManagement = useTabManagement(); + // 2025-12-24: 신규 훅 (먼저 tabManagement, attributeManagement에서 setter 추출 필요) + const { + customTabs, setCustomTabs, + activeTab, setActiveTab, + attributeSubTabs, setAttributeSubTabs, + activeAttributeTab, setActiveAttributeTab, + isAddTabDialogOpen, setIsAddTabDialogOpen, + isManageTabsDialogOpen, setIsManageTabsDialogOpen, + newTabLabel, setNewTabLabel, + editingTabId, setEditingTabId, + deletingTabId, setDeletingTabId, + isDeleteTabDialogOpen, setIsDeleteTabDialogOpen, + isManageAttributeTabsDialogOpen, setIsManageAttributeTabsDialogOpen, + isAddAttributeTabDialogOpen, setIsAddAttributeTabDialogOpen, + newAttributeTabLabel, setNewAttributeTabLabel, + editingAttributeTabId, setEditingAttributeTabId, + deletingAttributeTabId, setDeletingAttributeTabId, + isDeleteAttributeTabDialogOpen, setIsDeleteAttributeTabDialogOpen, + handleAddTab, handleUpdateTab, handleDeleteTab, confirmDeleteTab, + handleAddAttributeTab, handleUpdateAttributeTab, + handleDeleteAttributeTab, confirmDeleteAttributeTab, + moveTabUp, moveTabDown, + moveAttributeTabUp, moveAttributeTabDown, + getTabIcon, handleEditTabFromManage, + } = tabManagement; + + const { + unitOptions, setUnitOptions, + materialOptions, setMaterialOptions, + surfaceTreatmentOptions, setSurfaceTreatmentOptions, + customAttributeOptions, setCustomAttributeOptions, + isOptionDialogOpen, setIsOptionDialogOpen, + editingOptionType, setEditingOptionType, + newOptionValue, setNewOptionValue, + newOptionLabel, setNewOptionLabel, + newOptionColumnValues, setNewOptionColumnValues, + newOptionInputType, setNewOptionInputType, + newOptionRequired, setNewOptionRequired, + newOptionOptions, setNewOptionOptions, + newOptionPlaceholder, setNewOptionPlaceholder, + newOptionDefaultValue, setNewOptionDefaultValue, + isColumnManageDialogOpen, setIsColumnManageDialogOpen, + managingColumnType, setManagingColumnType, + attributeColumns, setAttributeColumns, + newColumnName, setNewColumnName, + newColumnKey, setNewColumnKey, + newColumnType, setNewColumnType, + newColumnRequired, setNewColumnRequired, + handleAddOption, handleDeleteOption, + } = attributeManagement; + + // 2025-12-24: 신규 훅 초기화 + const { isInitialLoading, error } = useInitialDataLoading({ + setCustomTabs, + setUnitOptions, + }); + + const importManagement = useImportManagement(); + const { + isImportSectionDialogOpen, setIsImportSectionDialogOpen, + isImportFieldDialogOpen, setIsImportFieldDialogOpen, + selectedImportSectionId, setSelectedImportSectionId, + selectedImportFieldId, setSelectedImportFieldId, + importFieldTargetSectionId, setImportFieldTargetSectionId, + handleImportSection: handleImportSectionFromHook, + handleImportField: handleImportFieldFromHook, + handleCloneSection, + } = importManagement; + + const reorderManagement = useReorderManagement(); + const { moveSection: moveSectionFromHook, moveField: moveFieldFromHook } = reorderManagement; + + const deleteManagement = useDeleteManagement({ itemPages }); + const { + handleDeletePage: handleDeletePageWithTracking, + handleUnlinkField: handleUnlinkFieldWithTracking, + handleResetAllData: handleResetAllDataFromHook, + } = deleteManagement; + // 훅에서 필요한 값들 구조분해 const { selectedPageId, setSelectedPageId, selectedPage, @@ -211,7 +281,7 @@ export function ItemMasterDataManagement() { isLoadTemplateDialogOpen, setIsLoadTemplateDialogOpen, selectedTemplateId, setSelectedTemplateId, isTemplateFieldDialogOpen, setIsTemplateFieldDialogOpen, - currentTemplateId: _currentTemplateId, setCurrentTemplateId, + setCurrentTemplateId, editingTemplateFieldId, setEditingTemplateFieldId, templateFieldName, setTemplateFieldName, templateFieldKey, setTemplateFieldKey, @@ -233,65 +303,6 @@ export function ItemMasterDataManagement() { handleDeleteBOMItemFromTemplate, } = templateManagement; - const { - unitOptions, setUnitOptions, - materialOptions, setMaterialOptions, - surfaceTreatmentOptions, setSurfaceTreatmentOptions, - customAttributeOptions, setCustomAttributeOptions, - isOptionDialogOpen, setIsOptionDialogOpen, - editingOptionType, setEditingOptionType, - newOptionValue, setNewOptionValue, - newOptionLabel, setNewOptionLabel, - newOptionColumnValues, setNewOptionColumnValues, - newOptionInputType, setNewOptionInputType, - newOptionRequired, setNewOptionRequired, - newOptionOptions, setNewOptionOptions, - newOptionPlaceholder, setNewOptionPlaceholder, - newOptionDefaultValue, setNewOptionDefaultValue, - isColumnManageDialogOpen, setIsColumnManageDialogOpen, - managingColumnType, setManagingColumnType, - attributeColumns, setAttributeColumns, - newColumnName, setNewColumnName, - newColumnKey, setNewColumnKey, - newColumnType, setNewColumnType, - newColumnRequired, setNewColumnRequired, - handleAddOption, handleDeleteOption, - handleAddColumn: _handleAddColumn, handleDeleteColumn: _handleDeleteColumn, - } = attributeManagement; - - const { - customTabs, setCustomTabs, - activeTab, setActiveTab, - attributeSubTabs, setAttributeSubTabs, - activeAttributeTab, setActiveAttributeTab, - isAddTabDialogOpen, setIsAddTabDialogOpen, - isManageTabsDialogOpen, setIsManageTabsDialogOpen, - newTabLabel, setNewTabLabel, - editingTabId, setEditingTabId, - deletingTabId, setDeletingTabId, - isDeleteTabDialogOpen, setIsDeleteTabDialogOpen, - isManageAttributeTabsDialogOpen, setIsManageAttributeTabsDialogOpen, - isAddAttributeTabDialogOpen, setIsAddAttributeTabDialogOpen, - newAttributeTabLabel, setNewAttributeTabLabel, - editingAttributeTabId, setEditingAttributeTabId, - deletingAttributeTabId, setDeletingAttributeTabId, - isDeleteAttributeTabDialogOpen, setIsDeleteAttributeTabDialogOpen, - handleAddTab, handleUpdateTab, handleDeleteTab, confirmDeleteTab, - handleAddAttributeTab, handleUpdateAttributeTab, - handleDeleteAttributeTab, confirmDeleteAttributeTab, - moveTabUp, moveTabDown, - moveAttributeTabUp, moveAttributeTabDown, - getTabIcon, handleEditTabFromManage, - } = tabManagement; - - // 모든 페이지의 섹션을 하나의 배열로 평탄화 - const _itemSections = itemPages.flatMap(page => - page.sections.map(section => ({ - ...section, - parentPageId: page.id - })) - ); - // 2025-11-26: itemPages의 모든 섹션 + 독립 섹션(independentSections)을 SectionTemplate 형식으로 변환 // 이렇게 하면 계층구조 탭과 섹션 탭이 같은 데이터 소스를 사용하여 자동 동기화됨 // 독립 섹션: 페이지에서 연결 해제된 섹션 (page_id = null) @@ -371,129 +382,6 @@ export function ItemMasterDataManagement() { return uniqueSections; }, [itemPages, independentSections]); - // 마운트 상태 추적 (SSR 호환) - const [_mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); - - // API 로딩 및 에러 상태 관리 - const [isInitialLoading, setIsInitialLoading] = useState(true); // 초기 데이터 로딩 - const [_isLoading, _setIsLoading] = useState(false); // 개별 작업 로딩 - const [error, setError] = useState(null); // 에러 메시지 - - // 초기 데이터 로딩 - useEffect(() => { - const loadInitialData = async () => { - try { - setIsInitialLoading(true); - setError(null); - - const data = await itemMasterApi.init(); - - // 2025-11-26: 백엔드가 entity_relationships 기반으로 변경됨 - // - pages[].sections: entity_relationships 기반으로 연결된 섹션 (이미 포함) - // - sections: 모든 독립 섹션 (재사용 가능 목록) - // - sectionTemplates: 삭제됨 → sections로 대체 - - // 1. 페이지 데이터 로드 (섹션이 이미 포함되어 있음) - const transformedPages = transformPagesResponse(data.pages); - loadItemPages(transformedPages); - - // 2. 독립 섹션 로드 (모든 재사용 가능 섹션) - // 백엔드가 sections 배열로 모든 독립 섹션을 반환 - if (data.sections && data.sections.length > 0) { - const transformedSections = transformSectionsResponse(data.sections); - loadIndependentSections(transformedSections); - console.log('✅ 독립 섹션 로드:', transformedSections.length); - } - - // 3. 섹션 템플릿 로드 (sectionTemplates → sections로 통합됨) - // 기존 sectionTemplates가 있으면 호환성 유지, 없으면 sections 사용 - if (data.sectionTemplates && data.sectionTemplates.length > 0) { - const transformedTemplates = transformSectionTemplatesResponse(data.sectionTemplates); - loadSectionTemplates(transformedTemplates); - } else if (data.sections && data.sections.length > 0) { - // sectionTemplates가 없으면 sections에서 is_template=true인 것만 사용 - const templates = data.sections - .filter((s: { is_template?: boolean }) => s.is_template) - .map(transformSectionTemplateFromSection); - if (templates.length > 0) { - loadSectionTemplates(templates); - } - } - - // 필드 로드 (2025-11-27: masterFields가 fields로 통합됨) - // data.fields = 모든 필드 목록 (백엔드 init API에서 반환) - if (data.fields && data.fields.length > 0) { - const transformedFields = transformFieldsResponse(data.fields); - - // 2025-11-27: section_id가 null인 필드만 필터링 (독립 필드) - const independentOnlyFields = transformedFields.filter( - f => f.section_id === null || f.section_id === undefined - ); - - // 2025-11-27: 항목탭용 (itemMasterFields) - 모든 필드 로드 - // 계층구조에서 추가한 필드도 항목탭에 바로 표시되도록 함 - // addFieldToSection에서 setItemMasterFields를 호출하므로 일관성 유지 - loadItemMasterFields(transformedFields as any); - - // 독립 필드용 (independentFields) - section_id=null인 필드만 - loadIndependentFields(independentOnlyFields); - - console.log('✅ 필드 로드:', { - total: transformedFields.length, - independent: independentOnlyFields.length, - allFieldsForItemsTab: transformedFields.length, - }); - } - - // 커스텀 탭 로드 (local state) - 교체 방식 (복제 방지) - if (data.customTabs && data.customTabs.length > 0) { - const transformedTabs = transformCustomTabsResponse(data.customTabs); - setCustomTabs(transformedTabs); - } - - // 단위 옵션 로드 (local state) - if (data.unitOptions && data.unitOptions.length > 0) { - const transformedUnits = transformUnitOptionsResponse(data.unitOptions); - setUnitOptions(transformedUnits); - } - - console.log('✅ Initial data loaded:', { - pages: data.pages?.length || 0, - sections: data.sections?.length || 0, - fields: data.fields?.length || 0, - customTabs: data.customTabs?.length || 0, - unitOptions: data.unitOptions?.length || 0, - }); - - } catch (err) { - if (err instanceof ApiError && err.errors) { - // Validation 에러 (422) - const errorMessages = Object.entries(err.errors) - .map(([field, messages]) => `${field}: ${messages.join(', ')}`) - .join('\n'); - toast.error(errorMessages); - setError('입력값을 확인해주세요.'); - } else { - const errorMessage = getErrorMessage(err); - setError(errorMessage); - toast.error(errorMessage); - } - console.error('❌ Failed to load initial data:', err); - } finally { - setIsInitialLoading(false); - } - }; - - loadInitialData(); - }, []); - - // ===== 훅으로 이동된 상태들 (참조용 주석) ===== - // 탭, 속성, 페이지, 섹션 관련 상태는 위의 훅에서 관리됩니다. - // 모바일 체크 const [isMobile, setIsMobile] = useState(false); useEffect(() => { @@ -508,122 +396,21 @@ export function ItemMasterDataManagement() { // BOM 관리 상태 (훅에 없음) const [_bomItems, setBomItems] = useState([]); - // 2025-11-26 추가: 섹션/필드 불러오기 다이얼로그 상태 - const [isImportSectionDialogOpen, setIsImportSectionDialogOpen] = useState(false); - const [isImportFieldDialogOpen, setIsImportFieldDialogOpen] = useState(false); - const [selectedImportSectionId, setSelectedImportSectionId] = useState(null); - const [selectedImportFieldId, setSelectedImportFieldId] = useState(null); - const [importFieldTargetSectionId, setImportFieldTargetSectionId] = useState(null); - - // 2025-11-26 추가: 섹션 불러오기 핸들러 - const handleImportSection = async () => { - if (!selectedPageId || !selectedImportSectionId) return; - - try { - await linkSectionToPage(selectedPageId, selectedImportSectionId); - toast.success('섹션을 불러왔습니다.'); - setSelectedImportSectionId(null); - } catch (error) { - console.error('섹션 불러오기 실패:', error); - toast.error(getErrorMessage(error)); - } - }; - - /** - * 필드 불러오기 핸들러 - * - * @description 2025-11-27: API 변경으로 단순화 - * - 이전: source 파라미터로 'master' | 'independent' 구분 - * - 현재: 모든 필드가 item_fields로 통합 → linkFieldToSection만 사용 - * - section_id=NULL인 필드를 섹션에 연결하는 방식으로 통일 - */ - const handleImportField = async () => { - if (!importFieldTargetSectionId || !selectedImportFieldId) return; - - try { - // 2025-12-02: 섹션별 순서 종속 - 해당 섹션의 마지막 순서 + 1로 설정 - const targetSection = selectedPage?.sections.find(s => s.id === importFieldTargetSectionId); - const existingFieldsCount = targetSection?.fields?.length ?? 0; - const newOrderNo = existingFieldsCount; // 0-based로 마지막 다음 순서 - - // 2025-11-27: 통합된 필드 연결 방식 - await linkFieldToSection(importFieldTargetSectionId, selectedImportFieldId, newOrderNo); - toast.success('필드를 섹션에 연결했습니다.'); - - setSelectedImportFieldId(null); - setImportFieldTargetSectionId(null); - } catch (error) { - console.error('필드 불러오기 실패:', error); - toast.error(getErrorMessage(error)); - } - }; - - // 2025-11-26 추가: 섹션 복제 핸들러 - const handleCloneSection = async (sectionId: number) => { - try { - await cloneSection(sectionId); - toast.success('섹션이 복제되었습니다.'); - } catch (error) { - console.error('섹션 복제 실패:', error); - toast.error(getErrorMessage(error)); - } - }; - - // ===== 이하 핸들러들은 훅으로 이동되어 제거됨 ===== - // handleAddOption, handleDeleteOption → useAttributeManagement - // handleAddPage, handleDuplicatePage → usePageManagement - // handleAddSection, handleLinkTemplate, handleEditSectionTitle, handleSaveSectionTitle → useSectionManagement - // handleAddField, handleEditField → useFieldManagement - // handleAddMasterField, handleEditMasterField, handleUpdateMasterField, handleDeleteMasterField → useMasterFieldManagement - // handleAddSectionTemplate 등 템플릿 관련 → useTemplateManagement - // handleAddTab 등 탭 관련 → useTabManagement - - // 페이지 삭제 핸들러 (pendingChanges 제거 포함) - 훅에 없어 유지 - const handleDeletePageWithTracking = (pageId: number) => { - const pageToDelete = itemPages.find(p => p.id === pageId); - const sectionIds = pageToDelete?.sections.map(s => s.id) || []; - const fieldIds = pageToDelete?.sections.flatMap(s => s.fields?.map(f => f.id) || []) || []; - deleteItemPage(pageId); - console.log('페이지 삭제 완료:', { pageId, removedSections: sectionIds.length, removedFields: fieldIds.length }); - }; - - // 섹션 삭제 핸들러 (pendingChanges 제거 포함) - 훅에 없어 유지 - const _handleDeleteSectionWithTracking = (pageId: number, sectionId: number) => { - const page = itemPages.find(p => p.id === pageId); - const sectionToDelete = page?.sections.find(s => s.id === sectionId); - const fieldIds = sectionToDelete?.fields?.map(f => f.id) || []; - deleteSection(Number(sectionId)); - console.log('섹션 삭제 완료:', { sectionId, removedFields: fieldIds.length }); - }; - - // 필드 연결 해제 핸들러 (2025-11-27: 삭제 → unlink로 변경) - // 섹션에서 필드 연결만 해제하고, 필드 자체는 독립 필드 목록에 유지됨 - const handleUnlinkFieldWithTracking = async (_pageId: string, sectionId: string, fieldId: string) => { - try { - await unlinkFieldFromSection(Number(sectionId), Number(fieldId)); - console.log('필드 연결 해제 완료:', fieldId); - } catch (error) { - console.error('필드 연결 해제 실패:', error); - toast.error('필드 연결 해제에 실패했습니다'); - } - }; - - // 절대경로 업데이트 - 로컬에서 처리 - const _handleUpdateAbsolutePathLocal = (pageId: number, newPath: string) => { - updateItemPage(pageId, { absolute_path: newPath }); - toast.success('절대경로가 업데이트되었습니다'); - }; - - // 필드 순서 변경 - const _handleReorderFieldsLocal = (sectionId: number, orderedFieldIds: number[]) => { - reorderFields(sectionId, orderedFieldIds); - }; - - // 페이지 이름 업데이트 - const _handleUpdatePageNameLocal = (pageId: number, newName: string) => { - updateItemPage(pageId, { page_name: newName }); - toast.success('페이지 이름이 업데이트되었습니다'); - }; + // 2025-12-24: 신규 훅에서 가져온 핸들러 래퍼 + const handleImportSection = async () => handleImportSectionFromHook(selectedPageId); + const handleImportField = async () => handleImportFieldFromHook(selectedPage ?? null); + const moveSection = async (dragIndex: number, hoverIndex: number) => moveSectionFromHook(selectedPage ?? null, dragIndex, hoverIndex); + const moveField = async (sectionId: number, dragFieldId: number, hoverFieldId: number) => moveFieldFromHook(selectedPage ?? null, sectionId, dragFieldId, hoverFieldId); + const _handleResetAllData = () => handleResetAllDataFromHook( + setUnitOptions, + setMaterialOptions, + setSurfaceTreatmentOptions, + setCustomAttributeOptions, + setAttributeColumns, + setBomItems, + setCustomTabs, + setAttributeSubTabs, + ); // ===== 래퍼 함수들 (훅 함수에 selectedPage 바인딩 및 타입 호환성) ===== const handleAddSectionWrapper = () => handleAddSection(selectedPage); @@ -638,110 +425,6 @@ export function ItemMasterDataManagement() { // eslint-disable-next-line @typescript-eslint/no-explicit-any const setNewPageItemTypeWrapper: React.Dispatch> = setNewPageItemType as any; - // ===== 유틸리티 함수들 ===== - // 현재 섹션의 모든 필드 가져오기 (조건부 필드 참조용) - const _getAllFieldsInSection = (sectionId: number) => { - if (!selectedPage) return []; - const section = selectedPage.sections.find(s => s.id === sectionId); - return section?.fields || []; - }; - - // 섹션 순서 변경 핸들러 (드래그앤드롭) - const moveSection = async (dragIndex: number, hoverIndex: number) => { - if (!selectedPage) return; - - const sections = [...selectedPage.sections]; - const [draggedSection] = sections.splice(dragIndex, 1); - sections.splice(hoverIndex, 0, draggedSection); - - // 새로운 순서의 섹션 ID 배열 생성 - const sectionIds = sections.map(s => s.id); - - try { - // API를 통해 섹션 순서 변경 (Context의 reorderSections 사용) - await reorderSections(selectedPage.id, sectionIds); - toast.success('섹션 순서가 변경되었습니다'); - } catch (error) { - console.error('섹션 순서 변경 실패:', error); - toast.error('섹션 순서 변경에 실패했습니다'); - } - }; - - // 필드 순서 변경 핸들러 - // 2025-12-03: ID 기반으로 변경 (index stale 문제 해결) - const moveField = async (sectionId: number, dragFieldId: number, hoverFieldId: number) => { - if (!selectedPage) return; - const section = selectedPage.sections.find(s => s.id === sectionId); - if (!section || !section.fields) return; - - // 동일 필드면 스킵 - if (dragFieldId === hoverFieldId) return; - - // 정렬된 배열에서 ID로 인덱스 찾기 - const sortedFields = [...section.fields].sort((a, b) => (a.order_no ?? 0) - (b.order_no ?? 0)); - const dragIndex = sortedFields.findIndex(f => f.id === dragFieldId); - const hoverIndex = sortedFields.findIndex(f => f.id === hoverFieldId); - - // 유효하지 않은 인덱스 체크 - if (dragIndex === -1 || hoverIndex === -1) { - return; - } - - // 드래그된 필드를 제거하고 새 위치에 삽입 - const [draggedField] = sortedFields.splice(dragIndex, 1); - sortedFields.splice(hoverIndex, 0, draggedField); - - const newFieldIds = sortedFields.map(f => f.id); - - try { - await reorderFields(sectionId, newFieldIds); - toast.success('항목 순서가 변경되었습니다'); - } catch (error) { - toast.error('항목 순서 변경에 실패했습니다'); - } - }; - - // 전체 데이터 초기화 핸들러 - const _handleResetAllData = () => { - if (!confirm('⚠️ 경고: 모든 품목기준관리 데이터(계층구조, 섹션, 항목, 속성)를 초기화하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다!')) { - return; - } - - try { - // ItemMasterContext의 모든 데이터 및 캐시 초기화 - resetAllData(); - - // 로컬 상태 초기화 (ItemMasterContext가 관리하지 않는 컴포넌트 로컬 상태) - setUnitOptions([]); - setMaterialOptions([]); - setSurfaceTreatmentOptions([]); - setCustomAttributeOptions({}); - setAttributeColumns({}); - setBomItems([]); - - // 탭 상태 초기화 (기본 탭만 남김) - setCustomTabs([ - { id: 'hierarchy', label: '계층구조', icon: 'FolderTree', isDefault: true, order: 1 }, - { id: 'sections', label: '섹션', icon: 'Layers', isDefault: true, order: 2 }, - { id: 'items', label: '항목', icon: 'ListTree', isDefault: true, order: 3 }, - { id: 'attributes', label: '속성', icon: 'Settings', isDefault: true, order: 4 } - ]); - - setAttributeSubTabs([]); - - console.log('🗑️ 모든 품목기준관리 데이터가 초기화되었습니다'); - toast.success('✅ 모든 데이터가 초기화되었습니다!\n계층구조, 섹션, 항목, 속성이 모두 삭제되었습니다.'); - - // 페이지 새로고침하여 완전히 초기화된 상태 반영 - setTimeout(() => { - window.location.reload(); - }, 1500); - } catch (error) { - toast.error('초기화 중 오류가 발생했습니다'); - console.error('Reset error:', error); - } - }; - // 초기 로딩 중 UI if (isInitialLoading) { return ( @@ -803,531 +486,32 @@ export function ItemMasterDataManagement() { */} - {/* 속성 탭 (단위/재질/표면처리 통합) */} + {/* 속성 탭 (단위/재질/표면처리 통합) - 2025-12-24: AttributeTabContent로 분리 */} - - - 속성 관리 - 단위, 재질, 표면처리 등의 속성을 관리합니다 - - - {/* 속성 하위 탭 (칩 형태) */} -
-
- {attributeSubTabs.sort((a, b) => a.order - b.order).map(tab => ( - - ))} -
- -
- - {/* 단위 관리 */} - {activeAttributeTab === 'units' && ( -
-
-

단위 목록

-
- - -
-
-
- {unitOptions.map((option) => { - const columns = attributeColumns['units'] || []; - const hasColumns = columns.length > 0 && option.columnValues; - - return ( -
-
-
-
- {option.label} - {option.inputType && ( - {getInputTypeLabel(option.inputType)} - )} - {option.required && ( - 필수 - )} -
- -
-
- 값(Value): - {option.value} -
- {option.placeholder && ( -
- 플레이스홀더: - {option.placeholder} -
- )} - {option.defaultValue && ( -
- 기본값: - {option.defaultValue} -
- )} - {option.inputType === 'dropdown' && option.options && ( -
- 옵션: -
- {option.options.map((opt, idx) => ( - {opt} - ))} -
-
- )} -
- - {hasColumns && ( -
-

추가 칼럼

-
- {columns.map((column) => ( -
- {column.name}: - {option.columnValues?.[column.key] || '-'} -
- ))} -
-
- )} -
- -
-
- ); - })} -
-
- )} - - {/* 재질 관리 */} - {activeAttributeTab === 'materials' && ( -
-
-

재질 목록

-
- - -
-
-
- {materialOptions.map((option) => { - const columns = attributeColumns['materials'] || []; - const hasColumns = columns.length > 0 && option.columnValues; - - return ( -
-
-
-
- {option.label} - {option.inputType && ( - {getInputTypeLabel(option.inputType)} - )} - {option.required && ( - 필수 - )} -
- -
-
- 값(Value): - {option.value} -
- {option.placeholder && ( -
- 플레이스홀더: - {option.placeholder} -
- )} - {option.defaultValue && ( -
- 기본값: - {option.defaultValue} -
- )} - {option.inputType === 'dropdown' && option.options && ( -
- 옵션: -
- {option.options.map((opt, idx) => ( - {opt} - ))} -
-
- )} -
- - {hasColumns && ( -
-

추가 칼럼

-
- {columns.map((column) => ( -
- {column.name}: - {option.columnValues?.[column.key] || '-'} -
- ))} -
-
- )} -
- -
-
- ); - })} -
-
- )} - - {/* 표면처리 관리 */} - {activeAttributeTab === 'surface' && ( -
-
-

표면처리 목록

-
- - -
-
-
- {surfaceTreatmentOptions.map((option) => { - const columns = attributeColumns['surface'] || []; - const hasColumns = columns.length > 0 && option.columnValues; - const inputTypeLabel = getInputTypeLabel(option.inputType); - - return ( -
-
-
-
- {option.label} - {option.inputType && ( - {inputTypeLabel} - )} - {option.required && ( - 필수 - )} -
- -
-
- 값(Value): - {option.value} -
- {option.placeholder && ( -
- 플레이스홀더: - {option.placeholder} -
- )} - {option.defaultValue && ( -
- 기본값: - {option.defaultValue} -
- )} - {option.inputType === 'dropdown' && option.options && ( -
- 옵션: -
- {option.options.map((opt, idx) => ( - {opt} - ))} -
-
- )} -
- - {hasColumns && ( -
-

추가 칼럼

-
- {columns.map((column) => ( -
- {column.name}: - {option.columnValues?.[column.key] || '-'} -
- ))} -
-
- )} -
- -
-
- ); - })} -
-
- )} - - {/* 사용자 정의 속성 탭 및 마스터 항목 탭 */} - {!['units', 'materials', 'surface'].includes(activeAttributeTab) && (() => { - const currentTabKey = activeAttributeTab; - - // 마스터 항목인지 확인 - const masterField = itemMasterFields.find(f => f.id.toString() === currentTabKey); - - // 마스터 항목이면 해당 항목의 속성값들을 표시 - // Note: properties is Record | null, convert to array for display - const propertiesArray = masterField?.properties - ? Object.entries(masterField.properties).map(([key, value]) => ({ key, ...value as object })) - : []; - - if (masterField && propertiesArray.length > 0) { - return ( -
-
-
-

{masterField.field_name} 속성 목록

-

- 항목 탭에서 추가한 "{masterField.field_name}" 항목의 속성값들입니다 -

-
-
- -
- {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - {propertiesArray.map((property: any) => { - const inputTypeLabel = getInputTypeLabel(property.type); - - return ( -
-
-
-
- {property.label} - {inputTypeLabel} - {property.required && ( - 필수 - )} -
- -
-
- 키(Key): - {property.key} -
- {property.placeholder && ( -
- 플레이스홀더: - {property.placeholder} -
- )} - {property.defaultValue && ( -
- 기본값: - {property.defaultValue} -
- )} - {property.type === 'dropdown' && property.options && ( -
- 옵션: -
- {property.options.map((opt: string, idx: number) => ( - {opt} - ))} -
-
- )} -
-
-
-
- ); - })} -
- -
-
- -
-

- 마스터 항목 속성 관리 -

-

- 이 속성들은 항목 탭에서 "{masterField.field_name}" 항목을 편집하여 추가/수정/삭제할 수 있습니다. -

-
-
-
-
- ); - } - - // 사용자 정의 속성 탭 (기존 로직) - const currentOptions = customAttributeOptions[currentTabKey] || []; - - return ( -
-
-

- {attributeSubTabs.find(t => t.key === activeAttributeTab)?.label || '사용자 정의'} 목록 -

-
- - -
-
- - {currentOptions.length > 0 ? ( -
- {currentOptions.map((option) => { - const columns = attributeColumns[currentTabKey] || []; - const hasColumns = columns.length > 0 && option.columnValues; - const inputTypeLabel = getInputTypeLabel(option.inputType); - - return ( -
-
-
-
- {option.label} - {inputTypeLabel} - {option.required && ( - 필수 - )} -
- -
-
- 값(Value): - {option.value} -
- {option.placeholder && ( -
- 플레이스홀더: - {option.placeholder} -
- )} - {option.defaultValue && ( -
- 기본값: - {option.defaultValue} -
- )} - {option.inputType === 'dropdown' && option.options && ( -
- 옵션: -
- {option.options.map((opt, idx) => ( - {opt} - ))} -
-
- )} -
- - {hasColumns && ( -
-

추가 칼럼

-
- {columns.map((column) => ( -
- {column.name}: - {option.columnValues?.[column.key] || '-'} -
- ))} -
-
- )} -
- -
-
- ); - })} -
- ) : ( -
- -

아직 추가된 항목이 없습니다

-

위 "추가" 버튼을 클릭하여 새로운 속성을 추가할 수 있습니다

-
- )} -
- ); - })()} -
-
+
{/* 항목 탭 */} diff --git a/src/components/items/ItemMasterDataManagement/components/AttributeTabContent.tsx b/src/components/items/ItemMasterDataManagement/components/AttributeTabContent.tsx new file mode 100644 index 00000000..e8ea0a23 --- /dev/null +++ b/src/components/items/ItemMasterDataManagement/components/AttributeTabContent.tsx @@ -0,0 +1,452 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Plus, Trash2, Settings, Package } from 'lucide-react'; +import type { ItemMasterField } from '@/contexts/ItemMasterContext'; +import type { MasterOption, OptionColumn } from '../types'; +import type { AttributeSubTab } from '../hooks/useTabManagement'; + +// UnitOption은 MasterOption으로 대체 +export type UnitOption = MasterOption; + +// AttributeColumn은 OptionColumn으로 대체 +export type AttributeColumn = OptionColumn; + +// 입력 타입 라벨 변환 헬퍼 함수 +const getInputTypeLabel = (inputType: string | undefined): string => { + const labels: Record = { + textbox: '텍스트박스', + number: '숫자', + dropdown: '드롭다운', + checkbox: '체크박스', + date: '날짜', + textarea: '텍스트영역', + }; + return labels[inputType || ''] || '텍스트박스'; +}; + +interface AttributeTabContentProps { + activeAttributeTab: string; + setActiveAttributeTab: (tab: string) => void; + attributeSubTabs: AttributeSubTab[]; + unitOptions: UnitOption[]; + materialOptions: UnitOption[]; + surfaceTreatmentOptions: UnitOption[]; + customAttributeOptions: Record; + attributeColumns: Record; + itemMasterFields: ItemMasterField[]; + // 다이얼로그 핸들러 + setIsManageAttributeTabsDialogOpen: (open: boolean) => void; + setIsOptionDialogOpen: (open: boolean) => void; + setEditingOptionType: (type: string) => void; + setNewOptionValue: (value: string) => void; + setNewOptionLabel: (value: string) => void; + setNewOptionColumnValues: (values: Record) => void; + setIsColumnManageDialogOpen: (open: boolean) => void; + setManagingColumnType: (type: string) => void; + setNewColumnName: (name: string) => void; + setNewColumnKey: (key: string) => void; + setNewColumnType: (type: 'text' | 'number') => void; + setNewColumnRequired: (required: boolean) => void; + handleDeleteOption: (type: string, id: string) => void; +} + +export function AttributeTabContent({ + activeAttributeTab, + setActiveAttributeTab, + attributeSubTabs, + unitOptions, + materialOptions, + surfaceTreatmentOptions, + customAttributeOptions, + attributeColumns, + itemMasterFields, + setIsManageAttributeTabsDialogOpen, + setIsOptionDialogOpen, + setEditingOptionType, + setNewOptionValue, + setNewOptionLabel, + setNewOptionColumnValues, + setIsColumnManageDialogOpen, + setManagingColumnType, + setNewColumnName, + setNewColumnKey, + setNewColumnType, + setNewColumnRequired, + handleDeleteOption, +}: AttributeTabContentProps) { + // 옵션 목록 렌더링 헬퍼 + const renderOptionList = ( + options: UnitOption[], + optionType: string, + title: string + ) => { + const columns = attributeColumns[optionType] || []; + + return ( +
+
+

{title}

+
+ + +
+
+
+ {options.map((option) => { + const hasColumns = columns.length > 0 && option.columnValues; + + return ( +
+
+
+
+ {option.label} + {option.inputType && ( + {getInputTypeLabel(option.inputType)} + )} + {option.required && ( + 필수 + )} +
+ +
+
+ 값(Value): + {option.value} +
+ {option.placeholder && ( +
+ 플레이스홀더: + {option.placeholder} +
+ )} + {option.defaultValue && ( +
+ 기본값: + {option.defaultValue} +
+ )} + {option.inputType === 'dropdown' && option.options && ( +
+ 옵션: +
+ {option.options.map((opt, idx) => ( + {opt} + ))} +
+
+ )} +
+ + {hasColumns && ( +
+

추가 칼럼

+
+ {columns.map((column) => ( +
+ {column.name}: + {option.columnValues?.[column.key] || '-'} +
+ ))} +
+
+ )} +
+ +
+
+ ); + })} +
+
+ ); + }; + + // 마스터 필드 속성 렌더링 + const renderMasterFieldProperties = (masterField: ItemMasterField) => { + const propertiesArray = masterField?.properties + ? Object.entries(masterField.properties).map(([key, value]) => ({ key, ...value as object })) + : []; + + if (propertiesArray.length === 0) return null; + + return ( +
+
+
+

{masterField.field_name} 속성 목록

+

+ 항목 탭에서 추가한 "{masterField.field_name}" 항목의 속성값들입니다 +

+
+
+ +
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + {propertiesArray.map((property: any) => { + const inputTypeLabel = getInputTypeLabel(property.type); + + return ( +
+
+
+
+ {property.label} + {inputTypeLabel} + {property.required && ( + 필수 + )} +
+ +
+
+ 키(Key): + {property.key} +
+ {property.placeholder && ( +
+ 플레이스홀더: + {property.placeholder} +
+ )} + {property.defaultValue && ( +
+ 기본값: + {property.defaultValue} +
+ )} + {property.type === 'dropdown' && property.options && ( +
+ 옵션: +
+ {property.options.map((opt: string, idx: number) => ( + {opt} + ))} +
+
+ )} +
+
+
+
+ ); + })} +
+ +
+
+ +
+

+ 마스터 항목 속성 관리 +

+

+ 이 속성들은 항목 탭에서 "{masterField.field_name}" 항목을 편집하여 추가/수정/삭제할 수 있습니다. +

+
+
+
+
+ ); + }; + + // 사용자 정의 속성 렌더링 + const renderCustomAttributeTab = () => { + const currentTabKey = activeAttributeTab; + const currentOptions = customAttributeOptions[currentTabKey] || []; + const columns = attributeColumns[currentTabKey] || []; + + return ( +
+
+

+ {attributeSubTabs.find(t => t.key === activeAttributeTab)?.label || '사용자 정의'} 목록 +

+
+ + +
+
+ + {currentOptions.length > 0 ? ( +
+ {currentOptions.map((option) => { + const hasColumns = columns.length > 0 && option.columnValues; + const inputTypeLabel = getInputTypeLabel(option.inputType); + + return ( +
+
+
+
+ {option.label} + {inputTypeLabel} + {option.required && ( + 필수 + )} +
+ +
+
+ 값(Value): + {option.value} +
+ {option.placeholder && ( +
+ 플레이스홀더: + {option.placeholder} +
+ )} + {option.defaultValue && ( +
+ 기본값: + {option.defaultValue} +
+ )} + {option.inputType === 'dropdown' && option.options && ( +
+ 옵션: +
+ {option.options.map((opt, idx) => ( + {opt} + ))} +
+
+ )} +
+ + {hasColumns && ( +
+

추가 칼럼

+
+ {columns.map((column) => ( +
+ {column.name}: + {option.columnValues?.[column.key] || '-'} +
+ ))} +
+
+ )} +
+ +
+
+ ); + })} +
+ ) : ( +
+ +

아직 추가된 항목이 없습니다

+

위 "추가" 버튼을 클릭하여 새로운 속성을 추가할 수 있습니다

+
+ )} +
+ ); + }; + + return ( + + + 속성 관리 + 단위, 재질, 표면처리 등의 속성을 관리합니다 + + + {/* 속성 하위 탭 (칩 형태) */} +
+
+ {attributeSubTabs.sort((a, b) => a.order - b.order).map(tab => ( + + ))} +
+ +
+ + {/* 단위 관리 */} + {activeAttributeTab === 'units' && renderOptionList(unitOptions, 'units', '단위 목록')} + + {/* 재질 관리 */} + {activeAttributeTab === 'materials' && renderOptionList(materialOptions, 'materials', '재질 목록')} + + {/* 표면처리 관리 */} + {activeAttributeTab === 'surface' && renderOptionList(surfaceTreatmentOptions, 'surface', '표면처리 목록')} + + {/* 사용자 정의 속성 탭 및 마스터 항목 탭 */} + {!['units', 'materials', 'surface'].includes(activeAttributeTab) && (() => { + const currentTabKey = activeAttributeTab; + + // 마스터 항목인지 확인 + const masterField = itemMasterFields.find(f => f.id.toString() === currentTabKey); + + if (masterField) { + const propertiesArray = masterField?.properties + ? Object.entries(masterField.properties).map(([key, value]) => ({ key, ...value as object })) + : []; + + if (propertiesArray.length > 0) { + return renderMasterFieldProperties(masterField); + } + } + + // 사용자 정의 속성 탭 + return renderCustomAttributeTab(); + })()} +
+
+ ); +} diff --git a/src/components/items/ItemMasterDataManagement/components/ErrorAlertDialog.tsx b/src/components/items/ItemMasterDataManagement/components/ErrorAlertDialog.tsx new file mode 100644 index 00000000..8c50f38c --- /dev/null +++ b/src/components/items/ItemMasterDataManagement/components/ErrorAlertDialog.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { AlertCircle } from 'lucide-react'; + +interface ErrorAlertDialogProps { + open: boolean; + onClose: () => void; + title?: string; + message: string; +} + +/** + * 에러 알림 다이얼로그 컴포넌트 + * 422 ValidationException 등의 에러 메시지를 표시 + */ +export function ErrorAlertDialog({ + open, + onClose, + title = '오류', + message, +}: ErrorAlertDialogProps) { + return ( + !isOpen && onClose()}> + + + + + {title} + + + {message} + + + + + 확인 + + + + + ); +} \ No newline at end of file diff --git a/src/components/items/ItemMasterDataManagement/components/ItemMasterDialogs.tsx b/src/components/items/ItemMasterDataManagement/components/ItemMasterDialogs.tsx new file mode 100644 index 00000000..52c88b45 --- /dev/null +++ b/src/components/items/ItemMasterDataManagement/components/ItemMasterDialogs.tsx @@ -0,0 +1,943 @@ +'use client'; + +import type { ItemPage, SectionTemplate, ItemMasterField, ItemSection, BOMItem } from '@/contexts/ItemMasterContext'; +import { FieldDialog } from '../dialogs/FieldDialog'; +import { FieldDrawer } from '../dialogs/FieldDrawer'; +import { TabManagementDialogs } from '../dialogs/TabManagementDialogs'; +import { OptionDialog } from '../dialogs/OptionDialog'; +import { ColumnManageDialog } from '../dialogs/ColumnManageDialog'; +import { PathEditDialog } from '../dialogs/PathEditDialog'; +import { PageDialog } from '../dialogs/PageDialog'; +import { SectionDialog } from '../dialogs/SectionDialog'; +import { MasterFieldDialog } from '../dialogs/MasterFieldDialog'; +import { TemplateFieldDialog } from '../dialogs/TemplateFieldDialog'; +import { LoadTemplateDialog } from '../dialogs/LoadTemplateDialog'; +import { ColumnDialog } from '../dialogs/ColumnDialog'; +import { SectionTemplateDialog } from '../dialogs/SectionTemplateDialog'; +import { ImportSectionDialog } from '../dialogs/ImportSectionDialog'; +import { ImportFieldDialog } from '../dialogs/ImportFieldDialog'; +import type { CustomTab, AttributeSubTab } from '../hooks/useTabManagement'; +import type { UnitOption } from '../hooks/useAttributeManagement'; + +interface TextboxColumn { + id: string; + name: string; + key: string; +} + +interface ConditionField { + fieldId: string; + fieldName: string; + operator: string; + value: string; + logicOperator?: 'AND' | 'OR'; +} + +interface ConditionSection { + sectionId: string; + sectionTitle: string; + operator: string; + value: string; + logicOperator?: 'AND' | 'OR'; +} + +interface AttributeColumn { + id: string; + name: string; + key: string; + type: string; + required: boolean; +} + +export interface ItemMasterDialogsProps { + isMobile: boolean; + selectedPage: ItemPage | null; + + // Tab Management + isManageTabsDialogOpen: boolean; + setIsManageTabsDialogOpen: (open: boolean) => void; + customTabs: CustomTab[]; + moveTabUp: (tabId: string) => void; + moveTabDown: (tabId: string) => void; + handleEditTabFromManage: (tabId: string) => void; + handleDeleteTab: (tabId: string) => void; + getTabIcon: (iconName: string) => React.ComponentType<{ className?: string }>; + setIsAddTabDialogOpen: (open: boolean) => void; + isDeleteTabDialogOpen: boolean; + setIsDeleteTabDialogOpen: (open: boolean) => void; + deletingTabId: string | null; + setDeletingTabId: (id: string | null) => void; + confirmDeleteTab: () => void; + isAddTabDialogOpen: boolean; + editingTabId: string | null; + setEditingTabId: (id: string | null) => void; + newTabLabel: string; + setNewTabLabel: (label: string) => void; + handleUpdateTab: () => void; + handleAddTab: () => void; + isManageAttributeTabsDialogOpen: boolean; + setIsManageAttributeTabsDialogOpen: (open: boolean) => void; + attributeSubTabs: AttributeSubTab[]; + moveAttributeTabUp: (tabId: string) => void; + moveAttributeTabDown: (tabId: string) => void; + handleDeleteAttributeTab: (tabId: string) => void; + isDeleteAttributeTabDialogOpen: boolean; + setIsDeleteAttributeTabDialogOpen: (open: boolean) => void; + deletingAttributeTabId: string | null; + setDeletingAttributeTabId: (id: string | null) => void; + confirmDeleteAttributeTab: () => void; + isAddAttributeTabDialogOpen: boolean; + setIsAddAttributeTabDialogOpen: (open: boolean) => void; + editingAttributeTabId: string | null; + setEditingAttributeTabId: (id: string | null) => void; + newAttributeTabLabel: string; + setNewAttributeTabLabel: (label: string) => void; + handleUpdateAttributeTab: () => void; + handleAddAttributeTab: () => void; + + // Option Dialog + isOptionDialogOpen: boolean; + setIsOptionDialogOpen: (open: boolean) => void; + newOptionValue: string; + setNewOptionValue: (value: string) => void; + newOptionLabel: string; + setNewOptionLabel: (value: string) => void; + newOptionColumnValues: Record; + setNewOptionColumnValues: (values: Record) => void; + newOptionInputType: string; + setNewOptionInputType: (type: string) => void; + newOptionRequired: boolean; + setNewOptionRequired: (required: boolean) => void; + newOptionOptions: string[]; + setNewOptionOptions: (options: string[]) => void; + newOptionPlaceholder: string; + setNewOptionPlaceholder: (placeholder: string) => void; + newOptionDefaultValue: string; + setNewOptionDefaultValue: (value: string) => void; + editingOptionType: string; + attributeColumns: Record; + handleAddOption: () => void; + + // Column Manage Dialog + isColumnManageDialogOpen: boolean; + setIsColumnManageDialogOpen: (open: boolean) => void; + managingColumnType: string; + setAttributeColumns: React.Dispatch>>; + newColumnName: string; + setNewColumnName: (name: string) => void; + newColumnKey: string; + setNewColumnKey: (key: string) => void; + newColumnType: string; + setNewColumnType: (type: string) => void; + newColumnRequired: boolean; + setNewColumnRequired: (required: boolean) => void; + + // Path Edit Dialog + editingPathPageId: number | null; + setEditingPathPageId: (id: number | null) => void; + editingAbsolutePath: string; + setEditingAbsolutePath: (path: string) => void; + updateItemPage: (id: number, updates: Partial) => void; + + // Page Dialog + isPageDialogOpen: boolean; + setIsPageDialogOpen: (open: boolean) => void; + newPageName: string; + setNewPageName: (name: string) => void; + newPageItemType: 'FG' | 'PT' | 'SM' | 'RM' | 'CS'; + setNewPageItemType: React.Dispatch>; + handleAddPage: () => void; + + // Section Dialog + isSectionDialogOpen: boolean; + setIsSectionDialogOpen: (open: boolean) => void; + newSectionType: 'fields' | 'bom'; + setNewSectionType: (type: 'fields' | 'bom') => void; + newSectionTitle: string; + setNewSectionTitle: (title: string) => void; + newSectionDescription: string; + setNewSectionDescription: (description: string) => void; + handleAddSection: () => void; + sectionInputMode: 'new' | 'existing'; + setSectionInputMode: (mode: 'new' | 'existing') => void; + sectionsAsTemplates: SectionTemplate[]; + selectedSectionTemplateId: number | null; + setSelectedSectionTemplateId: (id: number | null) => void; + handleLinkTemplate: () => void; + + // Field Dialog + isFieldDialogOpen: boolean; + setIsFieldDialogOpen: (open: boolean) => void; + selectedSectionForField: number | null; + editingFieldId: number | null; + setEditingFieldId: (id: number | null) => void; + fieldInputMode: 'new' | 'existing'; + setFieldInputMode: (mode: 'new' | 'existing') => void; + showMasterFieldList: boolean; + setShowMasterFieldList: (show: boolean) => void; + selectedMasterFieldId: number | null; + setSelectedMasterFieldId: (id: number | null) => void; + textboxColumns: TextboxColumn[]; + setTextboxColumns: React.Dispatch>; + newFieldConditionEnabled: boolean; + setNewFieldConditionEnabled: (enabled: boolean) => void; + newFieldConditionTargetType: 'field' | 'section'; + setNewFieldConditionTargetType: (type: 'field' | 'section') => void; + newFieldConditionFields: ConditionField[]; + setNewFieldConditionFields: React.Dispatch>; + newFieldConditionSections: ConditionSection[]; + setNewFieldConditionSections: React.Dispatch>; + tempConditionValue: string; + setTempConditionValue: (value: string) => void; + newFieldName: string; + setNewFieldName: (name: string) => void; + newFieldKey: string; + setNewFieldKey: (key: string) => void; + newFieldInputType: string; + setNewFieldInputType: (type: string) => void; + newFieldRequired: boolean; + setNewFieldRequired: (required: boolean) => void; + newFieldDescription: string; + setNewFieldDescription: (description: string) => void; + newFieldOptions: string[]; + setNewFieldOptions: React.Dispatch>; + itemMasterFields: ItemMasterField[]; + handleAddField: () => void; + isColumnDialogOpen: boolean; + setIsColumnDialogOpen: (open: boolean) => void; + editingColumnId: string | null; + setEditingColumnId: (id: string | null) => void; + columnName: string; + setColumnName: (name: string) => void; + columnKey: string; + setColumnKey: (key: string) => void; + + // Master Field Dialog + isMasterFieldDialogOpen: boolean; + setIsMasterFieldDialogOpen: (open: boolean) => void; + editingMasterFieldId: number | null; + setEditingMasterFieldId: (id: number | null) => void; + newMasterFieldName: string; + setNewMasterFieldName: (name: string) => void; + newMasterFieldKey: string; + setNewMasterFieldKey: (key: string) => void; + newMasterFieldInputType: string; + setNewMasterFieldInputType: (type: string) => void; + newMasterFieldRequired: boolean; + setNewMasterFieldRequired: (required: boolean) => void; + newMasterFieldCategory: string; + setNewMasterFieldCategory: (category: string) => void; + newMasterFieldDescription: string; + setNewMasterFieldDescription: (description: string) => void; + newMasterFieldOptions: string[]; + setNewMasterFieldOptions: React.Dispatch>; + newMasterFieldAttributeType: string; + setNewMasterFieldAttributeType: (type: string) => void; + newMasterFieldMultiColumn: boolean; + setNewMasterFieldMultiColumn: (multiColumn: boolean) => void; + newMasterFieldColumnCount: number; + setNewMasterFieldColumnCount: (count: number) => void; + newMasterFieldColumnNames: string[]; + setNewMasterFieldColumnNames: React.Dispatch>; + handleUpdateMasterField: () => void; + handleAddMasterField: () => void; + + // Section Template Dialog + isSectionTemplateDialogOpen: boolean; + setIsSectionTemplateDialogOpen: (open: boolean) => void; + editingSectionTemplateId: number | null; + setEditingSectionTemplateId: (id: number | null) => void; + newSectionTemplateTitle: string; + setNewSectionTemplateTitle: (title: string) => void; + newSectionTemplateDescription: string; + setNewSectionTemplateDescription: (description: string) => void; + newSectionTemplateCategory: string; + setNewSectionTemplateCategory: (category: string) => void; + newSectionTemplateType: 'fields' | 'bom'; + setNewSectionTemplateType: (type: 'fields' | 'bom') => void; + handleUpdateSectionTemplate: () => void; + handleAddSectionTemplate: () => void; + + // Template Field Dialog + isTemplateFieldDialogOpen: boolean; + setIsTemplateFieldDialogOpen: (open: boolean) => void; + editingTemplateFieldId: string | null; + setEditingTemplateFieldId: (id: string | null) => void; + templateFieldName: string; + setTemplateFieldName: (name: string) => void; + templateFieldKey: string; + setTemplateFieldKey: (key: string) => void; + templateFieldInputType: string; + setTemplateFieldInputType: (type: string) => void; + templateFieldRequired: boolean; + setTemplateFieldRequired: (required: boolean) => void; + templateFieldOptions: string[]; + setTemplateFieldOptions: React.Dispatch>; + templateFieldDescription: string; + setTemplateFieldDescription: (description: string) => void; + templateFieldMultiColumn: boolean; + setTemplateFieldMultiColumn: (multiColumn: boolean) => void; + templateFieldColumnCount: number; + setTemplateFieldColumnCount: (count: number) => void; + templateFieldColumnNames: string[]; + setTemplateFieldColumnNames: React.Dispatch>; + handleAddTemplateField: () => void; + templateFieldInputMode: 'new' | 'existing'; + setTemplateFieldInputMode: (mode: 'new' | 'existing') => void; + templateFieldShowMasterFieldList: boolean; + setTemplateFieldShowMasterFieldList: (show: boolean) => void; + templateFieldSelectedMasterFieldId: number | null; + setTemplateFieldSelectedMasterFieldId: (id: number | null) => void; + + // Load Template Dialog + isLoadTemplateDialogOpen: boolean; + setIsLoadTemplateDialogOpen: (open: boolean) => void; + sectionTemplates: SectionTemplate[]; + selectedTemplateId: number | null; + setSelectedTemplateId: (id: number | null) => void; + handleLoadTemplate: () => void; + + // Import Section Dialog + isImportSectionDialogOpen: boolean; + setIsImportSectionDialogOpen: (open: boolean) => void; + independentSections: ItemSection[]; + selectedImportSectionId: number | null; + setSelectedImportSectionId: (id: number | null) => void; + handleImportSection: () => Promise; + refreshIndependentSections: () => void; + getSectionUsage: (sectionId: number) => Promise<{ pages: { id: number; name: string }[] }>; + + // Import Field Dialog + isImportFieldDialogOpen: boolean; + setIsImportFieldDialogOpen: (open: boolean) => void; + selectedImportFieldId: number | null; + setSelectedImportFieldId: (id: number | null) => void; + handleImportField: () => Promise; + refreshIndependentFields: () => void; + getFieldUsage: (fieldId: number) => Promise<{ sections: { id: number; title: string }[] }>; + importFieldTargetSectionId: number | null; +} + +export function ItemMasterDialogs({ + isMobile, + selectedPage, + + // Tab Management + isManageTabsDialogOpen, + setIsManageTabsDialogOpen, + customTabs, + moveTabUp, + moveTabDown, + handleEditTabFromManage, + handleDeleteTab, + getTabIcon, + setIsAddTabDialogOpen, + isDeleteTabDialogOpen, + setIsDeleteTabDialogOpen, + deletingTabId, + setDeletingTabId, + confirmDeleteTab, + isAddTabDialogOpen, + editingTabId, + setEditingTabId, + newTabLabel, + setNewTabLabel, + handleUpdateTab, + handleAddTab, + isManageAttributeTabsDialogOpen, + setIsManageAttributeTabsDialogOpen, + attributeSubTabs, + moveAttributeTabUp, + moveAttributeTabDown, + handleDeleteAttributeTab, + isDeleteAttributeTabDialogOpen, + setIsDeleteAttributeTabDialogOpen, + deletingAttributeTabId, + setDeletingAttributeTabId, + confirmDeleteAttributeTab, + isAddAttributeTabDialogOpen, + setIsAddAttributeTabDialogOpen, + editingAttributeTabId, + setEditingAttributeTabId, + newAttributeTabLabel, + setNewAttributeTabLabel, + handleUpdateAttributeTab, + handleAddAttributeTab, + + // Option Dialog + isOptionDialogOpen, + setIsOptionDialogOpen, + newOptionValue, + setNewOptionValue, + newOptionLabel, + setNewOptionLabel, + newOptionColumnValues, + setNewOptionColumnValues, + newOptionInputType, + setNewOptionInputType, + newOptionRequired, + setNewOptionRequired, + newOptionOptions, + setNewOptionOptions, + newOptionPlaceholder, + setNewOptionPlaceholder, + newOptionDefaultValue, + setNewOptionDefaultValue, + editingOptionType, + attributeColumns, + handleAddOption, + + // Column Manage Dialog + isColumnManageDialogOpen, + setIsColumnManageDialogOpen, + managingColumnType, + setAttributeColumns, + newColumnName, + setNewColumnName, + newColumnKey, + setNewColumnKey, + newColumnType, + setNewColumnType, + newColumnRequired, + setNewColumnRequired, + + // Path Edit Dialog + editingPathPageId, + setEditingPathPageId, + editingAbsolutePath, + setEditingAbsolutePath, + updateItemPage, + + // Page Dialog + isPageDialogOpen, + setIsPageDialogOpen, + newPageName, + setNewPageName, + newPageItemType, + setNewPageItemType, + handleAddPage, + + // Section Dialog + isSectionDialogOpen, + setIsSectionDialogOpen, + newSectionType, + setNewSectionType, + newSectionTitle, + setNewSectionTitle, + newSectionDescription, + setNewSectionDescription, + handleAddSection, + sectionInputMode, + setSectionInputMode, + sectionsAsTemplates, + selectedSectionTemplateId, + setSelectedSectionTemplateId, + handleLinkTemplate, + + // Field Dialog + isFieldDialogOpen, + setIsFieldDialogOpen, + selectedSectionForField, + editingFieldId, + setEditingFieldId, + fieldInputMode, + setFieldInputMode, + showMasterFieldList, + setShowMasterFieldList, + selectedMasterFieldId, + setSelectedMasterFieldId, + textboxColumns, + setTextboxColumns, + newFieldConditionEnabled, + setNewFieldConditionEnabled, + newFieldConditionTargetType, + setNewFieldConditionTargetType, + newFieldConditionFields, + setNewFieldConditionFields, + newFieldConditionSections, + setNewFieldConditionSections, + tempConditionValue, + setTempConditionValue, + newFieldName, + setNewFieldName, + newFieldKey, + setNewFieldKey, + newFieldInputType, + setNewFieldInputType, + newFieldRequired, + setNewFieldRequired, + newFieldDescription, + setNewFieldDescription, + newFieldOptions, + setNewFieldOptions, + itemMasterFields, + handleAddField, + isColumnDialogOpen, + setIsColumnDialogOpen, + editingColumnId, + setEditingColumnId, + columnName, + setColumnName, + columnKey, + setColumnKey, + + // Master Field Dialog + isMasterFieldDialogOpen, + setIsMasterFieldDialogOpen, + editingMasterFieldId, + setEditingMasterFieldId, + newMasterFieldName, + setNewMasterFieldName, + newMasterFieldKey, + setNewMasterFieldKey, + newMasterFieldInputType, + setNewMasterFieldInputType, + newMasterFieldRequired, + setNewMasterFieldRequired, + newMasterFieldCategory, + setNewMasterFieldCategory, + newMasterFieldDescription, + setNewMasterFieldDescription, + newMasterFieldOptions, + setNewMasterFieldOptions, + newMasterFieldAttributeType, + setNewMasterFieldAttributeType, + newMasterFieldMultiColumn, + setNewMasterFieldMultiColumn, + newMasterFieldColumnCount, + setNewMasterFieldColumnCount, + newMasterFieldColumnNames, + setNewMasterFieldColumnNames, + handleUpdateMasterField, + handleAddMasterField, + + // Section Template Dialog + isSectionTemplateDialogOpen, + setIsSectionTemplateDialogOpen, + editingSectionTemplateId, + setEditingSectionTemplateId, + newSectionTemplateTitle, + setNewSectionTemplateTitle, + newSectionTemplateDescription, + setNewSectionTemplateDescription, + newSectionTemplateCategory, + setNewSectionTemplateCategory, + newSectionTemplateType, + setNewSectionTemplateType, + handleUpdateSectionTemplate, + handleAddSectionTemplate, + + // Template Field Dialog + isTemplateFieldDialogOpen, + setIsTemplateFieldDialogOpen, + editingTemplateFieldId, + setEditingTemplateFieldId, + templateFieldName, + setTemplateFieldName, + templateFieldKey, + setTemplateFieldKey, + templateFieldInputType, + setTemplateFieldInputType, + templateFieldRequired, + setTemplateFieldRequired, + templateFieldOptions, + setTemplateFieldOptions, + templateFieldDescription, + setTemplateFieldDescription, + templateFieldMultiColumn, + setTemplateFieldMultiColumn, + templateFieldColumnCount, + setTemplateFieldColumnCount, + templateFieldColumnNames, + setTemplateFieldColumnNames, + handleAddTemplateField, + templateFieldInputMode, + setTemplateFieldInputMode, + templateFieldShowMasterFieldList, + setTemplateFieldShowMasterFieldList, + templateFieldSelectedMasterFieldId, + setTemplateFieldSelectedMasterFieldId, + + // Load Template Dialog + isLoadTemplateDialogOpen, + setIsLoadTemplateDialogOpen, + sectionTemplates, + selectedTemplateId, + setSelectedTemplateId, + handleLoadTemplate, + + // Import Section Dialog + isImportSectionDialogOpen, + setIsImportSectionDialogOpen, + independentSections, + selectedImportSectionId, + setSelectedImportSectionId, + handleImportSection, + refreshIndependentSections, + getSectionUsage, + + // Import Field Dialog + isImportFieldDialogOpen, + setIsImportFieldDialogOpen, + selectedImportFieldId, + setSelectedImportFieldId, + handleImportField, + refreshIndependentFields, + getFieldUsage, + importFieldTargetSectionId, +}: ItemMasterDialogsProps) { + return ( + <> + + + + + + + {}} + /> + + + + + + {/* 항목 추가/수정 다이얼로그 - 데스크톱 */} + {!isMobile && ( + s.id === selectedSectionForField) || null} + selectedPage={selectedPage || null} + itemMasterFields={itemMasterFields} + handleAddField={handleAddField} + setIsColumnDialogOpen={setIsColumnDialogOpen} + setEditingColumnId={setEditingColumnId} + setColumnName={setColumnName} + setColumnKey={setColumnKey} + /> + )} + + {/* 항목 추가/수정 다이얼로그 - 모바일 (바텀시트) */} + {isMobile && ( + s.id === selectedSectionForField) || null} + selectedPage={selectedPage || null} + itemMasterFields={itemMasterFields} + handleAddField={handleAddField} + setIsColumnDialogOpen={setIsColumnDialogOpen} + setEditingColumnId={setEditingColumnId} + setColumnName={setColumnName} + setColumnKey={setColumnKey} + /> + )} + + {/* 텍스트박스 컬럼 추가/수정 다이얼로그 */} + + + + + + + + + + + {/* 섹션 불러오기 다이얼로그 */} + + + {/* 필드 불러오기 다이얼로그 */} + s.id === importFieldTargetSectionId)?.title + : undefined + } + /> + + ); +} diff --git a/src/components/items/ItemMasterDataManagement/components/index.ts b/src/components/items/ItemMasterDataManagement/components/index.ts index 8e7892bd..a7eae9ee 100644 --- a/src/components/items/ItemMasterDataManagement/components/index.ts +++ b/src/components/items/ItemMasterDataManagement/components/index.ts @@ -1,2 +1,6 @@ export { DraggableSection } from './DraggableSection'; -export { DraggableField } from './DraggableField'; \ No newline at end of file +export { DraggableField } from './DraggableField'; + +// 2025-12-24: Phase 2 UI 컴포넌트 분리 +export { AttributeTabContent } from './AttributeTabContent'; +// ItemMasterDialogs는 props가 너무 많아 사용하지 않음 (2025-12-24 결정) \ No newline at end of file diff --git a/src/components/items/ItemMasterDataManagement/contexts/ErrorAlertContext.tsx b/src/components/items/ItemMasterDataManagement/contexts/ErrorAlertContext.tsx new file mode 100644 index 00000000..3505b26b --- /dev/null +++ b/src/components/items/ItemMasterDataManagement/contexts/ErrorAlertContext.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { createContext, useContext, useState, useCallback, ReactNode } from 'react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { AlertCircle } from 'lucide-react'; + +interface ErrorAlertState { + open: boolean; + title: string; + message: string; +} + +interface ErrorAlertContextType { + showErrorAlert: (message: string, title?: string) => void; +} + +const ErrorAlertContext = createContext(null); + +/** + * 에러 알림 Context 사용 훅 + */ +export function useErrorAlert() { + const context = useContext(ErrorAlertContext); + if (!context) { + throw new Error('useErrorAlert must be used within ErrorAlertProvider'); + } + return context; +} + +interface ErrorAlertProviderProps { + children: ReactNode; +} + +/** + * 에러 알림 Provider + * ItemMasterDataManagement 컴포넌트에서 사용 + */ +export function ErrorAlertProvider({ children }: ErrorAlertProviderProps) { + const [errorAlert, setErrorAlert] = useState({ + open: false, + title: '오류', + message: '', + }); + + const showErrorAlert = useCallback((message: string, title: string = '오류') => { + setErrorAlert({ + open: true, + title, + message, + }); + }, []); + + const closeErrorAlert = useCallback(() => { + setErrorAlert(prev => ({ + ...prev, + open: false, + })); + }, []); + + return ( + + {children} + + {/* 에러 알림 다이얼로그 */} + !isOpen && closeErrorAlert()}> + + + + + {errorAlert.title} + + + {errorAlert.message} + + + + + 확인 + + + + + + ); +} diff --git a/src/components/items/ItemMasterDataManagement/contexts/index.ts b/src/components/items/ItemMasterDataManagement/contexts/index.ts new file mode 100644 index 00000000..1f705b8f --- /dev/null +++ b/src/components/items/ItemMasterDataManagement/contexts/index.ts @@ -0,0 +1 @@ +export { ErrorAlertProvider, useErrorAlert } from './ErrorAlertContext'; \ No newline at end of file diff --git a/src/components/items/ItemMasterDataManagement/hooks/index.ts b/src/components/items/ItemMasterDataManagement/hooks/index.ts index c5997da7..4ab043fa 100644 --- a/src/components/items/ItemMasterDataManagement/hooks/index.ts +++ b/src/components/items/ItemMasterDataManagement/hooks/index.ts @@ -17,4 +17,17 @@ export { useAttributeManagement } from './useAttributeManagement'; export type { UseAttributeManagementReturn } from './useAttributeManagement'; export { useTabManagement } from './useTabManagement'; -export type { UseTabManagementReturn, CustomTab, AttributeSubTab } from './useTabManagement'; \ No newline at end of file +export type { UseTabManagementReturn, CustomTab, AttributeSubTab } from './useTabManagement'; + +// 2025-12-24: 신규 훅 추가 +export { useInitialDataLoading } from './useInitialDataLoading'; +export type { UseInitialDataLoadingReturn } from './useInitialDataLoading'; + +export { useImportManagement } from './useImportManagement'; +export type { UseImportManagementReturn } from './useImportManagement'; + +export { useReorderManagement } from './useReorderManagement'; +export type { UseReorderManagementReturn } from './useReorderManagement'; + +export { useDeleteManagement } from './useDeleteManagement'; +export type { UseDeleteManagementReturn } from './useDeleteManagement'; \ No newline at end of file diff --git a/src/components/items/ItemMasterDataManagement/hooks/useDeleteManagement.ts b/src/components/items/ItemMasterDataManagement/hooks/useDeleteManagement.ts new file mode 100644 index 00000000..48ebcecd --- /dev/null +++ b/src/components/items/ItemMasterDataManagement/hooks/useDeleteManagement.ts @@ -0,0 +1,124 @@ +'use client'; + +import { useCallback } from 'react'; +import { useItemMaster } from '@/contexts/ItemMasterContext'; +import { toast } from 'sonner'; +import type { ItemPage, BOMItem } from '@/contexts/ItemMasterContext'; +import type { CustomTab, AttributeSubTab } from './useTabManagement'; +import type { MasterOption, OptionColumn } from '../types'; + +// 타입 alias (기존 호환성) +type UnitOption = MasterOption; +type MaterialOption = MasterOption; +type SurfaceTreatmentOption = MasterOption; + +export interface UseDeleteManagementReturn { + handleDeletePage: (pageId: number) => void; + handleDeleteSection: (pageId: number, sectionId: number) => void; + handleUnlinkField: (pageId: string, sectionId: string, fieldId: string) => Promise; + handleResetAllData: ( + setUnitOptions: React.Dispatch>, + setMaterialOptions: React.Dispatch>, + setSurfaceTreatmentOptions: React.Dispatch>, + setCustomAttributeOptions: React.Dispatch>>, + setAttributeColumns: React.Dispatch>>, + setBomItems: React.Dispatch>, + setCustomTabs: React.Dispatch>, + setAttributeSubTabs: React.Dispatch>, + ) => void; +} + +interface UseDeleteManagementProps { + itemPages: ItemPage[]; +} + +export function useDeleteManagement({ itemPages }: UseDeleteManagementProps): UseDeleteManagementReturn { + const { + deleteItemPage, + deleteSection, + unlinkFieldFromSection, + resetAllData, + } = useItemMaster(); + + // 페이지 삭제 핸들러 + const handleDeletePage = useCallback((pageId: number) => { + const pageToDelete = itemPages.find(p => p.id === pageId); + const sectionIds = pageToDelete?.sections.map(s => s.id) || []; + const fieldIds = pageToDelete?.sections.flatMap(s => s.fields?.map(f => f.id) || []) || []; + deleteItemPage(pageId); + console.log('페이지 삭제 완료:', { pageId, removedSections: sectionIds.length, removedFields: fieldIds.length }); + }, [itemPages, deleteItemPage]); + + // 섹션 삭제 핸들러 + const handleDeleteSection = useCallback((pageId: number, sectionId: number) => { + const page = itemPages.find(p => p.id === pageId); + const sectionToDelete = page?.sections.find(s => s.id === sectionId); + const fieldIds = sectionToDelete?.fields?.map(f => f.id) || []; + deleteSection(Number(sectionId)); + console.log('섹션 삭제 완료:', { sectionId, removedFields: fieldIds.length }); + }, [itemPages, deleteSection]); + + // 필드 연결 해제 핸들러 + const handleUnlinkField = useCallback(async (_pageId: string, sectionId: string, fieldId: string) => { + try { + await unlinkFieldFromSection(Number(sectionId), Number(fieldId)); + console.log('필드 연결 해제 완료:', fieldId); + } catch (error) { + console.error('필드 연결 해제 실패:', error); + toast.error('필드 연결 해제에 실패했습니다'); + } + }, [unlinkFieldFromSection]); + + // 전체 데이터 초기화 핸들러 + const handleResetAllData = useCallback(( + setUnitOptions: React.Dispatch>, + setMaterialOptions: React.Dispatch>, + setSurfaceTreatmentOptions: React.Dispatch>, + setCustomAttributeOptions: React.Dispatch>>, + setAttributeColumns: React.Dispatch>>, + setBomItems: React.Dispatch>, + setCustomTabs: React.Dispatch>, + setAttributeSubTabs: React.Dispatch>, + ) => { + if (!confirm('⚠️ 경고: 모든 품목기준관리 데이터(계층구조, 섹션, 항목, 속성)를 초기화하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다!')) { + return; + } + + try { + resetAllData(); + + setUnitOptions([]); + setMaterialOptions([]); + setSurfaceTreatmentOptions([]); + setCustomAttributeOptions({}); + setAttributeColumns({}); + setBomItems([]); + + setCustomTabs([ + { id: 'hierarchy', label: '계층구조', icon: 'FolderTree', isDefault: true, order: 1 }, + { id: 'sections', label: '섹션', icon: 'Layers', isDefault: true, order: 2 }, + { id: 'items', label: '항목', icon: 'ListTree', isDefault: true, order: 3 }, + { id: 'attributes', label: '속성', icon: 'Settings', isDefault: true, order: 4 } + ]); + + setAttributeSubTabs([]); + + console.log('🗑️ 모든 품목기준관리 데이터가 초기화되었습니다'); + toast.success('✅ 모든 데이터가 초기화되었습니다!\n계층구조, 섹션, 항목, 속성이 모두 삭제되었습니다.'); + + setTimeout(() => { + window.location.reload(); + }, 1500); + } catch (error) { + toast.error('초기화 중 오류가 발생했습니다'); + console.error('Reset error:', error); + } + }, [resetAllData]); + + return { + handleDeletePage, + handleDeleteSection, + handleUnlinkField, + handleResetAllData, + }; +} diff --git a/src/components/items/ItemMasterDataManagement/hooks/useErrorAlert.ts b/src/components/items/ItemMasterDataManagement/hooks/useErrorAlert.ts new file mode 100644 index 00000000..179a921d --- /dev/null +++ b/src/components/items/ItemMasterDataManagement/hooks/useErrorAlert.ts @@ -0,0 +1,48 @@ +'use client'; + +import { useState, useCallback } from 'react'; + +export interface ErrorAlertState { + open: boolean; + title: string; + message: string; +} + +export interface UseErrorAlertReturn { + errorAlert: ErrorAlertState; + showErrorAlert: (message: string, title?: string) => void; + closeErrorAlert: () => void; +} + +/** + * 에러 알림 다이얼로그 상태 관리 훅 + * AlertDialog로 에러 메시지를 표시할 때 사용 + */ +export function useErrorAlert(): UseErrorAlertReturn { + const [errorAlert, setErrorAlert] = useState({ + open: false, + title: '오류', + message: '', + }); + + const showErrorAlert = useCallback((message: string, title: string = '오류') => { + setErrorAlert({ + open: true, + title, + message, + }); + }, []); + + const closeErrorAlert = useCallback(() => { + setErrorAlert(prev => ({ + ...prev, + open: false, + })); + }, []); + + return { + errorAlert, + showErrorAlert, + closeErrorAlert, + }; +} \ No newline at end of file diff --git a/src/components/items/ItemMasterDataManagement/hooks/useFieldManagement.ts b/src/components/items/ItemMasterDataManagement/hooks/useFieldManagement.ts index 53aaab9f..36e700f3 100644 --- a/src/components/items/ItemMasterDataManagement/hooks/useFieldManagement.ts +++ b/src/components/items/ItemMasterDataManagement/hooks/useFieldManagement.ts @@ -3,9 +3,11 @@ import { useState, useEffect } from 'react'; import { toast } from 'sonner'; import { useItemMaster } from '@/contexts/ItemMasterContext'; +import { useErrorAlert } from '../contexts'; import type { ItemPage, ItemField, ItemMasterField, FieldDisplayCondition } from '@/contexts/ItemMasterContext'; import { type ConditionalFieldConfig } from '../components/ConditionalDisplayUI'; import { fieldService } from '../services'; +import { ApiError } from '@/lib/api/error-handler'; export interface UseFieldManagementReturn { // 다이얼로그 상태 @@ -79,6 +81,9 @@ export function useFieldManagement(): UseFieldManagementReturn { updateItemMasterField, } = useItemMaster(); + // 에러 알림 (AlertDialog로 표시) + const { showErrorAlert } = useErrorAlert(); + // 다이얼로그 상태 const [isFieldDialogOpen, setIsFieldDialogOpen] = useState(false); const [selectedSectionForField, setSelectedSectionForField] = useState(null); @@ -238,7 +243,23 @@ export function useFieldManagement(): UseFieldManagementReturn { resetFieldForm(); } catch (error) { console.error('필드 처리 실패:', error); - toast.error('항목 처리에 실패했습니다'); + + // 422 ValidationException 상세 메시지 처리 (field_key 중복/예약어, field_name 중복 등) + if (error instanceof ApiError) { + console.log('🔍 ApiError.errors:', error.errors); // 디버깅용 + + // errors 객체에서 첫 번째 에러 메시지 추출 → AlertDialog로 표시 + if (error.errors && Object.keys(error.errors).length > 0) { + const firstKey = Object.keys(error.errors)[0]; + const firstError = error.errors[firstKey]; + const errorMessage = Array.isArray(firstError) ? firstError[0] : firstError; + showErrorAlert(errorMessage, '항목 저장 실패'); + } else { + showErrorAlert(error.message, '항목 저장 실패'); + } + } else { + showErrorAlert('항목 처리에 실패했습니다', '오류'); + } } }; diff --git a/src/components/items/ItemMasterDataManagement/hooks/useImportManagement.ts b/src/components/items/ItemMasterDataManagement/hooks/useImportManagement.ts new file mode 100644 index 00000000..71db9bf3 --- /dev/null +++ b/src/components/items/ItemMasterDataManagement/hooks/useImportManagement.ts @@ -0,0 +1,112 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { useItemMaster } from '@/contexts/ItemMasterContext'; +import { getErrorMessage } from '@/lib/api/error-handler'; +import { toast } from 'sonner'; +import type { ItemPage } from '@/contexts/ItemMasterContext'; + +export interface UseImportManagementReturn { + // 섹션 Import 상태 + isImportSectionDialogOpen: boolean; + setIsImportSectionDialogOpen: React.Dispatch>; + selectedImportSectionId: number | null; + setSelectedImportSectionId: React.Dispatch>; + + // 필드 Import 상태 + isImportFieldDialogOpen: boolean; + setIsImportFieldDialogOpen: React.Dispatch>; + selectedImportFieldId: number | null; + setSelectedImportFieldId: React.Dispatch>; + importFieldTargetSectionId: number | null; + setImportFieldTargetSectionId: React.Dispatch>; + + // 핸들러 + handleImportSection: (selectedPageId: number | null) => Promise; + handleImportField: (selectedPage: ItemPage | null) => Promise; + handleCloneSection: (sectionId: number) => Promise; +} + +export function useImportManagement(): UseImportManagementReturn { + const { + linkSectionToPage, + linkFieldToSection, + cloneSection, + } = useItemMaster(); + + // 섹션 Import 상태 + const [isImportSectionDialogOpen, setIsImportSectionDialogOpen] = useState(false); + const [selectedImportSectionId, setSelectedImportSectionId] = useState(null); + + // 필드 Import 상태 + const [isImportFieldDialogOpen, setIsImportFieldDialogOpen] = useState(false); + const [selectedImportFieldId, setSelectedImportFieldId] = useState(null); + const [importFieldTargetSectionId, setImportFieldTargetSectionId] = useState(null); + + // 섹션 불러오기 핸들러 + const handleImportSection = useCallback(async (selectedPageId: number | null) => { + if (!selectedPageId || !selectedImportSectionId) return; + + try { + await linkSectionToPage(selectedPageId, selectedImportSectionId); + toast.success('섹션을 불러왔습니다.'); + setSelectedImportSectionId(null); + } catch (error) { + console.error('섹션 불러오기 실패:', error); + toast.error(getErrorMessage(error)); + } + }, [selectedImportSectionId, linkSectionToPage]); + + // 필드 불러오기 핸들러 + const handleImportField = useCallback(async (selectedPage: ItemPage | null) => { + if (!importFieldTargetSectionId || !selectedImportFieldId) return; + + try { + // 해당 섹션의 마지막 순서 + 1로 설정 + const targetSection = selectedPage?.sections.find(s => s.id === importFieldTargetSectionId); + const existingFieldsCount = targetSection?.fields?.length ?? 0; + const newOrderNo = existingFieldsCount; + + await linkFieldToSection(importFieldTargetSectionId, selectedImportFieldId, newOrderNo); + toast.success('필드를 섹션에 연결했습니다.'); + + setSelectedImportFieldId(null); + setImportFieldTargetSectionId(null); + } catch (error) { + console.error('필드 불러오기 실패:', error); + toast.error(getErrorMessage(error)); + } + }, [importFieldTargetSectionId, selectedImportFieldId, linkFieldToSection]); + + // 섹션 복제 핸들러 + const handleCloneSection = useCallback(async (sectionId: number) => { + try { + await cloneSection(sectionId); + toast.success('섹션이 복제되었습니다.'); + } catch (error) { + console.error('섹션 복제 실패:', error); + toast.error(getErrorMessage(error)); + } + }, [cloneSection]); + + return { + // 섹션 Import + isImportSectionDialogOpen, + setIsImportSectionDialogOpen, + selectedImportSectionId, + setSelectedImportSectionId, + + // 필드 Import + isImportFieldDialogOpen, + setIsImportFieldDialogOpen, + selectedImportFieldId, + setSelectedImportFieldId, + importFieldTargetSectionId, + setImportFieldTargetSectionId, + + // 핸들러 + handleImportSection, + handleImportField, + handleCloneSection, + }; +} \ No newline at end of file diff --git a/src/components/items/ItemMasterDataManagement/hooks/useInitialDataLoading.ts b/src/components/items/ItemMasterDataManagement/hooks/useInitialDataLoading.ts new file mode 100644 index 00000000..5a2c1d06 --- /dev/null +++ b/src/components/items/ItemMasterDataManagement/hooks/useInitialDataLoading.ts @@ -0,0 +1,176 @@ +'use client'; + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useItemMaster } from '@/contexts/ItemMasterContext'; +import { useItemMasterStore } from '@/stores/item-master/useItemMasterStore'; +import { itemMasterApi } from '@/lib/api/item-master'; +import { getErrorMessage, ApiError } from '@/lib/api/error-handler'; +import { + transformPagesResponse, + transformSectionsResponse, + transformSectionTemplatesResponse, + transformFieldsResponse, + transformCustomTabsResponse, + transformUnitOptionsResponse, + transformSectionTemplateFromSection, +} from '@/lib/api/transformers'; +import { toast } from 'sonner'; +import type { CustomTab } from './useTabManagement'; +import type { MasterOption } from '../types'; + +// 타입 alias +type UnitOption = MasterOption; + +export interface UseInitialDataLoadingReturn { + isInitialLoading: boolean; + error: string | null; + reload: () => Promise; +} + +interface UseInitialDataLoadingProps { + setCustomTabs: React.Dispatch>; + setUnitOptions: React.Dispatch>; +} + +export function useInitialDataLoading({ + setCustomTabs, + setUnitOptions, +}: UseInitialDataLoadingProps): UseInitialDataLoadingReturn { + const { + loadItemPages, + loadSectionTemplates, + loadItemMasterFields, + loadIndependentSections, + loadIndependentFields, + } = useItemMaster(); + + // ✅ 2025-12-24: Zustand store 연동 + const initFromApi = useItemMasterStore((state) => state.initFromApi); + + const [isInitialLoading, setIsInitialLoading] = useState(true); + const [error, setError] = useState(null); + + // 초기 로딩이 이미 실행되었는지 추적하는 ref + const hasInitialLoadRun = useRef(false); + + const loadInitialData = useCallback(async () => { + try { + setIsInitialLoading(true); + setError(null); + + // ✅ Zustand store 초기화 (정규화된 상태로 저장) + // Context와 병행 운영 - 점진적 마이그레이션 + try { + await initFromApi(); + console.log('✅ [Zustand] Store initialized'); + } catch (zustandError) { + // Zustand 초기화 실패해도 Context로 fallback + console.warn('⚠️ [Zustand] Init failed, falling back to Context:', zustandError); + } + + const data = await itemMasterApi.init(); + + // 1. 페이지 데이터 로드 (섹션이 이미 포함되어 있음) + const transformedPages = transformPagesResponse(data.pages); + loadItemPages(transformedPages); + + // 2. 독립 섹션 로드 (모든 재사용 가능 섹션) + if (data.sections && data.sections.length > 0) { + const transformedSections = transformSectionsResponse(data.sections); + loadIndependentSections(transformedSections); + console.log('✅ 독립 섹션 로드:', transformedSections.length); + } + + // 3. 섹션 템플릿 로드 + if (data.sectionTemplates && data.sectionTemplates.length > 0) { + const transformedTemplates = transformSectionTemplatesResponse(data.sectionTemplates); + loadSectionTemplates(transformedTemplates); + } else if (data.sections && data.sections.length > 0) { + const templates = data.sections + .filter((s: { is_template?: boolean }) => s.is_template) + .map(transformSectionTemplateFromSection); + if (templates.length > 0) { + loadSectionTemplates(templates); + } + } + + // 4. 필드 로드 + if (data.fields && data.fields.length > 0) { + const transformedFields = transformFieldsResponse(data.fields); + + const independentOnlyFields = transformedFields.filter( + f => f.section_id === null || f.section_id === undefined + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + loadItemMasterFields(transformedFields as any); + loadIndependentFields(independentOnlyFields); + + console.log('✅ 필드 로드:', { + total: transformedFields.length, + independent: independentOnlyFields.length, + }); + } + + // 5. 커스텀 탭 로드 + if (data.customTabs && data.customTabs.length > 0) { + const transformedTabs = transformCustomTabsResponse(data.customTabs); + setCustomTabs(transformedTabs); + } + + // 6. 단위 옵션 로드 + if (data.unitOptions && data.unitOptions.length > 0) { + const transformedUnits = transformUnitOptionsResponse(data.unitOptions); + setUnitOptions(transformedUnits); + } + + console.log('✅ Initial data loaded:', { + pages: data.pages?.length || 0, + sections: data.sections?.length || 0, + fields: data.fields?.length || 0, + customTabs: data.customTabs?.length || 0, + unitOptions: data.unitOptions?.length || 0, + }); + + } catch (err) { + if (err instanceof ApiError && err.errors) { + const errorMessages = Object.entries(err.errors) + .map(([field, messages]) => `${field}: ${messages.join(', ')}`) + .join('\n'); + toast.error(errorMessages); + setError('입력값을 확인해주세요.'); + } else { + const errorMessage = getErrorMessage(err); + setError(errorMessage); + toast.error(errorMessage); + } + console.error('❌ Failed to load initial data:', err); + } finally { + setIsInitialLoading(false); + } + }, [ + loadItemPages, + loadSectionTemplates, + loadItemMasterFields, + loadIndependentSections, + loadIndependentFields, + setCustomTabs, + setUnitOptions, + ]); + + // 초기 로딩은 한 번만 실행 (의존성 배열의 함수들이 불안정해도 무한 루프 방지) + useEffect(() => { + if (hasInitialLoadRun.current) { + return; + } + hasInitialLoadRun.current = true; + loadInitialData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { + isInitialLoading, + error, + reload: loadInitialData, + }; +} \ No newline at end of file diff --git a/src/components/items/ItemMasterDataManagement/hooks/useMasterFieldManagement.ts b/src/components/items/ItemMasterDataManagement/hooks/useMasterFieldManagement.ts index 256ff94c..5fff123d 100644 --- a/src/components/items/ItemMasterDataManagement/hooks/useMasterFieldManagement.ts +++ b/src/components/items/ItemMasterDataManagement/hooks/useMasterFieldManagement.ts @@ -3,8 +3,10 @@ import { useState } from 'react'; import { toast } from 'sonner'; import { useItemMaster } from '@/contexts/ItemMasterContext'; +import { useErrorAlert } from '../contexts'; import type { ItemMasterField } from '@/contexts/ItemMasterContext'; import { masterFieldService } from '../services'; +import { ApiError } from '@/lib/api/error-handler'; /** * @deprecated 2025-11-27: item_fields로 통합됨. @@ -44,10 +46,10 @@ export interface UseMasterFieldManagementReturn { setNewMasterFieldColumnNames: React.Dispatch>; // 핸들러 - handleAddMasterField: () => void; + handleAddMasterField: () => Promise; handleEditMasterField: (field: ItemMasterField) => void; - handleUpdateMasterField: () => void; - handleDeleteMasterField: (id: number) => void; + handleUpdateMasterField: () => Promise; + handleDeleteMasterField: (id: number) => Promise; resetMasterFieldForm: () => void; } @@ -59,6 +61,9 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn { deleteItemMasterField, } = useItemMaster(); + // 에러 알림 (AlertDialog로 표시) + const { showErrorAlert } = useErrorAlert(); + // 다이얼로그 상태 const [isMasterFieldDialogOpen, setIsMasterFieldDialogOpen] = useState(false); const [editingMasterFieldId, setEditingMasterFieldId] = useState(null); @@ -77,7 +82,7 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn { const [newMasterFieldColumnNames, setNewMasterFieldColumnNames] = useState(['컬럼1', '컬럼2']); // 마스터 항목 추가 - const handleAddMasterField = () => { + const handleAddMasterField = async () => { if (!newMasterFieldName.trim() || !newMasterFieldKey.trim()) { toast.error('항목명과 필드 키를 입력해주세요'); return; @@ -106,9 +111,30 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn { }, }; - addItemMasterField(newMasterFieldData as any); - resetMasterFieldForm(); - toast.success('항목이 추가되었습니다'); + try { + await addItemMasterField(newMasterFieldData as any); + resetMasterFieldForm(); + toast.success('항목이 추가되었습니다'); + } catch (error) { + console.error('항목 추가 실패:', error); + + // 422 ValidationException 상세 메시지 처리 (field_key 중복/예약어) → AlertDialog로 표시 + if (error instanceof ApiError) { + console.log('🔍 ApiError.errors:', error.errors); // 디버깅용 + + // errors 객체에서 첫 번째 에러 메시지 추출 + if (error.errors && Object.keys(error.errors).length > 0) { + const firstKey = Object.keys(error.errors)[0]; + const firstError = error.errors[firstKey]; + const errorMessage = Array.isArray(firstError) ? firstError[0] : firstError; + showErrorAlert(errorMessage, '항목 추가 실패'); + } else { + showErrorAlert(error.message, '항목 추가 실패'); + } + } else { + showErrorAlert('항목 추가에 실패했습니다', '오류'); + } + } }; // 마스터 항목 수정 시작 @@ -134,7 +160,7 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn { }; // 마스터 항목 업데이트 - const handleUpdateMasterField = () => { + const handleUpdateMasterField = async () => { if (!editingMasterFieldId || !newMasterFieldName.trim() || !newMasterFieldKey.trim()) { toast.error('항목명과 필드 키를 입력해주세요'); return; @@ -159,16 +185,47 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn { }, }; - updateItemMasterField(editingMasterFieldId, updateData); - resetMasterFieldForm(); - toast.success('항목이 수정되었습니다'); + try { + await updateItemMasterField(editingMasterFieldId, updateData); + resetMasterFieldForm(); + toast.success('항목이 수정되었습니다'); + } catch (error) { + console.error('항목 수정 실패:', error); + + // 422 ValidationException 상세 메시지 처리 (field_key 중복/예약어, field_name 중복 등) → AlertDialog로 표시 + if (error instanceof ApiError) { + console.log('🔍 ApiError.errors:', error.errors); // 디버깅용 + + // errors 객체에서 첫 번째 에러 메시지 추출 + if (error.errors && Object.keys(error.errors).length > 0) { + const firstKey = Object.keys(error.errors)[0]; + const firstError = error.errors[firstKey]; + const errorMessage = Array.isArray(firstError) ? firstError[0] : firstError; + showErrorAlert(errorMessage, '항목 수정 실패'); + } else { + showErrorAlert(error.message, '항목 수정 실패'); + } + } else { + showErrorAlert('항목 수정에 실패했습니다', '오류'); + } + } }; // 항목 삭제 (2025-11-27: 마스터 항목 → 항목으로 통합) - const handleDeleteMasterField = (id: number) => { + const handleDeleteMasterField = async (id: number) => { if (confirm('이 항목을 삭제하시겠습니까?\n(섹션에서 사용 중인 경우 연결도 함께 해제됩니다)')) { - deleteItemMasterField(id); - toast.success('항목이 삭제되었습니다'); + try { + await deleteItemMasterField(id); + toast.success('항목이 삭제되었습니다'); + } catch (error) { + console.error('항목 삭제 실패:', error); + + if (error instanceof ApiError) { + toast.error(error.message); + } else { + toast.error('항목 삭제에 실패했습니다'); + } + } } }; diff --git a/src/components/items/ItemMasterDataManagement/hooks/useReorderManagement.ts b/src/components/items/ItemMasterDataManagement/hooks/useReorderManagement.ts new file mode 100644 index 00000000..82fb36b2 --- /dev/null +++ b/src/components/items/ItemMasterDataManagement/hooks/useReorderManagement.ts @@ -0,0 +1,84 @@ +'use client'; + +import { useCallback } from 'react'; +import { useItemMaster } from '@/contexts/ItemMasterContext'; +import { toast } from 'sonner'; +import type { ItemPage } from '@/contexts/ItemMasterContext'; + +export interface UseReorderManagementReturn { + moveSection: (selectedPage: ItemPage | null, dragIndex: number, hoverIndex: number) => Promise; + moveField: (selectedPage: ItemPage | null, sectionId: number, dragFieldId: number, hoverFieldId: number) => Promise; +} + +export function useReorderManagement(): UseReorderManagementReturn { + const { + reorderSections, + reorderFields, + } = useItemMaster(); + + // 섹션 순서 변경 핸들러 (드래그앤드롭) + const moveSection = useCallback(async ( + selectedPage: ItemPage | null, + dragIndex: number, + hoverIndex: number + ) => { + if (!selectedPage) return; + + const sections = [...selectedPage.sections]; + const [draggedSection] = sections.splice(dragIndex, 1); + sections.splice(hoverIndex, 0, draggedSection); + + const sectionIds = sections.map(s => s.id); + + try { + await reorderSections(selectedPage.id, sectionIds); + toast.success('섹션 순서가 변경되었습니다'); + } catch (error) { + console.error('섹션 순서 변경 실패:', error); + toast.error('섹션 순서 변경에 실패했습니다'); + } + }, [reorderSections]); + + // 필드 순서 변경 핸들러 + const moveField = useCallback(async ( + selectedPage: ItemPage | null, + sectionId: number, + dragFieldId: number, + hoverFieldId: number + ) => { + if (!selectedPage) return; + const section = selectedPage.sections.find(s => s.id === sectionId); + if (!section || !section.fields) return; + + // 동일 필드면 스킵 + if (dragFieldId === hoverFieldId) return; + + // 정렬된 배열에서 ID로 인덱스 찾기 + const sortedFields = [...section.fields].sort((a, b) => (a.order_no ?? 0) - (b.order_no ?? 0)); + const dragIndex = sortedFields.findIndex(f => f.id === dragFieldId); + const hoverIndex = sortedFields.findIndex(f => f.id === hoverFieldId); + + // 유효하지 않은 인덱스 체크 + if (dragIndex === -1 || hoverIndex === -1) { + return; + } + + // 드래그된 필드를 제거하고 새 위치에 삽입 + const [draggedField] = sortedFields.splice(dragIndex, 1); + sortedFields.splice(hoverIndex, 0, draggedField); + + const newFieldIds = sortedFields.map(f => f.id); + + try { + await reorderFields(sectionId, newFieldIds); + toast.success('항목 순서가 변경되었습니다'); + } catch (error) { + toast.error('항목 순서 변경에 실패했습니다'); + } + }, [reorderFields]); + + return { + moveSection, + moveField, + }; +} \ No newline at end of file diff --git a/src/components/items/ItemMasterDataManagement/hooks/useTemplateManagement.ts b/src/components/items/ItemMasterDataManagement/hooks/useTemplateManagement.ts index c083e2c5..b2de6f90 100644 --- a/src/components/items/ItemMasterDataManagement/hooks/useTemplateManagement.ts +++ b/src/components/items/ItemMasterDataManagement/hooks/useTemplateManagement.ts @@ -3,8 +3,10 @@ import { useState } from 'react'; import { toast } from 'sonner'; import { useItemMaster } from '@/contexts/ItemMasterContext'; +import { useErrorAlert } from '../contexts'; import type { ItemPage, SectionTemplate, TemplateField, BOMItem, ItemMasterField } from '@/contexts/ItemMasterContext'; import { templateService } from '../services'; +import { ApiError } from '@/lib/api/error-handler'; export interface UseTemplateManagementReturn { // 섹션 템플릿 다이얼로그 상태 @@ -112,6 +114,9 @@ export function useTemplateManagement(): UseTemplateManagementReturn { deleteBOMItem, } = useItemMaster(); + // 에러 알림 (AlertDialog로 표시) + const { showErrorAlert } = useErrorAlert(); + // 섹션 템플릿 다이얼로그 상태 const [isSectionTemplateDialogOpen, setIsSectionTemplateDialogOpen] = useState(false); const [editingSectionTemplateId, setEditingSectionTemplateId] = useState(null); @@ -348,7 +353,23 @@ export function useTemplateManagement(): UseTemplateManagementReturn { resetTemplateFieldForm(); } catch (error) { console.error('항목 처리 실패:', error); - toast.error('항목 처리에 실패했습니다'); + + // 422 ValidationException 상세 메시지 처리 (field_key 중복/예약어, field_name 중복 등) → AlertDialog로 표시 + if (error instanceof ApiError) { + console.log('🔍 ApiError.errors:', error.errors); // 디버깅용 + + // errors 객체에서 첫 번째 에러 메시지 추출 + if (error.errors && Object.keys(error.errors).length > 0) { + const firstKey = Object.keys(error.errors)[0]; + const firstError = error.errors[firstKey]; + const errorMessage = Array.isArray(firstError) ? firstError[0] : firstError; + showErrorAlert(errorMessage, '항목 저장 실패'); + } else { + showErrorAlert(error.message, '항목 저장 실패'); + } + } else { + showErrorAlert('항목 처리에 실패했습니다', '오류'); + } } }; diff --git a/src/contexts/ItemMasterContext.tsx b/src/contexts/ItemMasterContext.tsx index 54b93912..60053a98 100644 --- a/src/contexts/ItemMasterContext.tsx +++ b/src/contexts/ItemMasterContext.tsx @@ -22,392 +22,56 @@ import type { FieldUsageResponse, } from '@/types/item-master-api'; -// ===== Type Definitions ===== +// 타입 정의는 별도 파일에서 import +export type { + BendingDetail, + BOMLine, + SpecificationMaster, + MaterialItemName, + ItemRevision, + ItemMaster, + ItemCategory, + ItemUnit, + ItemMaterial, + SurfaceTreatment, + PartTypeOption, + PartUsageOption, + GuideRailOption, + ItemFieldProperty, + ItemMasterField, + FieldDisplayCondition, + ItemField, + BOMItem, + ItemSection, + ItemPage, + TemplateField, + SectionTemplate, +} from '@/types/item-master.types'; -// 전개도 상세 정보 -export interface BendingDetail { - id: string; - no: number; // 번호 - input: number; // 입력 - elongation: number; // 연신율 (기본값 -1) - calculated: number; // 연신율 계산 후 - sum: number; // 합계 - shaded: boolean; // 음영 여부 - aAngle?: number; // A각 -} - -// 부품구성표(BOM, Bill of Materials) - 자재 명세서 -export interface BOMLine { - id: string; - childItemCode: string; // 구성 품목 코드 - childItemName: string; // 구성 품목명 - quantity: number; // 기준 수량 - unit: string; // 단위 - unitPrice?: number; // 단가 - quantityFormula?: string; // 수량 계산식 (예: "W * 2", "H + 100") - note?: string; // 비고 - // 절곡품 관련 (하위 절곡 부품용) - isBending?: boolean; - bendingDiagram?: string; // 전개도 이미지 URL - bendingDetails?: BendingDetail[]; // 전개도 상세 데이터 -} - -// 규격 마스터 (원자재/부자재용) -export interface SpecificationMaster { - id: string; - specificationCode: string; // 규격 코드 (예: 1.6T x 1219 x 2438) - itemType: 'RM' | 'SM'; // 원자재 | 부자재 - itemName?: string; // 품목명 (예: SPHC-SD, SPCC-SD) - 품목명별 규격 필터링용 - fieldCount: '1' | '2' | '3'; // 너비 입력 개수 - thickness: string; // 두께 - widthA: string; // 너비A - widthB?: string; // 너비B - widthC?: string; // 너비C - length: string; // 길이 - description?: string; // 설명 - isActive: boolean; // 활성 여부 - createdAt?: string; - updatedAt?: string; -} - -// 원자재/부자재 품목명 마스터 -export interface MaterialItemName { - id: string; - itemType: 'RM' | 'SM'; // 원자재 | 부자재 - itemName: string; // 품목명 (예: "SPHC-SD", "STS430") - category?: string; // 분류 (예: "냉연", "열연", "스테인리스") - description?: string; // 설명 - isActive: boolean; // 활성 여부 - createdAt: string; - updatedAt?: string; -} - -// 품목 수정 이력 -export interface ItemRevision { - revisionNumber: number; // 수정 차수 (1차, 2차, 3차...) - revisionDate: string; // 수정일 - revisionBy: string; // 수정자 - revisionReason?: string; // 수정 사유 - previousData: any; // 이전 버전의 전체 데이터 -} - -// 품목 마스터 -export interface ItemMaster { - id: string; - itemCode: string; - itemName: string; - itemType: 'FG' | 'PT' | 'SM' | 'RM' | 'CS'; // 제품, 부품, 부자재, 원자재, 소모품 - productCategory?: 'SCREEN' | 'STEEL'; // 제품 카테고리 (스크린/철재) - partType?: 'ASSEMBLY' | 'BENDING' | 'PURCHASED'; // 부품 유형 (조립/절곡/구매) - partUsage?: 'GUIDE_RAIL' | 'BOTTOM_FINISH' | 'CASE' | 'DOOR' | 'BRACKET' | 'GENERAL'; // 부품 용도 - unit: string; - category1?: string; - category2?: string; - category3?: string; - specification?: string; - isVariableSize?: boolean; - isActive?: boolean; // 품목 활성/비활성 (제품/부품/원자재/부자재만 사용) - lotAbbreviation?: string; // 로트 약자 (제품만 사용) - purchasePrice?: number; - marginRate?: number; - processingCost?: number; - laborCost?: number; - installCost?: number; - salesPrice?: number; - safetyStock?: number; - leadTime?: number; - bom?: BOMLine[]; // 부품구성표(BOM) - 자재 명세서 - bomCategories?: string[]; // 견적산출용 샘플 제품의 BOM 카테고리 (예: ['motor', 'guide-rail']) - - // 인정 정보 - certificationNumber?: string; // 인정번호 - certificationStartDate?: string; // 인정 유효기간 시작일 - certificationEndDate?: string; // 인정 유효기간 종료일 - specificationFile?: string; // 시방서 파일 (Base64 또는 URL) - specificationFileName?: string; // 시방서 파일명 - certificationFile?: string; // 인정서 파일 (Base64 또는 URL) - certificationFileName?: string; // 인정서 파일명 - note?: string; // 비고 (제품만 사용) - - // 조립 부품 관련 필드 - installationType?: string; // 설치 유형 (wall: 벽면형, side: 측면형, steel: 스틸, iron: 철재) - assemblyType?: string; // 종류 (M, T, C, D, S, U 등) - sideSpecWidth?: string; // 측면 규격 가로 (mm) - sideSpecHeight?: string; // 측면 규격 세로 (mm) - assemblyLength?: string; // 길이 (2438, 3000, 3500, 4000, 4300 등) - - // 가이드레일 관련 필드 - guideRailModelType?: string; // 가이드레일 모델 유형 - guideRailModel?: string; // 가이드레일 모델 - - // 절곡품 관련 (부품 유형이 BENDING인 경우) - bendingDiagram?: string; // 전개도 이미지 URL - bendingDetails?: BendingDetail[]; // 전개도 상세 데이터 - material?: string; // 재질 (EGI 1.55T, SUS 1.2T 등) - length?: string; // 길이/목함 (mm) - - // 버전 관리 - currentRevision: number; // 현재 차수 (0 = 최초, 1 = 1차 수정...) - revisions?: ItemRevision[]; // 수정 이력 - isFinal: boolean; // 최종 확정 여부 - finalizedDate?: string; // 최종 확정일 - finalizedBy?: string; // 최종 확정자 - - createdAt: string; -} - -// 품목 기준정보 관리 (Master Data) -export interface ItemCategory { - id: string; - categoryType: 'PRODUCT' | 'PART' | 'MATERIAL' | 'SUB_MATERIAL'; // 품목 구분 - category1: string; // 대분류 - category2?: string; // 중분류 - category3?: string; // 소분류 - code?: string; // 코드 (자동생성 또는 수동입력) - description?: string; - isActive: boolean; - createdAt: string; - updatedAt?: string; -} - -export interface ItemUnit { - id: string; - unitCode: string; // 단위 코드 (EA, SET, M, KG, L 등) - unitName: string; // 단위명 - description?: string; - isActive: boolean; - createdAt: string; - updatedAt?: string; -} - -export interface ItemMaterial { - id: string; - materialCode: string; // 재질 코드 - materialName: string; // 재질명 (EGI 1.55T, SUS 1.2T 등) - materialType: 'STEEL' | 'ALUMINUM' | 'PLASTIC' | 'OTHER'; // 재질 유형 - thickness?: string; // 두께 (1.2T, 1.6T 등) - description?: string; - isActive: boolean; - createdAt: string; - updatedAt?: string; -} - -export interface SurfaceTreatment { - id: string; - treatmentCode: string; // 처리 코드 - treatmentName: string; // 처리명 (무도장, 파우더도장, 아노다이징 등) - treatmentType: 'PAINTING' | 'COATING' | 'PLATING' | 'NONE'; // 처리 유형 - description?: string; - isActive: boolean; - createdAt: string; - updatedAt?: string; -} - -export interface PartTypeOption { - id: string; - partType: 'ASSEMBLY' | 'BENDING' | 'PURCHASED'; // 부품 유형 - optionCode: string; // 옵션 코드 - optionName: string; // 옵션명 - description?: string; - isActive: boolean; - createdAt: string; - updatedAt?: string; -} - -export interface PartUsageOption { - id: string; - usageCode: string; // 용도 코드 - usageName: string; // 용도명 (가이드레일, 하단마감재, 케이스 등) - description?: string; - isActive: boolean; - createdAt: string; - updatedAt?: string; -} - -export interface GuideRailOption { - id: string; - optionType: 'MODEL_TYPE' | 'MODEL' | 'CERTIFICATION' | 'SHAPE' | 'FINISH' | 'LENGTH'; // 옵션 유형 - optionCode: string; // 옵션 코드 - optionName: string; // 옵션명 - parentOption?: string; // 상위 옵션 (종속 관계) - description?: string; - isActive: boolean; - createdAt: string; - updatedAt?: string; -} - -// ===== 품목기준관리 계층구조 ===== - -// 항목 속성 -export interface ItemFieldProperty { - id?: string; // 속성 ID (properties 배열에서 사용) - key?: string; // 속성 키 (properties 배열에서 사용) - label?: string; // 속성 라벨 (properties 배열에서 사용) - type?: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea'; // 속성 타입 (properties 배열에서 사용) - inputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea' | 'section'; // 입력방식 - required: boolean; // 필수 여부 - row: number; // 행 위치 - col: number; // 열 위치 - options?: string[]; // 드롭다운 옵션 (입력방식이 dropdown일 경우) - defaultValue?: string; // 기본값 - placeholder?: string; // 플레이스홀더 - multiColumn?: boolean; // 다중 컬럼 사용 여부 - columnCount?: number; // 컬럼 개수 - columnNames?: string[]; // 각 컬럼의 이름 -} - -// 항목 마스터 (재사용 가능한 항목 템플릿) - MasterFieldResponse와 정확히 일치 -export interface ItemMasterField { - id: number; - tenant_id: number; - field_name: string; - field_key?: string | null; // 2025-11-28: field_key 추가 (형식: {ID}_{사용자입력}) - field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea'; // API와 동일 - category: string | null; - description: string | null; - is_common: boolean; // 공통 필드 여부 - is_required?: boolean; // 필수 여부 (API에서 반환) - default_value: string | null; // 기본값 - options: Array<{ label: string; value: string }> | null; // dropdown 옵션 - validation_rules: Record | null; // 검증 규칙 - properties: Record | null; // 추가 속성 - created_by: number | null; - updated_by: number | null; - created_at: string; - updated_at: string; -} - -// 조건부 표시 설정 -export interface FieldDisplayCondition { - targetType: 'field' | 'section'; // 조건 대상 타입 - // 일반항목 조건 (여러 개 가능) - fieldConditions?: Array<{ - fieldKey: string; // 조건이 되는 필드의 키 - expectedValue: string; // 예상되는 값 - }>; - // 섹션 조건 (여러 개 가능) - sectionIds?: string[]; // 표시할 섹션 ID 배열 -} - -// 항목 (Field) - API 응답 구조에 맞춰 수정 -export interface ItemField { - id: number; // 서버 생성 ID (string → number) - tenant_id?: number; // 백엔드에서 자동 추가 - group_id?: number | null; // 그룹 ID (독립 필드용) - section_id: number | null; // 외래키 - 섹션 ID (독립 필드는 null) - master_field_id?: number | null; // 마스터 항목 ID (마스터에서 가져온 경우) - field_name: string; // 항목명 (name → field_name) - field_key?: string | null; // 2025-11-28: 필드 키 (형식: {ID}_{사용자입력}, 백엔드에서 생성) - field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea'; // 필드 타입 - order_no: number; // 항목 순서 (order → order_no, required) - is_required: boolean; // 필수 여부 - placeholder?: string | null; // 플레이스홀더 - default_value?: string | null; // 기본값 - display_condition?: Record | null; // 조건부 표시 설정 (displayCondition → display_condition) - validation_rules?: Record | null; // 검증 규칙 - options?: Array<{ label: string; value: string }> | null; // dropdown 옵션 - properties?: Record | null; // 추가 속성 - // 2025-11-28 추가: 잠금 기능 - is_locked?: boolean; // 잠금 여부 - locked_by?: number | null; // 잠금 설정자 - locked_at?: string | null; // 잠금 시간 - created_by?: number | null; // 생성자 ID 추가 - updated_by?: number | null; // 수정자 ID 추가 - created_at: string; // 생성일 (camelCase → snake_case) - updated_at: string; // 수정일 추가 -} - -// BOM 아이템 타입 - API 응답 구조에 맞춰 수정 -export interface BOMItem { - id: number; // 서버 생성 ID (string → number) - tenant_id?: number; // 백엔드에서 자동 추가 - group_id?: number | null; // 그룹 ID (독립 BOM용) - section_id: number | null; // 외래키 - 섹션 ID (독립 BOM은 null) - item_code?: string | null; // 품목 코드 (itemCode → item_code, optional) - item_name: string; // 품목명 (itemName → item_name) - quantity: number; // 수량 - unit?: string | null; // 단위 (optional) - unit_price?: number | null; // 단가 추가 - total_price?: number | null; // 총액 추가 - spec?: string | null; // 규격/사양 추가 - note?: string | null; // 비고 (optional) - created_by?: number | null; // 생성자 ID 추가 - updated_by?: number | null; // 수정자 ID 추가 - created_at: string; // 생성일 (createdAt → created_at) - updated_at: string; // 수정일 추가 -} - -// 섹션 (Section) - API 응답 구조에 맞춰 수정 -export interface ItemSection { - id: number; // 서버 생성 ID (string → number) - tenant_id?: number; // 백엔드에서 자동 추가 - group_id?: number | null; // 그룹 ID (독립 섹션 그룹화용) - 2025-11-26 추가 - page_id: number | null; // 외래키 - 페이지 ID (null이면 독립 섹션) - 2025-11-26 수정 - title: string; // 섹션 제목 (API 필드명과 일치하도록 section_name → title) - section_type: 'BASIC' | 'BOM' | 'CUSTOM'; // 섹션 타입 (type → section_type, 값 변경) - description?: string | null; // 설명 - order_no: number; // 섹션 순서 (order → order_no) - is_template: boolean; // 템플릿 여부 (section_templates 통합) - 2025-11-26 추가 - is_default: boolean; // 기본 템플릿 여부 - 2025-11-26 추가 - is_collapsible?: boolean; // 접기/펼치기 가능 여부 (프론트엔드 전용, optional) - is_default_open?: boolean; // 기본 열림 상태 (프론트엔드 전용, optional) - created_by?: number | null; // 생성자 ID 추가 - updated_by?: number | null; // 수정자 ID 추가 - created_at: string; // 생성일 (camelCase → snake_case) - updated_at: string; // 수정일 추가 - fields?: ItemField[]; // 섹션에 포함된 항목들 (optional로 변경) - bom_items?: BOMItem[]; // BOM 타입일 경우 BOM 품목 목록 (bomItems → bom_items) -} - -// 페이지 (Page) - API 응답 구조에 맞춰 수정 -export interface ItemPage { - id: number; // 서버 생성 ID (string → number) - tenant_id?: number; // 백엔드에서 자동 추가 - page_name: string; // 페이지명 (camelCase → snake_case) - item_type: 'FG' | 'PT' | 'SM' | 'RM' | 'CS'; // 품목유형 - description?: string | null; // 설명 추가 - absolute_path: string; // 절대경로 (camelCase → snake_case) - is_active: boolean; // 사용 여부 (camelCase → snake_case) - order_no: number; // 순서 번호 추가 - created_by?: number | null; // 생성자 ID 추가 - updated_by?: number | null; // 수정자 ID 추가 - created_at: string; // 생성일 (camelCase → snake_case) - updated_at: string; // 수정일 (camelCase → snake_case) - sections: ItemSection[]; // 페이지에 포함된 섹션들 (Nested) -} - -// 템플릿 필드 (로컬 관리용 - API에서 제공하지 않음) -export interface TemplateField { - id: string; - name: string; - fieldKey: string; - property: { - inputType: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea'; - required: boolean; - options?: string[]; - multiColumn?: boolean; - columnCount?: number; - columnNames?: string[]; - }; - description?: string; -} - -// 섹션 템플릿 (재사용 가능한 섹션) - Transformer 출력과 UI 요구사항에 맞춤 -export interface SectionTemplate { - id: number; - tenant_id: number; - template_name: string; // transformer가 title → template_name으로 변환 - section_type: 'BASIC' | 'BOM' | 'CUSTOM'; // transformer가 type → section_type으로 변환 - description: string | null; - default_fields: TemplateField[] | null; // 기본 필드 (로컬 관리) - category?: string[]; // 적용 카테고리 (로컬 관리) - fields?: TemplateField[]; // 템플릿에 포함된 필드 (로컬 관리) - bomItems?: BOMItem[]; // BOM 타입일 경우 BOM 품목 (로컬 관리) - created_by: number | null; - updated_by: number | null; - created_at: string; - updated_at: string; -} +import type { + BendingDetail, + BOMLine, + SpecificationMaster, + MaterialItemName, + ItemRevision, + ItemMaster, + ItemCategory, + ItemUnit, + ItemMaterial, + SurfaceTreatment, + PartTypeOption, + PartUsageOption, + GuideRailOption, + ItemFieldProperty, + ItemMasterField, + FieldDisplayCondition, + ItemField, + BOMItem, + ItemSection, + ItemPage, + TemplateField, + SectionTemplate, +} from '@/types/item-master.types'; // ===== Context Type ===== interface ItemMasterContextType { @@ -1295,11 +959,22 @@ export function ItemMasterProvider({ children }: { children: ReactNode }) { throw new Error(response.message || '페이지 수정 실패'); } - // 응답 데이터 변환 및 state 업데이트 - const updatedPage = transformPageResponse(response.data); - setItemPages(prev => prev.map(page => page.id === id ? updatedPage : page)); + // ⚠️ 2026-01-06: 변경 요청한 필드만 업데이트 + // API 응답(response.data)에 sections가 빈 배열로 오기 때문에 + // 응답 전체를 덮어쓰면 기존 섹션이 사라지는 버그 발생 + // → 변경한 필드(page_name, absolute_path)만 업데이트하고 나머지는 기존 값 유지 + setItemPages(prev => prev.map(page => { + if (page.id === id) { + return { + ...page, + page_name: updates.page_name ?? page.page_name, + absolute_path: updates.absolute_path ?? page.absolute_path, + }; + } + return page; + })); - console.log('[ItemMasterContext] 페이지 수정 성공:', updatedPage); + console.log('[ItemMasterContext] 페이지 수정 성공:', { id, updates }); } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 페이지 수정 실패:', errorMessage); diff --git a/src/lib/api/item-master.ts b/src/lib/api/item-master.ts index 025ac505..8937465a 100644 --- a/src/lib/api/item-master.ts +++ b/src/lib/api/item-master.ts @@ -38,6 +38,11 @@ import type { LinkEntityRequest, LinkBomRequest, ReorderRelationshipsRequest, + // 2025-12-21 추가: 재질/표면처리 타입 + MaterialOptionRequest, + MaterialOptionResponse, + TreatmentOptionRequest, + TreatmentOptionResponse, } from '@/types/item-master-api'; import { getAuthHeaders } from './auth-headers'; import { handleApiError } from './error-handler'; @@ -1893,6 +1898,36 @@ export const itemMasterApi = { } }, + update: async (id: number, data: Partial): Promise> => { + const startTime = apiLogger.logRequest('PUT', `${BASE_URL}/item-master/unit-options/${id}`, data); + + try { + const headers = getAuthHeaders(); + const response = await fetch(`${BASE_URL}/item-master/unit-options/${id}`, { + method: 'PUT', + headers, + body: JSON.stringify(data), + }); + + if (!response.ok) { + await handleApiError(response); + } + + const result: ApiResponse = await response.json(); + apiLogger.logResponse('PUT', `${BASE_URL}/item-master/unit-options/${id}`, response.status, result, startTime); + + return result; + } catch (error) { + if (error instanceof TypeError) { + apiLogger.logError('PUT', `${BASE_URL}/item-master/unit-options/${id}`, error, undefined, startTime); + throw new Error('네트워크 연결을 확인해주세요. 서버에 연결할 수 없습니다.'); + } + + apiLogger.logError('PUT', `${BASE_URL}/item-master/unit-options/${id}`, error as Error, undefined, startTime); + throw error; + } + }, + delete: async (id: number): Promise> => { const startTime = apiLogger.logRequest('DELETE', `${BASE_URL}/item-master/unit-options/${id}`); @@ -1922,4 +1957,276 @@ export const itemMasterApi = { } }, }, + + // ============================================ + // 재질 관리 + // ============================================ + materials: { + list: async (): Promise> => { + const startTime = apiLogger.logRequest('GET', `${BASE_URL}/item-master/materials`); + + try { + const headers = getAuthHeaders(); + const response = await fetch(`${BASE_URL}/item-master/materials`, { + method: 'GET', + headers, + }); + + if (!response.ok) { + await handleApiError(response); + } + + const result: ApiResponse = await response.json(); + apiLogger.logResponse('GET', `${BASE_URL}/item-master/materials`, response.status, result, startTime); + + return result; + } catch (error) { + if (error instanceof TypeError) { + apiLogger.logError('GET', `${BASE_URL}/item-master/materials`, error, undefined, startTime); + throw new Error('네트워크 연결을 확인해주세요. 서버에 연결할 수 없습니다.'); + } + + apiLogger.logError('GET', `${BASE_URL}/item-master/materials`, error as Error, undefined, startTime); + throw error; + } + }, + + create: async (data: { + material_code: string; + material_name: string; + material_type: string; + thickness?: string; + description?: string; + is_active?: boolean; + }): Promise> => { + const startTime = apiLogger.logRequest('POST', `${BASE_URL}/item-master/materials`, data); + + try { + const headers = getAuthHeaders(); + const response = await fetch(`${BASE_URL}/item-master/materials`, { + method: 'POST', + headers, + body: JSON.stringify(data), + }); + + if (!response.ok) { + await handleApiError(response); + } + + const result: ApiResponse = await response.json(); + apiLogger.logResponse('POST', `${BASE_URL}/item-master/materials`, response.status, result, startTime); + + return result; + } catch (error) { + if (error instanceof TypeError) { + apiLogger.logError('POST', `${BASE_URL}/item-master/materials`, error, undefined, startTime); + throw new Error('네트워크 연결을 확인해주세요. 서버에 연결할 수 없습니다.'); + } + + apiLogger.logError('POST', `${BASE_URL}/item-master/materials`, error as Error, undefined, startTime); + throw error; + } + }, + + update: async (id: number | string, data: { + material_code?: string; + material_name?: string; + material_type?: string; + thickness?: string; + description?: string; + is_active?: boolean; + }): Promise> => { + const startTime = apiLogger.logRequest('PUT', `${BASE_URL}/item-master/materials/${id}`, data); + + try { + const headers = getAuthHeaders(); + const response = await fetch(`${BASE_URL}/item-master/materials/${id}`, { + method: 'PUT', + headers, + body: JSON.stringify(data), + }); + + if (!response.ok) { + await handleApiError(response); + } + + const result: ApiResponse = await response.json(); + apiLogger.logResponse('PUT', `${BASE_URL}/item-master/materials/${id}`, response.status, result, startTime); + + return result; + } catch (error) { + if (error instanceof TypeError) { + apiLogger.logError('PUT', `${BASE_URL}/item-master/materials/${id}`, error, undefined, startTime); + throw new Error('네트워크 연결을 확인해주세요. 서버에 연결할 수 없습니다.'); + } + + apiLogger.logError('PUT', `${BASE_URL}/item-master/materials/${id}`, error as Error, undefined, startTime); + throw error; + } + }, + + delete: async (id: number | string): Promise> => { + const startTime = apiLogger.logRequest('DELETE', `${BASE_URL}/item-master/materials/${id}`); + + try { + const headers = getAuthHeaders(); + const response = await fetch(`${BASE_URL}/item-master/materials/${id}`, { + method: 'DELETE', + headers, + }); + + if (!response.ok) { + await handleApiError(response); + } + + const result: ApiResponse = await response.json(); + apiLogger.logResponse('DELETE', `${BASE_URL}/item-master/materials/${id}`, response.status, result, startTime); + + return result; + } catch (error) { + if (error instanceof TypeError) { + apiLogger.logError('DELETE', `${BASE_URL}/item-master/materials/${id}`, error, undefined, startTime); + throw new Error('네트워크 연결을 확인해주세요. 서버에 연결할 수 없습니다.'); + } + + apiLogger.logError('DELETE', `${BASE_URL}/item-master/materials/${id}`, error as Error, undefined, startTime); + throw error; + } + }, + }, + + // ============================================ + // 표면처리 관리 + // ============================================ + treatments: { + list: async (): Promise> => { + const startTime = apiLogger.logRequest('GET', `${BASE_URL}/item-master/surface-treatments`); + + try { + const headers = getAuthHeaders(); + const response = await fetch(`${BASE_URL}/item-master/surface-treatments`, { + method: 'GET', + headers, + }); + + if (!response.ok) { + await handleApiError(response); + } + + const result: ApiResponse = await response.json(); + apiLogger.logResponse('GET', `${BASE_URL}/item-master/surface-treatments`, response.status, result, startTime); + + return result; + } catch (error) { + if (error instanceof TypeError) { + apiLogger.logError('GET', `${BASE_URL}/item-master/surface-treatments`, error, undefined, startTime); + throw new Error('네트워크 연결을 확인해주세요. 서버에 연결할 수 없습니다.'); + } + + apiLogger.logError('GET', `${BASE_URL}/item-master/surface-treatments`, error as Error, undefined, startTime); + throw error; + } + }, + + create: async (data: { + treatment_code: string; + treatment_name: string; + treatment_type: string; + description?: string; + is_active?: boolean; + }): Promise> => { + const startTime = apiLogger.logRequest('POST', `${BASE_URL}/item-master/surface-treatments`, data); + + try { + const headers = getAuthHeaders(); + const response = await fetch(`${BASE_URL}/item-master/surface-treatments`, { + method: 'POST', + headers, + body: JSON.stringify(data), + }); + + if (!response.ok) { + await handleApiError(response); + } + + const result: ApiResponse = await response.json(); + apiLogger.logResponse('POST', `${BASE_URL}/item-master/surface-treatments`, response.status, result, startTime); + + return result; + } catch (error) { + if (error instanceof TypeError) { + apiLogger.logError('POST', `${BASE_URL}/item-master/surface-treatments`, error, undefined, startTime); + throw new Error('네트워크 연결을 확인해주세요. 서버에 연결할 수 없습니다.'); + } + + apiLogger.logError('POST', `${BASE_URL}/item-master/surface-treatments`, error as Error, undefined, startTime); + throw error; + } + }, + + update: async (id: number | string, data: { + treatment_code?: string; + treatment_name?: string; + treatment_type?: string; + description?: string; + is_active?: boolean; + }): Promise> => { + const startTime = apiLogger.logRequest('PUT', `${BASE_URL}/item-master/surface-treatments/${id}`, data); + + try { + const headers = getAuthHeaders(); + const response = await fetch(`${BASE_URL}/item-master/surface-treatments/${id}`, { + method: 'PUT', + headers, + body: JSON.stringify(data), + }); + + if (!response.ok) { + await handleApiError(response); + } + + const result: ApiResponse = await response.json(); + apiLogger.logResponse('PUT', `${BASE_URL}/item-master/surface-treatments/${id}`, response.status, result, startTime); + + return result; + } catch (error) { + if (error instanceof TypeError) { + apiLogger.logError('PUT', `${BASE_URL}/item-master/surface-treatments/${id}`, error, undefined, startTime); + throw new Error('네트워크 연결을 확인해주세요. 서버에 연결할 수 없습니다.'); + } + + apiLogger.logError('PUT', `${BASE_URL}/item-master/surface-treatments/${id}`, error as Error, undefined, startTime); + throw error; + } + }, + + delete: async (id: number | string): Promise> => { + const startTime = apiLogger.logRequest('DELETE', `${BASE_URL}/item-master/surface-treatments/${id}`); + + try { + const headers = getAuthHeaders(); + const response = await fetch(`${BASE_URL}/item-master/surface-treatments/${id}`, { + method: 'DELETE', + headers, + }); + + if (!response.ok) { + await handleApiError(response); + } + + const result: ApiResponse = await response.json(); + apiLogger.logResponse('DELETE', `${BASE_URL}/item-master/surface-treatments/${id}`, response.status, result, startTime); + + return result; + } catch (error) { + if (error instanceof TypeError) { + apiLogger.logError('DELETE', `${BASE_URL}/item-master/surface-treatments/${id}`, error, undefined, startTime); + throw new Error('네트워크 연결을 확인해주세요. 서버에 연결할 수 없습니다.'); + } + + apiLogger.logError('DELETE', `${BASE_URL}/item-master/surface-treatments/${id}`, error as Error, undefined, startTime); + throw error; + } + }, + }, }; diff --git a/src/lib/api/transformers.ts b/src/lib/api/transformers.ts index 795f8d1f..d30834ac 100644 --- a/src/lib/api/transformers.ts +++ b/src/lib/api/transformers.ts @@ -376,9 +376,9 @@ export const transformUnitOptionResponse = ( ): { id: string; value: string; label: string; isActive: boolean } => { return { id: response.id.toString(), // number → string 변환 - value: response.value, - label: response.label, - isActive: true, // API에 없으므로 기본값 + value: response.unit_code, + label: response.unit_name, + isActive: response.is_active, }; }; diff --git a/src/stores/item-master/normalizers.ts b/src/stores/item-master/normalizers.ts new file mode 100644 index 00000000..28949f90 --- /dev/null +++ b/src/stores/item-master/normalizers.ts @@ -0,0 +1,344 @@ +/** + * API 응답 → 정규화된 상태 변환 함수 + * + * API 응답 (Nested 구조) → Zustand 스토어 (정규화된 구조) + */ + +import type { + InitResponse, + ItemPageResponse, + ItemSectionResponse, + ItemFieldResponse, + BomItemResponse, +} from '@/types/item-master-api'; + +import type { + PageEntity, + SectionEntity, + FieldEntity, + BOMItemEntity, + EntitiesState, + IdsState, + SectionType, + FieldType, + ItemType, +} from './types'; + +// ===== 타입 변환 헬퍼 ===== + +/** + * 섹션 타입 변환: API ('fields' | 'bom') → Store ('BASIC' | 'BOM' | 'CUSTOM') + */ +const normalizeSectionType = (apiType: string): SectionType => { + switch (apiType) { + case 'bom': + return 'BOM'; + case 'fields': + default: + return 'BASIC'; + } +}; + +/** + * 필드 타입 변환 (API와 동일하므로 그대로 사용) + */ +const normalizeFieldType = (apiType: string): FieldType => { + return apiType as FieldType; +}; + +// ===== 개별 엔티티 변환 ===== + +/** + * API 페이지 응답 → PageEntity + */ +export const normalizePageResponse = ( + page: ItemPageResponse, + sectionIds: number[] = [] +): PageEntity => ({ + id: page.id, + tenant_id: page.tenant_id, + page_name: page.page_name, + item_type: page.item_type as ItemType, + description: page.description, + absolute_path: page.absolute_path || '', + is_active: page.is_active, + order_no: page.order_no, + created_by: page.created_by, + updated_by: page.updated_by, + created_at: page.created_at, + updated_at: page.updated_at, + sectionIds, +}); + +/** + * API 섹션 응답 → SectionEntity + */ +export const normalizeSectionResponse = ( + section: ItemSectionResponse, + fieldIds: number[] = [], + bomItemIds: number[] = [] +): SectionEntity => ({ + id: section.id, + tenant_id: section.tenant_id, + group_id: section.group_id, + page_id: section.page_id, + title: section.title, + section_type: normalizeSectionType(section.type), + description: section.description, + order_no: section.order_no, + is_template: section.is_template, + is_default: section.is_default, + is_collapsible: true, // 프론트엔드 기본값 + is_default_open: true, // 프론트엔드 기본값 + created_by: section.created_by, + updated_by: section.updated_by, + created_at: section.created_at, + updated_at: section.updated_at, + fieldIds, + bomItemIds, +}); + +/** + * API 필드 응답 → FieldEntity + */ +export const normalizeFieldResponse = (field: ItemFieldResponse): FieldEntity => ({ + id: field.id, + tenant_id: field.tenant_id, + group_id: field.group_id, + section_id: field.section_id, + master_field_id: field.master_field_id, + field_name: field.field_name, + field_key: field.field_key, + field_type: normalizeFieldType(field.field_type), + order_no: field.order_no, + is_required: field.is_required, + placeholder: field.placeholder, + default_value: field.default_value, + display_condition: field.display_condition, + validation_rules: field.validation_rules, + options: field.options, + properties: field.properties, + is_locked: field.is_locked, + locked_by: field.locked_by, + locked_at: field.locked_at, + created_by: field.created_by, + updated_by: field.updated_by, + created_at: field.created_at, + updated_at: field.updated_at, +}); + +/** + * API BOM 응답 → BOMItemEntity + */ +export const normalizeBomItemResponse = (bom: BomItemResponse): BOMItemEntity => ({ + id: bom.id, + tenant_id: bom.tenant_id, + group_id: bom.group_id, + section_id: bom.section_id, + item_code: bom.item_code, + item_name: bom.item_name, + quantity: bom.quantity, + unit: bom.unit, + unit_price: bom.unit_price, + total_price: bom.total_price, + spec: bom.spec, + note: bom.note, + created_by: bom.created_by, + updated_by: bom.updated_by, + created_at: bom.created_at, + updated_at: bom.updated_at, +}); + +// ===== Init 응답 정규화 (전체 데이터) ===== + +export interface NormalizedInitData { + entities: EntitiesState; + ids: IdsState; +} + +/** + * Init API 응답 → 정규화된 상태 + * + * API 응답의 Nested 구조를 평탄화: + * pages[].sections[].fields[] → entities.pages, entities.sections, entities.fields + */ +export const normalizeInitResponse = (data: InitResponse): NormalizedInitData => { + const entities: EntitiesState = { + pages: {}, + sections: {}, + fields: {}, + bomItems: {}, + }; + + const ids: IdsState = { + pages: [], + independentSections: [], + independentFields: [], + independentBomItems: [], + }; + + // 1. 페이지 정규화 (Nested sections 포함) + data.pages.forEach((page) => { + const sectionIds: number[] = []; + + // 페이지에 포함된 섹션 처리 + if (page.sections && page.sections.length > 0) { + page.sections.forEach((section) => { + sectionIds.push(section.id); + + const fieldIds: number[] = []; + const bomItemIds: number[] = []; + + // 섹션에 포함된 필드 처리 + if (section.fields && section.fields.length > 0) { + section.fields.forEach((field) => { + fieldIds.push(field.id); + entities.fields[field.id] = normalizeFieldResponse(field); + }); + } + + // 섹션에 포함된 BOM 처리 (bom_items 또는 bomItems) + const bomItems = section.bom_items || section.bomItems || []; + if (bomItems.length > 0) { + bomItems.forEach((bom) => { + bomItemIds.push(bom.id); + entities.bomItems[bom.id] = normalizeBomItemResponse(bom); + }); + } + + // 섹션 저장 + entities.sections[section.id] = normalizeSectionResponse(section, fieldIds, bomItemIds); + }); + } + + // 페이지 저장 + entities.pages[page.id] = normalizePageResponse(page, sectionIds); + ids.pages.push(page.id); + }); + + // 2. 독립 섹션 정규화 (sections 필드가 있을 경우) + if (data.sections && data.sections.length > 0) { + data.sections.forEach((section) => { + // 이미 페이지에서 처리된 섹션은 스킵 + if (entities.sections[section.id]) return; + + const fieldIds: number[] = []; + const bomItemIds: number[] = []; + + // 필드 처리 + if (section.fields && section.fields.length > 0) { + section.fields.forEach((field) => { + fieldIds.push(field.id); + if (!entities.fields[field.id]) { + entities.fields[field.id] = normalizeFieldResponse(field); + } + }); + } + + // BOM 처리 + const bomItems = section.bom_items || section.bomItems || []; + if (bomItems.length > 0) { + bomItems.forEach((bom) => { + bomItemIds.push(bom.id); + if (!entities.bomItems[bom.id]) { + entities.bomItems[bom.id] = normalizeBomItemResponse(bom); + } + }); + } + + // 섹션 저장 + entities.sections[section.id] = normalizeSectionResponse(section, fieldIds, bomItemIds); + + // 독립 섹션인 경우 (page_id === null) + if (section.page_id === null) { + ids.independentSections.push(section.id); + } + }); + } + + // 3. 독립 필드 정규화 (fields 필드가 있을 경우) + if (data.fields && data.fields.length > 0) { + data.fields.forEach((field) => { + // 이미 섹션에서 처리된 필드는 스킵 + if (entities.fields[field.id]) return; + + entities.fields[field.id] = normalizeFieldResponse(field); + + // 독립 필드인 경우 (section_id === null) + if (field.section_id === null) { + ids.independentFields.push(field.id); + } + }); + } + + return { entities, ids }; +}; + +// ===== 역변환 (상태 → API 요청) ===== + +/** + * PageEntity → API 요청 형식 + */ +export const denormalizePageForRequest = ( + page: Partial +): Record => ({ + page_name: page.page_name, + item_type: page.item_type, + description: page.description, + absolute_path: page.absolute_path, + is_active: page.is_active, + order_no: page.order_no, +}); + +/** + * SectionEntity → API 요청 형식 + */ +export const denormalizeSectionForRequest = ( + section: Partial +): Record => { + // section_type → type 변환 + const type = section.section_type === 'BOM' ? 'bom' : 'fields'; + + return { + title: section.title, + type, + description: section.description, + order_no: section.order_no, + is_template: section.is_template, + is_default: section.is_default, + }; +}; + +/** + * FieldEntity → API 요청 형식 + */ +export const denormalizeFieldForRequest = ( + field: Partial +): Record => ({ + field_name: field.field_name, + field_key: field.field_key, + field_type: field.field_type, + order_no: field.order_no, + is_required: field.is_required, + placeholder: field.placeholder, + default_value: field.default_value, + display_condition: field.display_condition, + validation_rules: field.validation_rules, + options: field.options, + properties: field.properties, +}); + +/** + * BOMItemEntity → API 요청 형식 + */ +export const denormalizeBomItemForRequest = ( + bom: Partial +): Record => ({ + item_code: bom.item_code, + item_name: bom.item_name, + quantity: bom.quantity, + unit: bom.unit, + unit_price: bom.unit_price, + spec: bom.spec, + note: bom.note, +}); \ No newline at end of file diff --git a/src/stores/item-master/types.ts b/src/stores/item-master/types.ts new file mode 100644 index 00000000..1521a2a8 --- /dev/null +++ b/src/stores/item-master/types.ts @@ -0,0 +1,562 @@ +/** + * 품목기준관리 Zustand Store 타입 정의 + * + * 핵심 원칙: + * 1. 정규화된 상태 구조 (Normalized State) + * 2. 1곳 수정 → 모든 뷰 자동 업데이트 + * 3. 기존 API 타입과 호환성 유지 + */ + +// ===== 기본 타입 (API 호환) ===== + +/** 품목 유형 */ +export type ItemType = 'FG' | 'PT' | 'SM' | 'RM' | 'CS'; + +/** 섹션 타입 */ +export type SectionType = 'BASIC' | 'BOM' | 'CUSTOM'; + +/** 필드 타입 */ +export type FieldType = 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea'; + +/** 부품 유형 */ +export type PartType = 'ASSEMBLY' | 'BENDING' | 'PURCHASED'; + +/** 재질 유형 */ +export type MaterialType = 'STEEL' | 'ALUMINUM' | 'PLASTIC' | 'OTHER'; + +/** 표면처리 유형 */ +export type TreatmentType = 'PAINTING' | 'COATING' | 'PLATING' | 'NONE'; + +/** 가이드레일 옵션 유형 */ +export type GuideRailOptionType = 'MODEL_TYPE' | 'MODEL' | 'CERTIFICATION' | 'SHAPE' | 'FINISH' | 'LENGTH'; + +// ===== 정규화된 엔티티 타입 ===== + +/** + * 페이지 엔티티 (정규화) + * - sections 배열 대신 sectionIds만 저장 + */ +export interface PageEntity { + id: number; + tenant_id?: number; + page_name: string; + item_type: ItemType; + description?: string | null; + absolute_path: string; + is_active: boolean; + order_no: number; + created_by?: number | null; + updated_by?: number | null; + created_at: string; + updated_at: string; + // 정규화: 섹션 ID만 참조 + sectionIds: number[]; +} + +/** + * 섹션 엔티티 (정규화) + * - fields, bom_items 배열 대신 ID만 저장 + */ +export interface SectionEntity { + id: number; + tenant_id?: number; + group_id?: number | null; + page_id: number | null; // null = 독립 섹션 + title: string; + section_type: SectionType; + description?: string | null; + order_no: number; + is_template: boolean; + is_default: boolean; + is_collapsible?: boolean; + is_default_open?: boolean; + created_by?: number | null; + updated_by?: number | null; + created_at: string; + updated_at: string; + // 정규화: ID만 참조 + fieldIds: number[]; + bomItemIds: number[]; +} + +/** + * 필드 엔티티 + */ +export interface FieldEntity { + id: number; + tenant_id?: number; + group_id?: number | null; + section_id: number | null; // null = 독립 필드 + master_field_id?: number | null; + field_name: string; + field_key?: string | null; + field_type: FieldType; + order_no: number; + is_required: boolean; + placeholder?: string | null; + default_value?: string | null; + display_condition?: Record | null; + validation_rules?: Record | null; + options?: Array<{ label: string; value: string }> | null; + properties?: Record | null; + is_locked?: boolean; + locked_by?: number | null; + locked_at?: string | null; + created_by?: number | null; + updated_by?: number | null; + created_at: string; + updated_at: string; +} + +/** + * BOM 아이템 엔티티 + */ +export interface BOMItemEntity { + id: number; + tenant_id?: number; + group_id?: number | null; + section_id: number | null; // null = 독립 BOM + item_code?: string | null; + item_name: string; + quantity: number; + unit?: string | null; + unit_price?: number | null; + total_price?: number | null; + spec?: string | null; + note?: string | null; + created_by?: number | null; + updated_by?: number | null; + created_at: string; + updated_at: string; +} + +// ===== 참조 데이터 타입 ===== + +/** 품목 마스터 */ +export interface ItemMasterRef { + id: string; + itemCode: string; + itemName: string; + itemType: ItemType; + unit: string; + isActive?: boolean; + createdAt: string; +} + +/** 규격 마스터 */ +export interface SpecificationMasterRef { + id: string; + specificationCode: string; + itemType: 'RM' | 'SM'; + itemName?: string; + fieldCount: '1' | '2' | '3'; + thickness: string; + widthA: string; + widthB?: string; + widthC?: string; + length: string; + description?: string; + isActive: boolean; + createdAt?: string; + updatedAt?: string; +} + +/** 원자재/부자재 품목명 마스터 */ +export interface MaterialItemNameRef { + id: string; + itemType: 'RM' | 'SM'; + itemName: string; + category?: string; + description?: string; + isActive: boolean; + createdAt: string; + updatedAt?: string; +} + +/** 품목 분류 */ +export interface ItemCategoryRef { + id: string; + categoryType: 'PRODUCT' | 'PART' | 'MATERIAL' | 'SUB_MATERIAL'; + category1: string; + category2?: string; + category3?: string; + code?: string; + description?: string; + isActive: boolean; + createdAt: string; + updatedAt?: string; +} + +/** 단위 */ +export interface ItemUnitRef { + id: string; + unitCode: string; + unitName: string; + description?: string; + isActive: boolean; + createdAt: string; + updatedAt?: string; +} + +/** 재질 */ +export interface ItemMaterialRef { + id: string; + materialCode: string; + materialName: string; + materialType: MaterialType; + thickness?: string; + description?: string; + isActive: boolean; + createdAt: string; + updatedAt?: string; +} + +/** 표면처리 */ +export interface SurfaceTreatmentRef { + id: string; + treatmentCode: string; + treatmentName: string; + treatmentType: TreatmentType; + description?: string; + isActive: boolean; + createdAt: string; + updatedAt?: string; +} + +/** 부품유형 옵션 */ +export interface PartTypeOptionRef { + id: string; + partType: PartType; + optionCode: string; + optionName: string; + description?: string; + isActive: boolean; + createdAt: string; + updatedAt?: string; +} + +/** 부품용도 옵션 */ +export interface PartUsageOptionRef { + id: string; + usageCode: string; + usageName: string; + description?: string; + isActive: boolean; + createdAt: string; + updatedAt?: string; +} + +/** 가이드레일 옵션 */ +export interface GuideRailOptionRef { + id: string; + optionType: GuideRailOptionType; + optionCode: string; + optionName: string; + parentOption?: string; + description?: string; + isActive: boolean; + createdAt: string; + updatedAt?: string; +} + +/** 마스터 필드 (재사용 가능한 필드 템플릿) */ +export interface MasterFieldRef { + id: number; + tenant_id: number; + field_name: string; + field_key?: string | null; + field_type: FieldType; + category: string | null; + description: string | null; + is_common: boolean; + is_required?: boolean; + default_value: string | null; + options: Array<{ label: string; value: string }> | null; + validation_rules: Record | null; + properties: Record | null; + created_by: number | null; + updated_by: number | null; + created_at: string; + updated_at: string; +} + +// ===== 스토어 상태 타입 ===== + +/** + * 정규화된 엔티티 상태 + */ +export interface EntitiesState { + pages: Record; + sections: Record; + fields: Record; + bomItems: Record; +} + +/** + * ID 목록 (순서 관리) + */ +export interface IdsState { + pages: number[]; + independentSections: number[]; // page_id가 null인 섹션 + independentFields: number[]; // section_id가 null인 필드 + independentBomItems: number[]; // section_id가 null인 BOM +} + +/** + * 참조 데이터 상태 + */ +export interface ReferencesState { + itemMasters: ItemMasterRef[]; + specificationMasters: SpecificationMasterRef[]; + materialItemNames: MaterialItemNameRef[]; + itemCategories: ItemCategoryRef[]; + itemUnits: ItemUnitRef[]; + itemMaterials: ItemMaterialRef[]; + surfaceTreatments: SurfaceTreatmentRef[]; + partTypeOptions: PartTypeOptionRef[]; + partUsageOptions: PartUsageOptionRef[]; + guideRailOptions: GuideRailOptionRef[]; + masterFields: MasterFieldRef[]; +} + +/** + * UI 상태 + */ +export interface UIState { + isLoading: boolean; + error: string | null; + selectedPageId: number | null; + selectedSectionId: number | null; + selectedFieldId: number | null; +} + +/** + * 메인 스토어 상태 + */ +export interface ItemMasterState { + entities: EntitiesState; + ids: IdsState; + references: ReferencesState; + ui: UIState; +} + +// ===== 액션 타입 ===== + +/** + * 페이지 액션 + */ +export interface PageActions { + loadPages: (pages: PageEntity[]) => void; + createPage: (page: Omit) => Promise; + updatePage: (id: number, updates: Partial) => Promise; + deletePage: (id: number) => Promise; +} + +/** + * 섹션 액션 + */ +export interface SectionActions { + loadSections: (sections: SectionEntity[]) => void; + createSection: (section: Omit) => Promise; + createSectionInPage: (pageId: number, section: Omit) => Promise; + updateSection: (id: number, updates: Partial) => Promise; + deleteSection: (id: number) => Promise; + linkSectionToPage: (sectionId: number, pageId: number) => Promise; + unlinkSectionFromPage: (sectionId: number) => Promise; + reorderSections: (pageId: number, dragSectionId: number, hoverSectionId: number) => Promise; +} + +/** + * 필드 액션 + */ +export interface FieldActions { + loadFields: (fields: FieldEntity[]) => void; + createField: (field: Omit) => Promise; + createFieldInSection: (sectionId: number, field: Omit) => Promise; + updateField: (id: number, updates: Partial) => Promise; + deleteField: (id: number) => Promise; + linkFieldToSection: (fieldId: number, sectionId: number) => Promise; + unlinkFieldFromSection: (fieldId: number) => Promise; + reorderFields: (sectionId: number, dragFieldId: number, hoverFieldId: number) => Promise; +} + +/** + * BOM 액션 + */ +export interface BOMActions { + loadBomItems: (items: BOMItemEntity[]) => void; + createBomItem: (item: Omit) => Promise; + updateBomItem: (id: number, updates: Partial) => Promise; + deleteBomItem: (id: number) => Promise; +} + +/** + * UI 액션 + */ +export interface UIActions { + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + selectPage: (pageId: number | null) => void; + selectSection: (sectionId: number | null) => void; + selectField: (fieldId: number | null) => void; +} + +/** + * 속성(단위/재질/표면처리) CRUD 액션 + */ +export interface PropertyActions { + // 단위 CRUD + addUnit: (data: { + unitCode: string; + unitName: string; + description?: string; + isActive?: boolean; + }) => Promise; + updateUnit: ( + id: string, + updates: { + unitCode?: string; + unitName?: string; + description?: string; + isActive?: boolean; + } + ) => Promise; + deleteUnit: (id: string) => Promise; + + // 재질 CRUD + addMaterial: (data: { + materialCode: string; + materialName: string; + materialType: MaterialType; + thickness?: string; + description?: string; + isActive?: boolean; + }) => Promise; + updateMaterial: ( + id: string, + updates: { + materialCode?: string; + materialName?: string; + materialType?: MaterialType; + thickness?: string; + description?: string; + isActive?: boolean; + } + ) => Promise; + deleteMaterial: (id: string) => Promise; + + // 표면처리 CRUD + addTreatment: (data: { + treatmentCode: string; + treatmentName: string; + treatmentType: TreatmentType; + description?: string; + isActive?: boolean; + }) => Promise; + updateTreatment: ( + id: string, + updates: { + treatmentCode?: string; + treatmentName?: string; + treatmentType?: TreatmentType; + description?: string; + isActive?: boolean; + } + ) => Promise; + deleteTreatment: (id: string) => Promise; + + // 섹션 복제 + cloneSection: (sectionId: number) => Promise; +} + +/** + * 전체 스토어 타입 + */ +export interface ItemMasterStore extends ItemMasterState { + // 페이지 액션 + loadPages: PageActions['loadPages']; + createPage: PageActions['createPage']; + updatePage: PageActions['updatePage']; + deletePage: PageActions['deletePage']; + + // 섹션 액션 + loadSections: SectionActions['loadSections']; + createSection: SectionActions['createSection']; + createSectionInPage: SectionActions['createSectionInPage']; + updateSection: SectionActions['updateSection']; + deleteSection: SectionActions['deleteSection']; + linkSectionToPage: SectionActions['linkSectionToPage']; + unlinkSectionFromPage: SectionActions['unlinkSectionFromPage']; + reorderSections: SectionActions['reorderSections']; + + // 필드 액션 + loadFields: FieldActions['loadFields']; + createField: FieldActions['createField']; + createFieldInSection: FieldActions['createFieldInSection']; + updateField: FieldActions['updateField']; + deleteField: FieldActions['deleteField']; + linkFieldToSection: FieldActions['linkFieldToSection']; + unlinkFieldFromSection: FieldActions['unlinkFieldFromSection']; + reorderFields: FieldActions['reorderFields']; + + // BOM 액션 + loadBomItems: BOMActions['loadBomItems']; + createBomItem: BOMActions['createBomItem']; + updateBomItem: BOMActions['updateBomItem']; + deleteBomItem: BOMActions['deleteBomItem']; + + // UI 액션 + setLoading: UIActions['setLoading']; + setError: UIActions['setError']; + selectPage: UIActions['selectPage']; + selectSection: UIActions['selectSection']; + selectField: UIActions['selectField']; + + // 속성 CRUD 액션 + addUnit: PropertyActions['addUnit']; + updateUnit: PropertyActions['updateUnit']; + deleteUnit: PropertyActions['deleteUnit']; + addMaterial: PropertyActions['addMaterial']; + updateMaterial: PropertyActions['updateMaterial']; + deleteMaterial: PropertyActions['deleteMaterial']; + addTreatment: PropertyActions['addTreatment']; + updateTreatment: PropertyActions['updateTreatment']; + deleteTreatment: PropertyActions['deleteTreatment']; + cloneSection: PropertyActions['cloneSection']; + + // 초기화 + reset: () => void; + + // API 연동 + initFromApi: () => Promise; +} + +// ===== 파생 상태 (Denormalized) 타입 ===== + +/** + * 계층구조 뷰용 페이지 (섹션/필드 포함) + */ +export interface PageWithDetails { + id: number; + page_name: string; + item_type: ItemType; + description?: string | null; + is_active: boolean; + order_no: number; + sections: SectionWithDetails[]; +} + +/** + * 계층구조 뷰용 섹션 (필드/BOM 포함) + */ +export interface SectionWithDetails { + id: number; + title: string; + section_type: SectionType; + page_id: number | null; + order_no: number; + is_collapsible?: boolean; + is_default_open?: boolean; + fields: FieldEntity[]; + bom_items: BOMItemEntity[]; +} \ No newline at end of file diff --git a/src/stores/item-master/useItemMasterStore.ts b/src/stores/item-master/useItemMasterStore.ts new file mode 100644 index 00000000..2f8ce512 --- /dev/null +++ b/src/stores/item-master/useItemMasterStore.ts @@ -0,0 +1,1161 @@ +/** + * 품목기준관리 Zustand Store + * + * 핵심 원칙: + * 1. 정규화된 상태 구조 → 1곳 수정으로 모든 뷰 업데이트 + * 2. Immer로 불변성 관리 + * 3. 기존 API와 100% 호환 + */ + +import { create } from 'zustand'; +import { immer } from 'zustand/middleware/immer'; +import { devtools } from 'zustand/middleware'; +import type { + ItemMasterStore, + ItemMasterState, + PageEntity, + SectionEntity, + FieldEntity, + BOMItemEntity, +} from './types'; +import { itemMasterApi } from '@/lib/api/item-master'; +import { + normalizeInitResponse, + normalizePageResponse, + normalizeSectionResponse, + normalizeFieldResponse, + normalizeBomItemResponse, + denormalizePageForRequest, + denormalizeSectionForRequest, + denormalizeFieldForRequest, +} from './normalizers'; + +// ===== 초기 상태 ===== + +const initialState: ItemMasterState = { + entities: { + pages: {}, + sections: {}, + fields: {}, + bomItems: {}, + }, + ids: { + pages: [], + independentSections: [], + independentFields: [], + independentBomItems: [], + }, + references: { + itemMasters: [], + specificationMasters: [], + materialItemNames: [], + itemCategories: [], + itemUnits: [], + itemMaterials: [], + surfaceTreatments: [], + partTypeOptions: [], + partUsageOptions: [], + guideRailOptions: [], + masterFields: [], + }, + ui: { + isLoading: false, + error: null, + selectedPageId: null, + selectedSectionId: null, + selectedFieldId: null, + }, +}; + +// ===== 스토어 생성 ===== + +export const useItemMasterStore = create()( + devtools( + immer((set, get) => ({ + ...initialState, + + // ===== 페이지 액션 ===== + + loadPages: (pages) => { + set((state) => { + // 기존 데이터 초기화 + state.entities.pages = {}; + state.ids.pages = []; + + pages.forEach((page) => { + state.entities.pages[page.id] = page; + state.ids.pages.push(page.id); + }); + }); + }, + + createPage: async (pageData) => { + const state = get(); + state.ui.isLoading = true; + + try { + // TODO: API 호출 + // const response = await itemMasterApi.createPage(pageData); + + // 임시: 로컬에서 ID 생성 (실제로는 API 응답 사용) + const newPage: PageEntity = { + ...pageData, + id: Date.now(), + sectionIds: [], + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + set((state) => { + state.entities.pages[newPage.id] = newPage; + state.ids.pages.push(newPage.id); + state.ui.isLoading = false; + }); + + return newPage; + } catch (error) { + set((state) => { + state.ui.isLoading = false; + state.ui.error = error instanceof Error ? error.message : '페이지 생성 실패'; + }); + throw error; + } + }, + + updatePage: async (id, updates) => { + try { + console.log('[ItemMasterStore] updatePage 시작:', { id, updates }); + + // ✅ Phase 3: API 연동 + const apiData = denormalizePageForRequest(updates); + await itemMasterApi.pages.update(id, apiData); + + // ✅ 변경된 필드만 로컬 상태 업데이트 (sectionIds는 건드리지 않음!) + // API 응답에 sections가 빈 배열로 오기 때문에 initFromApi() 사용 안 함 + set((state) => { + const page = state.entities.pages[id]; + if (page) { + // 변경 요청된 필드들만 업데이트 + if (updates.page_name !== undefined) page.page_name = updates.page_name; + if (updates.description !== undefined) page.description = updates.description; + if (updates.item_type !== undefined) page.item_type = updates.item_type; + if (updates.absolute_path !== undefined) page.absolute_path = updates.absolute_path; + if (updates.is_active !== undefined) page.is_active = updates.is_active; + if (updates.order_no !== undefined) page.order_no = updates.order_no; + // sectionIds는 건드리지 않음 - 페이지 정보만 수정한 거니까! + page.updated_at = new Date().toISOString(); + } + }); + + console.log('[ItemMasterStore] updatePage 완료:', { id, updates }); + } catch (error) { + console.error('[ItemMasterStore] updatePage 실패:', error); + set((state) => { + state.ui.error = error instanceof Error ? error.message : '페이지 수정 실패'; + }); + throw error; + } + }, + + deletePage: async (id) => { + try { + // TODO: API 호출 + // await itemMasterApi.deletePage(id); + + set((state) => { + // 페이지에 연결된 섹션들을 독립 섹션으로 변경 + const page = state.entities.pages[id]; + if (page) { + page.sectionIds.forEach((sectionId) => { + if (state.entities.sections[sectionId]) { + state.entities.sections[sectionId].page_id = null; + state.ids.independentSections.push(sectionId); + } + }); + } + + // 페이지 삭제 + delete state.entities.pages[id]; + state.ids.pages = state.ids.pages.filter((pageId) => pageId !== id); + }); + } catch (error) { + set((state) => { + state.ui.error = error instanceof Error ? error.message : '페이지 삭제 실패'; + }); + throw error; + } + }, + + // ===== 섹션 액션 ===== + + loadSections: (sections) => { + set((state) => { + sections.forEach((section) => { + state.entities.sections[section.id] = section; + + // 독립 섹션 목록 관리 + if (section.page_id === null) { + if (!state.ids.independentSections.includes(section.id)) { + state.ids.independentSections.push(section.id); + } + } + + // 페이지에 섹션 ID 추가 + if (section.page_id !== null && state.entities.pages[section.page_id]) { + const page = state.entities.pages[section.page_id]; + if (!page.sectionIds.includes(section.id)) { + page.sectionIds.push(section.id); + } + } + }); + }); + }, + + createSection: async (sectionData) => { + try { + // TODO: API 호출 + + const newSection: SectionEntity = { + ...sectionData, + id: Date.now(), + fieldIds: [], + bomItemIds: [], + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + set((state) => { + state.entities.sections[newSection.id] = newSection; + + if (newSection.page_id === null) { + state.ids.independentSections.push(newSection.id); + } else if (state.entities.pages[newSection.page_id]) { + state.entities.pages[newSection.page_id].sectionIds.push(newSection.id); + } + }); + + return newSection; + } catch (error) { + set((state) => { + state.ui.error = error instanceof Error ? error.message : '섹션 생성 실패'; + }); + throw error; + } + }, + + createSectionInPage: async (pageId, sectionData) => { + const { createSection } = get(); + return createSection({ + ...sectionData, + page_id: pageId, + }); + }, + + updateSection: async (id, updates) => { + try { + console.log('[ItemMasterStore] updateSection 시작:', { id, updates }); + + // ✅ Phase 3: API 연동 + const apiData = denormalizeSectionForRequest(updates); + await itemMasterApi.sections.update(id, apiData); + + // ✅ 변경된 필드만 로컬 상태 업데이트 (fieldIds, bomItemIds는 건드리지 않음!) + // API 응답에 fields가 빈 배열로 오기 때문에 initFromApi() 사용 안 함 + set((state) => { + const section = state.entities.sections[id]; + if (section) { + // 변경 요청된 필드들만 업데이트 + if (updates.title !== undefined) section.title = updates.title; + if (updates.description !== undefined) section.description = updates.description; + if (updates.section_type !== undefined) section.section_type = updates.section_type; + if (updates.order_no !== undefined) section.order_no = updates.order_no; + if (updates.is_template !== undefined) section.is_template = updates.is_template; + if (updates.is_default !== undefined) section.is_default = updates.is_default; + if (updates.is_collapsible !== undefined) section.is_collapsible = updates.is_collapsible; + if (updates.is_default_open !== undefined) section.is_default_open = updates.is_default_open; + // fieldIds, bomItemIds는 건드리지 않음 - 섹션 정보만 수정한 거니까! + section.updated_at = new Date().toISOString(); + } + }); + + console.log('[ItemMasterStore] updateSection 완료:', { id, updates }); + } catch (error) { + console.error('[ItemMasterStore] updateSection 실패:', error); + set((state) => { + state.ui.error = error instanceof Error ? error.message : '섹션 수정 실패'; + }); + throw error; + } + }, + + deleteSection: async (id) => { + try { + // TODO: API 호출 + + set((state) => { + const section = state.entities.sections[id]; + if (!section) return; + + // 섹션에 연결된 필드들을 독립 필드로 변경 + section.fieldIds.forEach((fieldId) => { + if (state.entities.fields[fieldId]) { + state.entities.fields[fieldId].section_id = null; + state.ids.independentFields.push(fieldId); + } + }); + + // 섹션에 연결된 BOM들을 독립 BOM으로 변경 + section.bomItemIds.forEach((bomId) => { + if (state.entities.bomItems[bomId]) { + state.entities.bomItems[bomId].section_id = null; + state.ids.independentBomItems.push(bomId); + } + }); + + // 페이지에서 섹션 ID 제거 + if (section.page_id !== null && state.entities.pages[section.page_id]) { + const page = state.entities.pages[section.page_id]; + page.sectionIds = page.sectionIds.filter((sId) => sId !== id); + } + + // 독립 섹션 목록에서 제거 + state.ids.independentSections = state.ids.independentSections.filter( + (sId) => sId !== id + ); + + // 섹션 삭제 + delete state.entities.sections[id]; + }); + } catch (error) { + set((state) => { + state.ui.error = error instanceof Error ? error.message : '섹션 삭제 실패'; + }); + throw error; + } + }, + + linkSectionToPage: async (sectionId, pageId) => { + try { + // TODO: API 호출 + + set((state) => { + const section = state.entities.sections[sectionId]; + const page = state.entities.pages[pageId]; + + if (!section || !page) return; + + // 기존 페이지에서 제거 + if (section.page_id !== null && state.entities.pages[section.page_id]) { + const oldPage = state.entities.pages[section.page_id]; + oldPage.sectionIds = oldPage.sectionIds.filter((id) => id !== sectionId); + } + + // 독립 섹션 목록에서 제거 + state.ids.independentSections = state.ids.independentSections.filter( + (id) => id !== sectionId + ); + + // 새 페이지에 연결 + section.page_id = pageId; + if (!page.sectionIds.includes(sectionId)) { + page.sectionIds.push(sectionId); + } + }); + } catch (error) { + set((state) => { + state.ui.error = error instanceof Error ? error.message : '섹션 연결 실패'; + }); + throw error; + } + }, + + unlinkSectionFromPage: async (sectionId) => { + try { + // TODO: API 호출 + + set((state) => { + const section = state.entities.sections[sectionId]; + if (!section || section.page_id === null) return; + + // 페이지에서 섹션 ID 제거 + const page = state.entities.pages[section.page_id]; + if (page) { + page.sectionIds = page.sectionIds.filter((id) => id !== sectionId); + } + + // 독립 섹션으로 변경 + section.page_id = null; + if (!state.ids.independentSections.includes(sectionId)) { + state.ids.independentSections.push(sectionId); + } + }); + } catch (error) { + set((state) => { + state.ui.error = error instanceof Error ? error.message : '섹션 연결 해제 실패'; + }); + throw error; + } + }, + + reorderSections: async (pageId, dragSectionId, hoverSectionId) => { + try { + set((state) => { + const page = state.entities.pages[pageId]; + if (!page) return; + + const sectionIds = [...page.sectionIds]; + const dragIndex = sectionIds.indexOf(dragSectionId); + const hoverIndex = sectionIds.indexOf(hoverSectionId); + + if (dragIndex === -1 || hoverIndex === -1) return; + + // 배열에서 드래그된 항목 제거 후 새 위치에 삽입 + sectionIds.splice(dragIndex, 1); + sectionIds.splice(hoverIndex, 0, dragSectionId); + + // 페이지의 sectionIds 업데이트 + page.sectionIds = sectionIds; + + // 각 섹션의 order_no 업데이트 + sectionIds.forEach((id, index) => { + if (state.entities.sections[id]) { + state.entities.sections[id].order_no = index; + } + }); + }); + + // TODO: API 호출하여 서버에도 순서 저장 + console.log('[ItemMasterStore] reorderSections 완료:', { pageId, dragSectionId, hoverSectionId }); + } catch (error) { + console.error('[ItemMasterStore] reorderSections 실패:', error); + set((state) => { + state.ui.error = error instanceof Error ? error.message : '섹션 순서 변경 실패'; + }); + throw error; + } + }, + + // ===== 필드 액션 ===== + + loadFields: (fields) => { + set((state) => { + fields.forEach((field) => { + state.entities.fields[field.id] = field; + + // 독립 필드 목록 관리 + if (field.section_id === null) { + if (!state.ids.independentFields.includes(field.id)) { + state.ids.independentFields.push(field.id); + } + } + + // 섹션에 필드 ID 추가 + if (field.section_id !== null && state.entities.sections[field.section_id]) { + const section = state.entities.sections[field.section_id]; + if (!section.fieldIds.includes(field.id)) { + section.fieldIds.push(field.id); + } + } + }); + }); + }, + + createField: async (fieldData) => { + try { + // TODO: API 호출 + + const newField: FieldEntity = { + ...fieldData, + id: Date.now(), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + set((state) => { + state.entities.fields[newField.id] = newField; + + if (newField.section_id === null) { + state.ids.independentFields.push(newField.id); + } else if (state.entities.sections[newField.section_id]) { + state.entities.sections[newField.section_id].fieldIds.push(newField.id); + } + }); + + return newField; + } catch (error) { + set((state) => { + state.ui.error = error instanceof Error ? error.message : '필드 생성 실패'; + }); + throw error; + } + }, + + createFieldInSection: async (sectionId, fieldData) => { + const { createField } = get(); + return createField({ + ...fieldData, + section_id: sectionId, + }); + }, + + updateField: async (id, updates) => { + try { + // ✅ Phase 3: API 연동 + const apiData = denormalizeFieldForRequest(updates); + const response = await itemMasterApi.fields.update(id, apiData); + + // ⭐ 핵심: 1곳만 수정하면 끝! + set((state) => { + if (state.entities.fields[id]) { + Object.assign(state.entities.fields[id], updates, { + updated_at: response.data?.updated_at || new Date().toISOString(), + }); + } + }); + + console.log('[ItemMasterStore] updateField 완료:', { id, updates }); + } catch (error) { + console.error('[ItemMasterStore] updateField 실패:', error); + set((state) => { + state.ui.error = error instanceof Error ? error.message : '필드 수정 실패'; + }); + throw error; + } + }, + + deleteField: async (id) => { + try { + // TODO: API 호출 + + set((state) => { + const field = state.entities.fields[id]; + if (!field) return; + + // 섹션에서 필드 ID 제거 + if (field.section_id !== null && state.entities.sections[field.section_id]) { + const section = state.entities.sections[field.section_id]; + section.fieldIds = section.fieldIds.filter((fId) => fId !== id); + } + + // 독립 필드 목록에서 제거 + state.ids.independentFields = state.ids.independentFields.filter( + (fId) => fId !== id + ); + + // 필드 삭제 + delete state.entities.fields[id]; + }); + } catch (error) { + set((state) => { + state.ui.error = error instanceof Error ? error.message : '필드 삭제 실패'; + }); + throw error; + } + }, + + linkFieldToSection: async (fieldId, sectionId) => { + try { + // TODO: API 호출 + + set((state) => { + const field = state.entities.fields[fieldId]; + const section = state.entities.sections[sectionId]; + + if (!field || !section) return; + + // 기존 섹션에서 제거 + if (field.section_id !== null && state.entities.sections[field.section_id]) { + const oldSection = state.entities.sections[field.section_id]; + oldSection.fieldIds = oldSection.fieldIds.filter((id) => id !== fieldId); + } + + // 독립 필드 목록에서 제거 + state.ids.independentFields = state.ids.independentFields.filter( + (id) => id !== fieldId + ); + + // 새 섹션에 연결 + field.section_id = sectionId; + if (!section.fieldIds.includes(fieldId)) { + section.fieldIds.push(fieldId); + } + }); + } catch (error) { + set((state) => { + state.ui.error = error instanceof Error ? error.message : '필드 연결 실패'; + }); + throw error; + } + }, + + unlinkFieldFromSection: async (fieldId) => { + try { + // TODO: API 호출 + + set((state) => { + const field = state.entities.fields[fieldId]; + if (!field || field.section_id === null) return; + + // 섹션에서 필드 ID 제거 + const section = state.entities.sections[field.section_id]; + if (section) { + section.fieldIds = section.fieldIds.filter((id) => id !== fieldId); + } + + // 독립 필드로 변경 + field.section_id = null; + if (!state.ids.independentFields.includes(fieldId)) { + state.ids.independentFields.push(fieldId); + } + }); + } catch (error) { + set((state) => { + state.ui.error = error instanceof Error ? error.message : '필드 연결 해제 실패'; + }); + throw error; + } + }, + + reorderFields: async (sectionId, dragFieldId, hoverFieldId) => { + try { + set((state) => { + const section = state.entities.sections[sectionId]; + if (!section) return; + + const fieldIds = [...section.fieldIds]; + const dragIndex = fieldIds.indexOf(dragFieldId); + const hoverIndex = fieldIds.indexOf(hoverFieldId); + + if (dragIndex === -1 || hoverIndex === -1) return; + + // 배열에서 드래그된 항목 제거 후 새 위치에 삽입 + fieldIds.splice(dragIndex, 1); + fieldIds.splice(hoverIndex, 0, dragFieldId); + + // 섹션의 fieldIds 업데이트 + section.fieldIds = fieldIds; + + // 각 필드의 order_no 업데이트 + fieldIds.forEach((id, index) => { + if (state.entities.fields[id]) { + state.entities.fields[id].order_no = index; + } + }); + }); + + // TODO: API 호출하여 서버에도 순서 저장 + console.log('[ItemMasterStore] reorderFields 완료:', { sectionId, dragFieldId, hoverFieldId }); + } catch (error) { + console.error('[ItemMasterStore] reorderFields 실패:', error); + set((state) => { + state.ui.error = error instanceof Error ? error.message : '필드 순서 변경 실패'; + }); + throw error; + } + }, + + // ===== BOM 액션 ===== + + loadBomItems: (items) => { + set((state) => { + items.forEach((item) => { + state.entities.bomItems[item.id] = item; + + // 독립 BOM 목록 관리 + if (item.section_id === null) { + if (!state.ids.independentBomItems.includes(item.id)) { + state.ids.independentBomItems.push(item.id); + } + } + + // 섹션에 BOM ID 추가 + if (item.section_id !== null && state.entities.sections[item.section_id]) { + const section = state.entities.sections[item.section_id]; + if (!section.bomItemIds.includes(item.id)) { + section.bomItemIds.push(item.id); + } + } + }); + }); + }, + + createBomItem: async (itemData) => { + try { + // TODO: API 호출 + + const newItem: BOMItemEntity = { + ...itemData, + id: Date.now(), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + set((state) => { + state.entities.bomItems[newItem.id] = newItem; + + if (newItem.section_id === null) { + state.ids.independentBomItems.push(newItem.id); + } else if (state.entities.sections[newItem.section_id]) { + state.entities.sections[newItem.section_id].bomItemIds.push(newItem.id); + } + }); + + return newItem; + } catch (error) { + set((state) => { + state.ui.error = error instanceof Error ? error.message : 'BOM 생성 실패'; + }); + throw error; + } + }, + + updateBomItem: async (id, updates) => { + try { + // TODO: API 호출 + + set((state) => { + if (state.entities.bomItems[id]) { + Object.assign(state.entities.bomItems[id], updates, { + updated_at: new Date().toISOString(), + }); + } + }); + } catch (error) { + set((state) => { + state.ui.error = error instanceof Error ? error.message : 'BOM 수정 실패'; + }); + throw error; + } + }, + + deleteBomItem: async (id) => { + try { + // TODO: API 호출 + + set((state) => { + const item = state.entities.bomItems[id]; + if (!item) return; + + // 섹션에서 BOM ID 제거 + if (item.section_id !== null && state.entities.sections[item.section_id]) { + const section = state.entities.sections[item.section_id]; + section.bomItemIds = section.bomItemIds.filter((bId) => bId !== id); + } + + // 독립 BOM 목록에서 제거 + state.ids.independentBomItems = state.ids.independentBomItems.filter( + (bId) => bId !== id + ); + + // BOM 삭제 + delete state.entities.bomItems[id]; + }); + } catch (error) { + set((state) => { + state.ui.error = error instanceof Error ? error.message : 'BOM 삭제 실패'; + }); + throw error; + } + }, + + // ===== UI 액션 ===== + + setLoading: (loading) => { + set((state) => { + state.ui.isLoading = loading; + }); + }, + + setError: (error) => { + set((state) => { + state.ui.error = error; + }); + }, + + selectPage: (pageId) => { + set((state) => { + state.ui.selectedPageId = pageId; + }); + }, + + selectSection: (sectionId) => { + set((state) => { + state.ui.selectedSectionId = sectionId; + }); + }, + + selectField: (fieldId) => { + set((state) => { + state.ui.selectedFieldId = fieldId; + }); + }, + + // ===== 초기화 ===== + + reset: () => { + set(initialState); + }, + + // ===== 속성(단위/재질/표면처리) 액션 ===== + + // 단위 CRUD + addUnit: async (unitData: { + unitCode: string; + unitName: string; + description?: string; + isActive?: boolean; + }) => { + try { + const response = await itemMasterApi.units.create({ + unit_code: unitData.unitCode, + unit_name: unitData.unitName, + description: unitData.description, + is_active: unitData.isActive ?? true, + }); + + const newUnit = { + id: String(response.data.id), + unitCode: response.data.unit_code, + unitName: response.data.unit_name, + description: response.data.description, + isActive: response.data.is_active, + createdAt: response.data.created_at, + updatedAt: response.data.updated_at, + }; + + set((state) => { + state.references.itemUnits.push(newUnit); + }); + + console.log('[ItemMasterStore] addUnit 완료:', newUnit); + return newUnit; + } catch (error) { + console.error('[ItemMasterStore] addUnit 실패:', error); + throw error; + } + }, + + updateUnit: async ( + id: string, + updates: { + unitCode?: string; + unitName?: string; + description?: string; + isActive?: boolean; + } + ) => { + try { + const response = await itemMasterApi.units.update(Number(id), { + unit_code: updates.unitCode, + unit_name: updates.unitName, + description: updates.description, + is_active: updates.isActive, + }); + + set((state) => { + const index = state.references.itemUnits.findIndex((u) => u.id === id); + if (index !== -1) { + state.references.itemUnits[index] = { + ...state.references.itemUnits[index], + unitCode: updates.unitCode ?? state.references.itemUnits[index].unitCode, + unitName: updates.unitName ?? state.references.itemUnits[index].unitName, + description: updates.description, + isActive: updates.isActive ?? state.references.itemUnits[index].isActive, + updatedAt: response.data.updated_at, + }; + } + }); + + console.log('[ItemMasterStore] updateUnit 완료:', { id, updates }); + } catch (error) { + console.error('[ItemMasterStore] updateUnit 실패:', error); + throw error; + } + }, + + deleteUnit: async (id) => { + try { + await itemMasterApi.units.delete(Number(id)); + + set((state) => { + state.references.itemUnits = state.references.itemUnits.filter((u) => u.id !== id); + }); + + console.log('[ItemMasterStore] deleteUnit 완료:', id); + } catch (error) { + console.error('[ItemMasterStore] deleteUnit 실패:', error); + throw error; + } + }, + + // 재질 CRUD + addMaterial: async (materialData: { + materialCode: string; + materialName: string; + materialType: 'STEEL' | 'ALUMINUM' | 'PLASTIC' | 'OTHER'; + thickness?: string; + description?: string; + isActive?: boolean; + }) => { + try { + const response = await itemMasterApi.materials.create({ + material_code: materialData.materialCode, + material_name: materialData.materialName, + material_type: materialData.materialType || 'OTHER', + thickness: materialData.thickness, + description: materialData.description, + is_active: materialData.isActive ?? true, + }); + + const newMaterial = { + id: String(response.data.id), + materialCode: response.data.material_code, + materialName: response.data.material_name, + materialType: response.data.material_type, + thickness: response.data.thickness, + description: response.data.description, + isActive: response.data.is_active, + createdAt: response.data.created_at, + updatedAt: response.data.updated_at, + }; + + set((state) => { + state.references.itemMaterials.push(newMaterial); + }); + + console.log('[ItemMasterStore] addMaterial 완료:', newMaterial); + return newMaterial; + } catch (error) { + console.error('[ItemMasterStore] addMaterial 실패:', error); + throw error; + } + }, + + updateMaterial: async ( + id: string, + updates: { + materialCode?: string; + materialName?: string; + materialType?: 'STEEL' | 'ALUMINUM' | 'PLASTIC' | 'OTHER'; + thickness?: string; + description?: string; + isActive?: boolean; + } + ) => { + try { + const response = await itemMasterApi.materials.update(id, { + material_code: updates.materialCode, + material_name: updates.materialName, + material_type: updates.materialType, + thickness: updates.thickness, + description: updates.description, + is_active: updates.isActive, + }); + + set((state) => { + const index = state.references.itemMaterials.findIndex((m) => m.id === id); + if (index !== -1) { + state.references.itemMaterials[index] = { + ...state.references.itemMaterials[index], + materialCode: updates.materialCode ?? state.references.itemMaterials[index].materialCode, + materialName: updates.materialName ?? state.references.itemMaterials[index].materialName, + materialType: updates.materialType ?? state.references.itemMaterials[index].materialType, + thickness: updates.thickness, + description: updates.description, + isActive: updates.isActive ?? state.references.itemMaterials[index].isActive, + updatedAt: response.data.updated_at, + }; + } + }); + + console.log('[ItemMasterStore] updateMaterial 완료:', { id, updates }); + } catch (error) { + console.error('[ItemMasterStore] updateMaterial 실패:', error); + throw error; + } + }, + + deleteMaterial: async (id) => { + try { + await itemMasterApi.materials.delete(id); + + set((state) => { + state.references.itemMaterials = state.references.itemMaterials.filter((m) => m.id !== id); + }); + + console.log('[ItemMasterStore] deleteMaterial 완료:', id); + } catch (error) { + console.error('[ItemMasterStore] deleteMaterial 실패:', error); + throw error; + } + }, + + // 표면처리 CRUD + addTreatment: async (treatmentData: { + treatmentCode: string; + treatmentName: string; + treatmentType: 'PAINTING' | 'COATING' | 'PLATING' | 'NONE'; + description?: string; + isActive?: boolean; + }) => { + try { + const response = await itemMasterApi.treatments.create({ + treatment_code: treatmentData.treatmentCode, + treatment_name: treatmentData.treatmentName, + treatment_type: treatmentData.treatmentType || 'NONE', + description: treatmentData.description, + is_active: treatmentData.isActive ?? true, + }); + + const newTreatment = { + id: String(response.data.id), + treatmentCode: response.data.treatment_code, + treatmentName: response.data.treatment_name, + treatmentType: response.data.treatment_type, + description: response.data.description, + isActive: response.data.is_active, + createdAt: response.data.created_at, + updatedAt: response.data.updated_at, + }; + + set((state) => { + state.references.surfaceTreatments.push(newTreatment); + }); + + console.log('[ItemMasterStore] addTreatment 완료:', newTreatment); + return newTreatment; + } catch (error) { + console.error('[ItemMasterStore] addTreatment 실패:', error); + throw error; + } + }, + + updateTreatment: async ( + id: string, + updates: { + treatmentCode?: string; + treatmentName?: string; + treatmentType?: 'PAINTING' | 'COATING' | 'PLATING' | 'NONE'; + description?: string; + isActive?: boolean; + } + ) => { + try { + const response = await itemMasterApi.treatments.update(id, { + treatment_code: updates.treatmentCode, + treatment_name: updates.treatmentName, + treatment_type: updates.treatmentType, + description: updates.description, + is_active: updates.isActive, + }); + + set((state) => { + const index = state.references.surfaceTreatments.findIndex((t) => t.id === id); + if (index !== -1) { + state.references.surfaceTreatments[index] = { + ...state.references.surfaceTreatments[index], + treatmentCode: updates.treatmentCode ?? state.references.surfaceTreatments[index].treatmentCode, + treatmentName: updates.treatmentName ?? state.references.surfaceTreatments[index].treatmentName, + treatmentType: updates.treatmentType ?? state.references.surfaceTreatments[index].treatmentType, + description: updates.description, + isActive: updates.isActive ?? state.references.surfaceTreatments[index].isActive, + updatedAt: response.data.updated_at, + }; + } + }); + + console.log('[ItemMasterStore] updateTreatment 완료:', { id, updates }); + } catch (error) { + console.error('[ItemMasterStore] updateTreatment 실패:', error); + throw error; + } + }, + + deleteTreatment: async (id) => { + try { + await itemMasterApi.treatments.delete(id); + + set((state) => { + state.references.surfaceTreatments = state.references.surfaceTreatments.filter((t) => t.id !== id); + }); + + console.log('[ItemMasterStore] deleteTreatment 완료:', id); + } catch (error) { + console.error('[ItemMasterStore] deleteTreatment 실패:', error); + throw error; + } + }, + + // ===== 섹션 복제 ===== + + cloneSection: async (sectionId) => { + try { + const response = await itemMasterApi.sections.clone(sectionId); + + // 정규화된 섹션 엔티티 생성 + const clonedSection = normalizeSectionResponse(response); + + set((state) => { + state.entities.sections[clonedSection.id] = clonedSection; + // 독립 섹션으로 추가 + state.ids.independentSections.push(clonedSection.id); + }); + + console.log('[ItemMasterStore] cloneSection 완료:', clonedSection); + return clonedSection; + } catch (error) { + console.error('[ItemMasterStore] cloneSection 실패:', error); + throw error; + } + }, + + // ===== API 연동 ===== + + initFromApi: async () => { + set((state) => { + state.ui.isLoading = true; + state.ui.error = null; + }); + + try { + // API 호출 + const data = await itemMasterApi.init(); + + // 정규화 + const normalized = normalizeInitResponse(data); + + set((state) => { + // 엔티티 설정 + state.entities = normalized.entities; + state.ids = normalized.ids; + + // 참조 데이터 설정 (API에서 masterFields 등 포함 시) + if (data.masterFields) { + state.references.masterFields = data.masterFields; + } + + state.ui.isLoading = false; + }); + + console.log('[ItemMasterStore] initFromApi 완료:', { + pages: Object.keys(normalized.entities.pages).length, + sections: Object.keys(normalized.entities.sections).length, + fields: Object.keys(normalized.entities.fields).length, + bomItems: Object.keys(normalized.entities.bomItems).length, + }); + } catch (error) { + console.error('[ItemMasterStore] initFromApi 실패:', error); + set((state) => { + state.ui.isLoading = false; + state.ui.error = error instanceof Error ? error.message : 'API 초기화 실패'; + }); + throw error; + } + }, + })), + { name: 'item-master-store' } + ) +); + +// ===== 타입 export ===== +export type { ItemMasterStore, ItemMasterState }; \ No newline at end of file diff --git a/src/types/item-master-api.ts b/src/types/item-master-api.ts index 6bb51f9e..c45d69b6 100644 --- a/src/types/item-master-api.ts +++ b/src/types/item-master-api.ts @@ -617,12 +617,14 @@ export interface TabColumnResponse { // ============================================ /** - * 단위 옵션 생성 요청 + * 단위 옵션 생성/수정 요청 * POST /v1/item-master/units */ export interface UnitOptionRequest { - label: string; - value: string; + unit_code: string; + unit_name: string; + description?: string; + is_active?: boolean; } /** @@ -631,8 +633,78 @@ export interface UnitOptionRequest { export interface UnitOptionResponse { id: number; tenant_id: number; - label: string; - value: string; + unit_code: string; + unit_name: string; + description?: string; + is_active: boolean; + created_by: number | null; + created_at: string; + updated_at: string; +} + +// ============================================ +// 재질 옵션 +// ============================================ + +export type MaterialType = 'STEEL' | 'ALUMINUM' | 'PLASTIC' | 'OTHER'; + +/** + * 재질 옵션 생성/수정 요청 + */ +export interface MaterialOptionRequest { + material_code: string; + material_name: string; + material_type: MaterialType; + thickness?: string; + description?: string; + is_active?: boolean; +} + +/** + * 재질 옵션 응답 + */ +export interface MaterialOptionResponse { + id: number; + tenant_id: number; + material_code: string; + material_name: string; + material_type: MaterialType; + thickness?: string; + description?: string; + is_active: boolean; + created_by: number | null; + created_at: string; + updated_at: string; +} + +// ============================================ +// 표면처리 옵션 +// ============================================ + +export type TreatmentType = 'PAINTING' | 'COATING' | 'PLATING' | 'NONE'; + +/** + * 표면처리 옵션 생성/수정 요청 + */ +export interface TreatmentOptionRequest { + treatment_code: string; + treatment_name: string; + treatment_type: TreatmentType; + description?: string; + is_active?: boolean; +} + +/** + * 표면처리 옵션 응답 + */ +export interface TreatmentOptionResponse { + id: number; + tenant_id: number; + treatment_code: string; + treatment_name: string; + treatment_type: TreatmentType; + description?: string; + is_active: boolean; created_by: number | null; created_at: string; updated_at: string; diff --git a/src/types/item-master.types.ts b/src/types/item-master.types.ts new file mode 100644 index 00000000..dbb3ac39 --- /dev/null +++ b/src/types/item-master.types.ts @@ -0,0 +1,392 @@ +/** + * 품목기준관리 타입 정의 + * ItemMasterContext에서 분리됨 (2026-01-06) + */ + +// ===== 기본 타입 ===== + +// 전개도 상세 정보 +export interface BendingDetail { + id: string; + no: number; // 번호 + input: number; // 입력 + elongation: number; // 연신율 (기본값 -1) + calculated: number; // 연신율 계산 후 + sum: number; // 합계 + shaded: boolean; // 음영 여부 + aAngle?: number; // A각 +} + +// 부품구성표(BOM, Bill of Materials) - 자재 명세서 +export interface BOMLine { + id: string; + childItemCode: string; // 구성 품목 코드 + childItemName: string; // 구성 품목명 + quantity: number; // 기준 수량 + unit: string; // 단위 + unitPrice?: number; // 단가 + quantityFormula?: string; // 수량 계산식 (예: "W * 2", "H + 100") + note?: string; // 비고 + // 절곡품 관련 (하위 절곡 부품용) + isBending?: boolean; + bendingDiagram?: string; // 전개도 이미지 URL + bendingDetails?: BendingDetail[]; // 전개도 상세 데이터 +} + +// 규격 마스터 (원자재/부자재용) +export interface SpecificationMaster { + id: string; + specificationCode: string; // 규격 코드 (예: 1.6T x 1219 x 2438) + itemType: 'RM' | 'SM'; // 원자재 | 부자재 + itemName?: string; // 품목명 (예: SPHC-SD, SPCC-SD) - 품목명별 규격 필터링용 + fieldCount: '1' | '2' | '3'; // 너비 입력 개수 + thickness: string; // 두께 + widthA: string; // 너비A + widthB?: string; // 너비B + widthC?: string; // 너비C + length: string; // 길이 + description?: string; // 설명 + isActive: boolean; // 활성 여부 + createdAt?: string; + updatedAt?: string; +} + +// 원자재/부자재 품목명 마스터 +export interface MaterialItemName { + id: string; + itemType: 'RM' | 'SM'; // 원자재 | 부자재 + itemName: string; // 품목명 (예: "SPHC-SD", "STS430") + category?: string; // 분류 (예: "냉연", "열연", "스테인리스") + description?: string; // 설명 + isActive: boolean; // 활성 여부 + createdAt: string; + updatedAt?: string; +} + +// 품목 수정 이력 +export interface ItemRevision { + revisionNumber: number; // 수정 차수 (1차, 2차, 3차...) + revisionDate: string; // 수정일 + revisionBy: string; // 수정자 + revisionReason?: string; // 수정 사유 + previousData: any; // 이전 버전의 전체 데이터 +} + +// 품목 마스터 +export interface ItemMaster { + id: string; + itemCode: string; + itemName: string; + itemType: 'FG' | 'PT' | 'SM' | 'RM' | 'CS'; // 제품, 부품, 부자재, 원자재, 소모품 + productCategory?: 'SCREEN' | 'STEEL'; // 제품 카테고리 (스크린/철재) + partType?: 'ASSEMBLY' | 'BENDING' | 'PURCHASED'; // 부품 유형 (조립/절곡/구매) + partUsage?: 'GUIDE_RAIL' | 'BOTTOM_FINISH' | 'CASE' | 'DOOR' | 'BRACKET' | 'GENERAL'; // 부품 용도 + unit: string; + category1?: string; + category2?: string; + category3?: string; + specification?: string; + isVariableSize?: boolean; + isActive?: boolean; // 품목 활성/비활성 (제품/부품/원자재/부자재만 사용) + lotAbbreviation?: string; // 로트 약자 (제품만 사용) + purchasePrice?: number; + marginRate?: number; + processingCost?: number; + laborCost?: number; + installCost?: number; + salesPrice?: number; + safetyStock?: number; + leadTime?: number; + bom?: BOMLine[]; // 부품구성표(BOM) - 자재 명세서 + bomCategories?: string[]; // 견적산출용 샘플 제품의 BOM 카테고리 (예: ['motor', 'guide-rail']) + + // 인정 정보 + certificationNumber?: string; // 인정번호 + certificationStartDate?: string; // 인정 유효기간 시작일 + certificationEndDate?: string; // 인정 유효기간 종료일 + specificationFile?: string; // 시방서 파일 (Base64 또는 URL) + specificationFileName?: string; // 시방서 파일명 + certificationFile?: string; // 인정서 파일 (Base64 또는 URL) + certificationFileName?: string; // 인정서 파일명 + note?: string; // 비고 (제품만 사용) + + // 조립 부품 관련 필드 + installationType?: string; // 설치 유형 (wall: 벽면형, side: 측면형, steel: 스틸, iron: 철재) + assemblyType?: string; // 종류 (M, T, C, D, S, U 등) + sideSpecWidth?: string; // 측면 규격 가로 (mm) + sideSpecHeight?: string; // 측면 규격 세로 (mm) + assemblyLength?: string; // 길이 (2438, 3000, 3500, 4000, 4300 등) + + // 가이드레일 관련 필드 + guideRailModelType?: string; // 가이드레일 모델 유형 + guideRailModel?: string; // 가이드레일 모델 + + // 절곡품 관련 (부품 유형이 BENDING인 경우) + bendingDiagram?: string; // 전개도 이미지 URL + bendingDetails?: BendingDetail[]; // 전개도 상세 데이터 + material?: string; // 재질 (EGI 1.55T, SUS 1.2T 등) + length?: string; // 길이/목함 (mm) + + // 버전 관리 + currentRevision: number; // 현재 차수 (0 = 최초, 1 = 1차 수정...) + revisions?: ItemRevision[]; // 수정 이력 + isFinal: boolean; // 최종 확정 여부 + finalizedDate?: string; // 최종 확정일 + finalizedBy?: string; // 최종 확정자 + + createdAt: string; +} + +// ===== 품목 기준정보 관리 (Master Data) ===== + +export interface ItemCategory { + id: string; + categoryType: 'PRODUCT' | 'PART' | 'MATERIAL' | 'SUB_MATERIAL'; // 품목 구분 + category1: string; // 대분류 + category2?: string; // 중분류 + category3?: string; // 소분류 + code?: string; // 코드 (자동생성 또는 수동입력) + description?: string; + isActive: boolean; + createdAt: string; + updatedAt?: string; +} + +export interface ItemUnit { + id: string; + unitCode: string; // 단위 코드 (EA, SET, M, KG, L 등) + unitName: string; // 단위명 + description?: string; + isActive: boolean; + createdAt: string; + updatedAt?: string; +} + +export interface ItemMaterial { + id: string; + materialCode: string; // 재질 코드 + materialName: string; // 재질명 (EGI 1.55T, SUS 1.2T 등) + materialType: 'STEEL' | 'ALUMINUM' | 'PLASTIC' | 'OTHER'; // 재질 유형 + thickness?: string; // 두께 (1.2T, 1.6T 등) + description?: string; + isActive: boolean; + createdAt: string; + updatedAt?: string; +} + +export interface SurfaceTreatment { + id: string; + treatmentCode: string; // 처리 코드 + treatmentName: string; // 처리명 (무도장, 파우더도장, 아노다이징 등) + treatmentType: 'PAINTING' | 'COATING' | 'PLATING' | 'NONE'; // 처리 유형 + description?: string; + isActive: boolean; + createdAt: string; + updatedAt?: string; +} + +export interface PartTypeOption { + id: string; + partType: 'ASSEMBLY' | 'BENDING' | 'PURCHASED'; // 부품 유형 + optionCode: string; // 옵션 코드 + optionName: string; // 옵션명 + description?: string; + isActive: boolean; + createdAt: string; + updatedAt?: string; +} + +export interface PartUsageOption { + id: string; + usageCode: string; // 용도 코드 + usageName: string; // 용도명 (가이드레일, 하단마감재, 케이스 등) + description?: string; + isActive: boolean; + createdAt: string; + updatedAt?: string; +} + +export interface GuideRailOption { + id: string; + optionType: 'MODEL_TYPE' | 'MODEL' | 'CERTIFICATION' | 'SHAPE' | 'FINISH' | 'LENGTH'; // 옵션 유형 + optionCode: string; // 옵션 코드 + optionName: string; // 옵션명 + parentOption?: string; // 상위 옵션 (종속 관계) + description?: string; + isActive: boolean; + createdAt: string; + updatedAt?: string; +} + +// ===== 품목기준관리 계층구조 ===== + +// 항목 속성 +export interface ItemFieldProperty { + id?: string; // 속성 ID (properties 배열에서 사용) + key?: string; // 속성 키 (properties 배열에서 사용) + label?: string; // 속성 라벨 (properties 배열에서 사용) + type?: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea'; // 속성 타입 (properties 배열에서 사용) + inputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea' | 'section'; // 입력방식 + required: boolean; // 필수 여부 + row: number; // 행 위치 + col: number; // 열 위치 + options?: string[]; // 드롭다운 옵션 (입력방식이 dropdown일 경우) + defaultValue?: string; // 기본값 + placeholder?: string; // 플레이스홀더 + multiColumn?: boolean; // 다중 컬럼 사용 여부 + columnCount?: number; // 컬럼 개수 + columnNames?: string[]; // 각 컬럼의 이름 +} + +// 항목 마스터 (재사용 가능한 항목 템플릿) - MasterFieldResponse와 정확히 일치 +export interface ItemMasterField { + id: number; + tenant_id: number; + field_name: string; + field_key?: string | null; // 2025-11-28: field_key 추가 (형식: {ID}_{사용자입력}) + field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea'; // API와 동일 + category: string | null; + description: string | null; + is_common: boolean; // 공통 필드 여부 + is_required?: boolean; // 필수 여부 (API에서 반환) + default_value: string | null; // 기본값 + options: Array<{ label: string; value: string }> | null; // dropdown 옵션 + validation_rules: Record | null; // 검증 규칙 + properties: Record | null; // 추가 속성 + created_by: number | null; + updated_by: number | null; + created_at: string; + updated_at: string; +} + +// 조건부 표시 설정 +export interface FieldDisplayCondition { + targetType: 'field' | 'section'; // 조건 대상 타입 + // 일반항목 조건 (여러 개 가능) + fieldConditions?: Array<{ + fieldKey: string; // 조건이 되는 필드의 키 + expectedValue: string; // 예상되는 값 + }>; + // 섹션 조건 (여러 개 가능) + sectionIds?: string[]; // 표시할 섹션 ID 배열 +} + +// 항목 (Field) - API 응답 구조에 맞춰 수정 +export interface ItemField { + id: number; // 서버 생성 ID (string → number) + tenant_id?: number; // 백엔드에서 자동 추가 + group_id?: number | null; // 그룹 ID (독립 필드용) + section_id: number | null; // 외래키 - 섹션 ID (독립 필드는 null) + master_field_id?: number | null; // 마스터 항목 ID (마스터에서 가져온 경우) + field_name: string; // 항목명 (name → field_name) + field_key?: string | null; // 2025-11-28: 필드 키 (형식: {ID}_{사용자입력}, 백엔드에서 생성) + field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea'; // 필드 타입 + order_no: number; // 항목 순서 (order → order_no, required) + is_required: boolean; // 필수 여부 + placeholder?: string | null; // 플레이스홀더 + default_value?: string | null; // 기본값 + display_condition?: Record | null; // 조건부 표시 설정 (displayCondition → display_condition) + validation_rules?: Record | null; // 검증 규칙 + options?: Array<{ label: string; value: string }> | null; // dropdown 옵션 + properties?: Record | null; // 추가 속성 + // 2025-11-28 추가: 잠금 기능 + is_locked?: boolean; // 잠금 여부 + locked_by?: number | null; // 잠금 설정자 + locked_at?: string | null; // 잠금 시간 + created_by?: number | null; // 생성자 ID 추가 + updated_by?: number | null; // 수정자 ID 추가 + created_at: string; // 생성일 (camelCase → snake_case) + updated_at: string; // 수정일 추가 +} + +// BOM 아이템 타입 - API 응답 구조에 맞춰 수정 +export interface BOMItem { + id: number; // 서버 생성 ID (string → number) + tenant_id?: number; // 백엔드에서 자동 추가 + group_id?: number | null; // 그룹 ID (독립 BOM용) + section_id: number | null; // 외래키 - 섹션 ID (독립 BOM은 null) + item_code?: string | null; // 품목 코드 (itemCode → item_code, optional) + item_name: string; // 품목명 (itemName → item_name) + quantity: number; // 수량 + unit?: string | null; // 단위 (optional) + unit_price?: number | null; // 단가 추가 + total_price?: number | null; // 총액 추가 + spec?: string | null; // 규격/사양 추가 + note?: string | null; // 비고 (optional) + created_by?: number | null; // 생성자 ID 추가 + updated_by?: number | null; // 수정자 ID 추가 + created_at: string; // 생성일 (createdAt → created_at) + updated_at: string; // 수정일 추가 +} + +// 섹션 (Section) - API 응답 구조에 맞춰 수정 +export interface ItemSection { + id: number; // 서버 생성 ID (string → number) + tenant_id?: number; // 백엔드에서 자동 추가 + group_id?: number | null; // 그룹 ID (독립 섹션 그룹화용) - 2025-11-26 추가 + page_id: number | null; // 외래키 - 페이지 ID (null이면 독립 섹션) - 2025-11-26 수정 + title: string; // 섹션 제목 (API 필드명과 일치하도록 section_name → title) + section_type: 'BASIC' | 'BOM' | 'CUSTOM'; // 섹션 타입 (type → section_type, 값 변경) + description?: string | null; // 설명 + order_no: number; // 섹션 순서 (order → order_no) + is_template: boolean; // 템플릿 여부 (section_templates 통합) - 2025-11-26 추가 + is_default: boolean; // 기본 템플릿 여부 - 2025-11-26 추가 + is_collapsible?: boolean; // 접기/펼치기 가능 여부 (프론트엔드 전용, optional) + is_default_open?: boolean; // 기본 열림 상태 (프론트엔드 전용, optional) + created_by?: number | null; // 생성자 ID 추가 + updated_by?: number | null; // 수정자 ID 추가 + created_at: string; // 생성일 (camelCase → snake_case) + updated_at: string; // 수정일 추가 + fields?: ItemField[]; // 섹션에 포함된 항목들 (optional로 변경) + bom_items?: BOMItem[]; // BOM 타입일 경우 BOM 품목 목록 (bomItems → bom_items) +} + +// 페이지 (Page) - API 응답 구조에 맞춰 수정 +export interface ItemPage { + id: number; // 서버 생성 ID (string → number) + tenant_id?: number; // 백엔드에서 자동 추가 + page_name: string; // 페이지명 (camelCase → snake_case) + item_type: 'FG' | 'PT' | 'SM' | 'RM' | 'CS'; // 품목유형 + description?: string | null; // 설명 추가 + absolute_path: string; // 절대경로 (camelCase → snake_case) + is_active: boolean; // 사용 여부 (camelCase → snake_case) + order_no: number; // 순서 번호 추가 + created_by?: number | null; // 생성자 ID 추가 + updated_by?: number | null; // 수정자 ID 추가 + created_at: string; // 생성일 (camelCase → snake_case) + updated_at: string; // 수정일 (camelCase → snake_case) + sections: ItemSection[]; // 페이지에 포함된 섹션들 (Nested) +} + +// 템플릿 필드 (로컬 관리용 - API에서 제공하지 않음) +export interface TemplateField { + id: string; + name: string; + fieldKey: string; + property: { + inputType: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea'; + required: boolean; + options?: string[]; + multiColumn?: boolean; + columnCount?: number; + columnNames?: string[]; + }; + description?: string; +} + +// 섹션 템플릿 (재사용 가능한 섹션) - Transformer 출력과 UI 요구사항에 맞춤 +export interface SectionTemplate { + id: number; + tenant_id: number; + template_name: string; // transformer가 title → template_name으로 변환 + section_type: 'BASIC' | 'BOM' | 'CUSTOM'; // transformer가 type → section_type으로 변환 + description: string | null; + default_fields: TemplateField[] | null; // 기본 필드 (로컬 관리) + category?: string[]; // 적용 카테고리 (로컬 관리) + fields?: TemplateField[]; // 템플릿에 포함된 필드 (로컬 관리) + bomItems?: BOMItem[]; // BOM 타입일 경우 BOM 품목 (로컬 관리) + created_by: number | null; + updated_by: number | null; + created_at: string; + updated_at: string; +} \ No newline at end of file