feat: 품목기준관리 Zustand 리팩토링 및 422 에러 팝업

- Zustand store 도입 (useItemMasterStore)
- 훅 분리 및 구조 개선 (hooks/, contexts/)
- 422 ValidationException 에러 AlertDialog 팝업 추가
- API 함수 분리 (src/lib/api/item-master.ts)
- 타입 정의 정리 (item-master.types.ts, item-master-api.ts)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2026-01-06 20:49:37 +09:00
32 changed files with 6747 additions and 1410 deletions

View File

@@ -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<number, PageEntity>;
sections: Record<number, SectionEntity>;
fields: Record<number, FieldEntity>;
bomItems: Record<number, BOMItemEntity>;
};
// ===== 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) |

View File

@@ -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. **마이그레이션**: 테스트 완료 후 기존 페이지 대체

View File

@@ -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 응답 정규화

View File

@@ -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 문서 확인하고 시작해.
```

View File

@@ -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<void>;
}
```
#### 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<void>;
moveField: (sectionId: number, dragFieldId: number, hoverFieldId: number) => Promise<void>;
}
```
#### 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<void>;
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<string, Option[]>;
attributeColumns: Record<string, Column[]>;
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. 최종 수동 테스트

35
package-lock.json generated
View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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<string, string> = {
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<string, UnitOption[]>;
attributeColumns: Record<string, AttributeColumn[]>;
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<string, string>) => 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 (
<div className="space-y-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium">{title}</h3>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => {
setManagingColumnType(optionType);
setNewColumnName('');
setNewColumnKey('');
setNewColumnType('text');
setNewColumnRequired(false);
setIsColumnManageDialogOpen(true);
}}>
<Settings className="w-4 h-4 mr-2" />
</Button>
<Button size="sm" onClick={() => {
setEditingOptionType(optionType === 'units' ? 'unit' : optionType === 'materials' ? 'material' : optionType === 'surface' ? 'surface' : optionType);
setIsOptionDialogOpen(true);
}}>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
<div className="space-y-3">
{options.map((option) => {
const hasColumns = columns.length > 0 && option.columnValues;
return (
<div key={option.id} className="p-4 border rounded hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<span className="font-medium text-base">{option.label}</span>
{option.inputType && (
<Badge variant="outline" className="text-xs">{getInputTypeLabel(option.inputType)}</Badge>
)}
{option.required && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
</div>
<div className="text-sm text-muted-foreground space-y-1">
<div className="flex gap-2">
<span className="font-medium min-w-16">(Value):</span>
<span>{option.value}</span>
</div>
{option.placeholder && (
<div className="flex gap-2">
<span className="font-medium min-w-16">:</span>
<span>{option.placeholder}</span>
</div>
)}
{option.defaultValue && (
<div className="flex gap-2">
<span className="font-medium min-w-16">:</span>
<span>{option.defaultValue}</span>
</div>
)}
{option.inputType === 'dropdown' && option.options && (
<div className="flex gap-2">
<span className="font-medium min-w-16">:</span>
<div className="flex flex-wrap gap-1">
{option.options.map((opt, idx) => (
<Badge key={idx} variant="secondary" className="text-xs">{opt}</Badge>
))}
</div>
</div>
)}
</div>
{hasColumns && (
<div className="mt-3 pt-3 border-t">
<p className="text-xs font-medium text-muted-foreground mb-2"> </p>
<div className="grid grid-cols-2 gap-2 text-sm">
{columns.map((column) => (
<div key={column.id} className="flex gap-2">
<span className="text-muted-foreground">{column.name}:</span>
<span>{option.columnValues?.[column.key] || '-'}</span>
</div>
))}
</div>
</div>
)}
</div>
<Button variant="ghost" size="sm" onClick={() => handleDeleteOption(optionType === 'units' ? 'unit' : optionType === 'materials' ? 'material' : optionType === 'surface' ? 'surface' : optionType, option.id)}>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</div>
);
})}
</div>
</div>
);
};
// 마스터 필드 속성 렌더링
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 (
<div className="space-y-4">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-medium">{masterField.field_name} </h3>
<p className="text-sm text-muted-foreground mt-1">
&quot;{masterField.field_name}&quot;
</p>
</div>
</div>
<div className="space-y-3">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{propertiesArray.map((property: any) => {
const inputTypeLabel = getInputTypeLabel(property.type);
return (
<div key={property.key} className="p-4 border rounded hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<span className="font-medium text-base">{property.label}</span>
<Badge variant="outline" className="text-xs">{inputTypeLabel}</Badge>
{property.required && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
</div>
<div className="text-sm text-muted-foreground space-y-1">
<div className="flex gap-2">
<span className="font-medium min-w-24">(Key):</span>
<code className="bg-gray-100 px-2 py-0.5 rounded text-xs">{property.key}</code>
</div>
{property.placeholder && (
<div className="flex gap-2">
<span className="font-medium min-w-24">:</span>
<span>{property.placeholder}</span>
</div>
)}
{property.defaultValue && (
<div className="flex gap-2">
<span className="font-medium min-w-24">:</span>
<span>{property.defaultValue}</span>
</div>
)}
{property.type === 'dropdown' && property.options && (
<div className="flex gap-2">
<span className="font-medium min-w-24">:</span>
<div className="flex flex-wrap gap-1">
{property.options.map((opt: string, idx: number) => (
<Badge key={idx} variant="secondary" className="text-xs">{opt}</Badge>
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start gap-2">
<Package className="w-5 h-5 text-blue-600 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-blue-900">
</p>
<p className="text-xs text-blue-700 mt-1">
<strong> </strong> &quot;{masterField.field_name}&quot; // .
</p>
</div>
</div>
</div>
</div>
);
};
// 사용자 정의 속성 렌더링
const renderCustomAttributeTab = () => {
const currentTabKey = activeAttributeTab;
const currentOptions = customAttributeOptions[currentTabKey] || [];
const columns = attributeColumns[currentTabKey] || [];
return (
<div className="space-y-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium">
{attributeSubTabs.find(t => t.key === activeAttributeTab)?.label || '사용자 정의'}
</h3>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => {
setManagingColumnType(currentTabKey);
setNewColumnName('');
setNewColumnKey('');
setNewColumnType('text');
setNewColumnRequired(false);
setIsColumnManageDialogOpen(true);
}}>
<Settings className="w-4 h-4 mr-2" />
</Button>
<Button size="sm" onClick={() => {
setEditingOptionType(activeAttributeTab);
setNewOptionValue('');
setNewOptionLabel('');
setNewOptionColumnValues({});
setIsOptionDialogOpen(true);
}}>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{currentOptions.length > 0 ? (
<div className="space-y-3">
{currentOptions.map((option) => {
const hasColumns = columns.length > 0 && option.columnValues;
const inputTypeLabel = getInputTypeLabel(option.inputType);
return (
<div key={option.id} className="p-4 border rounded hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<span className="font-medium text-base">{option.label}</span>
<Badge variant="outline" className="text-xs">{inputTypeLabel}</Badge>
{option.required && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
</div>
<div className="text-sm text-muted-foreground space-y-1">
<div className="flex gap-2">
<span className="font-medium min-w-16">(Value):</span>
<span>{option.value}</span>
</div>
{option.placeholder && (
<div className="flex gap-2">
<span className="font-medium min-w-16">:</span>
<span>{option.placeholder}</span>
</div>
)}
{option.defaultValue && (
<div className="flex gap-2">
<span className="font-medium min-w-16">:</span>
<span>{option.defaultValue}</span>
</div>
)}
{option.inputType === 'dropdown' && option.options && (
<div className="flex gap-2">
<span className="font-medium min-w-16">:</span>
<div className="flex flex-wrap gap-1">
{option.options.map((opt, idx) => (
<Badge key={idx} variant="secondary" className="text-xs">{opt}</Badge>
))}
</div>
</div>
)}
</div>
{hasColumns && (
<div className="mt-3 pt-3 border-t">
<p className="text-xs font-medium text-muted-foreground mb-2"> </p>
<div className="grid grid-cols-2 gap-2 text-sm">
{columns.map((column) => (
<div key={column.id} className="flex gap-2">
<span className="text-muted-foreground">{column.name}:</span>
<span>{option.columnValues?.[column.key] || '-'}</span>
</div>
))}
</div>
</div>
)}
</div>
<Button variant="ghost" size="sm" onClick={() => handleDeleteOption(currentTabKey, option.id)}>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</div>
);
})}
</div>
) : (
<div className="border-2 border-dashed rounded-lg p-8 text-center text-gray-500">
<Settings className="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p className="mb-2"> </p>
<p className="text-sm"> &quot;&quot; </p>
</div>
)}
</div>
);
};
return (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription>, , </CardDescription>
</CardHeader>
<CardContent>
{/* 속성 하위 탭 (칩 형태) */}
<div className="flex items-center gap-2 mb-6 border-b pb-2">
<div className="flex gap-2 flex-1 flex-wrap">
{attributeSubTabs.sort((a, b) => a.order - b.order).map(tab => (
<Button
key={tab.id}
variant={activeAttributeTab === tab.key ? 'default' : 'outline'}
size="sm"
onClick={() => setActiveAttributeTab(tab.key)}
className="rounded-full"
>
{tab.label}
</Button>
))}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setIsManageAttributeTabsDialogOpen(true)}
className="shrink-0"
>
<Settings className="w-4 h-4 mr-1" />
</Button>
</div>
{/* 단위 관리 */}
{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();
})()}
</CardContent>
</Card>
);
}

View File

@@ -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 (
<AlertDialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2 text-destructive">
<AlertCircle className="h-5 w-5" />
{title}
</AlertDialogTitle>
<AlertDialogDescription className="text-base">
{message}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={onClose}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -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<string, string>;
setNewOptionColumnValues: (values: Record<string, string>) => 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<string, AttributeColumn[]>;
handleAddOption: () => void;
// Column Manage Dialog
isColumnManageDialogOpen: boolean;
setIsColumnManageDialogOpen: (open: boolean) => void;
managingColumnType: string;
setAttributeColumns: React.Dispatch<React.SetStateAction<Record<string, AttributeColumn[]>>>;
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<ItemPage>) => void;
// Page Dialog
isPageDialogOpen: boolean;
setIsPageDialogOpen: (open: boolean) => void;
newPageName: string;
setNewPageName: (name: string) => void;
newPageItemType: 'FG' | 'PT' | 'SM' | 'RM' | 'CS';
setNewPageItemType: React.Dispatch<React.SetStateAction<'FG' | 'PT' | 'SM' | 'RM' | 'CS'>>;
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<React.SetStateAction<TextboxColumn[]>>;
newFieldConditionEnabled: boolean;
setNewFieldConditionEnabled: (enabled: boolean) => void;
newFieldConditionTargetType: 'field' | 'section';
setNewFieldConditionTargetType: (type: 'field' | 'section') => void;
newFieldConditionFields: ConditionField[];
setNewFieldConditionFields: React.Dispatch<React.SetStateAction<ConditionField[]>>;
newFieldConditionSections: ConditionSection[];
setNewFieldConditionSections: React.Dispatch<React.SetStateAction<ConditionSection[]>>;
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<React.SetStateAction<string[]>>;
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<React.SetStateAction<string[]>>;
newMasterFieldAttributeType: string;
setNewMasterFieldAttributeType: (type: string) => void;
newMasterFieldMultiColumn: boolean;
setNewMasterFieldMultiColumn: (multiColumn: boolean) => void;
newMasterFieldColumnCount: number;
setNewMasterFieldColumnCount: (count: number) => void;
newMasterFieldColumnNames: string[];
setNewMasterFieldColumnNames: React.Dispatch<React.SetStateAction<string[]>>;
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<React.SetStateAction<string[]>>;
templateFieldDescription: string;
setTemplateFieldDescription: (description: string) => void;
templateFieldMultiColumn: boolean;
setTemplateFieldMultiColumn: (multiColumn: boolean) => void;
templateFieldColumnCount: number;
setTemplateFieldColumnCount: (count: number) => void;
templateFieldColumnNames: string[];
setTemplateFieldColumnNames: React.Dispatch<React.SetStateAction<string[]>>;
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<void>;
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<void>;
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 (
<>
<TabManagementDialogs
isManageTabsDialogOpen={isManageTabsDialogOpen}
setIsManageTabsDialogOpen={setIsManageTabsDialogOpen}
customTabs={customTabs}
moveTabUp={moveTabUp}
moveTabDown={moveTabDown}
handleEditTabFromManage={handleEditTabFromManage}
handleDeleteTab={handleDeleteTab}
getTabIcon={getTabIcon}
setIsAddTabDialogOpen={setIsAddTabDialogOpen}
isDeleteTabDialogOpen={isDeleteTabDialogOpen}
setIsDeleteTabDialogOpen={setIsDeleteTabDialogOpen}
deletingTabId={deletingTabId}
setDeletingTabId={setDeletingTabId}
confirmDeleteTab={confirmDeleteTab}
isAddTabDialogOpen={isAddTabDialogOpen}
editingTabId={editingTabId}
setEditingTabId={setEditingTabId}
newTabLabel={newTabLabel}
setNewTabLabel={setNewTabLabel}
handleUpdateTab={handleUpdateTab}
handleAddTab={handleAddTab}
isManageAttributeTabsDialogOpen={isManageAttributeTabsDialogOpen}
setIsManageAttributeTabsDialogOpen={setIsManageAttributeTabsDialogOpen}
attributeSubTabs={attributeSubTabs}
moveAttributeTabUp={moveAttributeTabUp}
moveAttributeTabDown={moveAttributeTabDown}
handleDeleteAttributeTab={handleDeleteAttributeTab}
isDeleteAttributeTabDialogOpen={isDeleteAttributeTabDialogOpen}
setIsDeleteAttributeTabDialogOpen={setIsDeleteAttributeTabDialogOpen}
deletingAttributeTabId={deletingAttributeTabId}
setDeletingAttributeTabId={setDeletingAttributeTabId}
confirmDeleteAttributeTab={confirmDeleteAttributeTab}
isAddAttributeTabDialogOpen={isAddAttributeTabDialogOpen}
setIsAddAttributeTabDialogOpen={setIsAddAttributeTabDialogOpen}
editingAttributeTabId={editingAttributeTabId}
setEditingAttributeTabId={setEditingAttributeTabId}
newAttributeTabLabel={newAttributeTabLabel}
setNewAttributeTabLabel={setNewAttributeTabLabel}
handleUpdateAttributeTab={handleUpdateAttributeTab}
handleAddAttributeTab={handleAddAttributeTab}
/>
<OptionDialog
isOpen={isOptionDialogOpen}
setIsOpen={setIsOptionDialogOpen}
newOptionValue={newOptionValue}
setNewOptionValue={setNewOptionValue}
newOptionLabel={newOptionLabel}
setNewOptionLabel={setNewOptionLabel}
newOptionColumnValues={newOptionColumnValues}
setNewOptionColumnValues={setNewOptionColumnValues}
newOptionInputType={newOptionInputType}
setNewOptionInputType={setNewOptionInputType}
newOptionRequired={newOptionRequired}
setNewOptionRequired={setNewOptionRequired}
newOptionOptions={newOptionOptions}
setNewOptionOptions={setNewOptionOptions}
newOptionPlaceholder={newOptionPlaceholder}
setNewOptionPlaceholder={setNewOptionPlaceholder}
newOptionDefaultValue={newOptionDefaultValue}
setNewOptionDefaultValue={setNewOptionDefaultValue}
editingOptionType={editingOptionType}
attributeSubTabs={attributeSubTabs}
attributeColumns={attributeColumns}
handleAddOption={handleAddOption}
/>
<ColumnManageDialog
isOpen={isColumnManageDialogOpen}
setIsOpen={setIsColumnManageDialogOpen}
managingColumnType={managingColumnType}
attributeSubTabs={attributeSubTabs}
attributeColumns={attributeColumns}
setAttributeColumns={setAttributeColumns}
newColumnName={newColumnName}
setNewColumnName={setNewColumnName}
newColumnKey={newColumnKey}
setNewColumnKey={setNewColumnKey}
newColumnType={newColumnType}
setNewColumnType={setNewColumnType}
newColumnRequired={newColumnRequired}
setNewColumnRequired={setNewColumnRequired}
/>
<PathEditDialog
editingPathPageId={editingPathPageId}
setEditingPathPageId={setEditingPathPageId}
editingAbsolutePath={editingAbsolutePath}
setEditingAbsolutePath={setEditingAbsolutePath}
updateItemPage={updateItemPage}
trackChange={() => {}}
/>
<PageDialog
isPageDialogOpen={isPageDialogOpen}
setIsPageDialogOpen={setIsPageDialogOpen}
newPageName={newPageName}
setNewPageName={setNewPageName}
newPageItemType={newPageItemType}
setNewPageItemType={setNewPageItemType}
handleAddPage={handleAddPage}
/>
<SectionDialog
isSectionDialogOpen={isSectionDialogOpen}
setIsSectionDialogOpen={setIsSectionDialogOpen}
newSectionType={newSectionType}
setNewSectionType={setNewSectionType}
newSectionTitle={newSectionTitle}
setNewSectionTitle={setNewSectionTitle}
newSectionDescription={newSectionDescription}
setNewSectionDescription={setNewSectionDescription}
handleAddSection={handleAddSection}
sectionInputMode={sectionInputMode}
setSectionInputMode={setSectionInputMode}
sectionTemplates={sectionsAsTemplates}
selectedTemplateId={selectedSectionTemplateId}
setSelectedTemplateId={setSelectedSectionTemplateId}
handleLinkTemplate={handleLinkTemplate}
/>
{/* 항목 추가/수정 다이얼로그 - 데스크톱 */}
{!isMobile && (
<FieldDialog
isOpen={isFieldDialogOpen}
onOpenChange={setIsFieldDialogOpen}
editingFieldId={editingFieldId}
setEditingFieldId={setEditingFieldId}
fieldInputMode={fieldInputMode}
setFieldInputMode={setFieldInputMode}
showMasterFieldList={showMasterFieldList}
setShowMasterFieldList={setShowMasterFieldList}
selectedMasterFieldId={selectedMasterFieldId}
setSelectedMasterFieldId={setSelectedMasterFieldId}
textboxColumns={textboxColumns}
setTextboxColumns={setTextboxColumns}
newFieldConditionEnabled={newFieldConditionEnabled}
setNewFieldConditionEnabled={setNewFieldConditionEnabled}
newFieldConditionTargetType={newFieldConditionTargetType}
setNewFieldConditionTargetType={setNewFieldConditionTargetType}
newFieldConditionFields={newFieldConditionFields}
setNewFieldConditionFields={setNewFieldConditionFields}
newFieldConditionSections={newFieldConditionSections}
setNewFieldConditionSections={setNewFieldConditionSections}
tempConditionValue={tempConditionValue}
setTempConditionValue={setTempConditionValue}
newFieldName={newFieldName}
setNewFieldName={setNewFieldName}
newFieldKey={newFieldKey}
setNewFieldKey={setNewFieldKey}
newFieldInputType={newFieldInputType}
setNewFieldInputType={setNewFieldInputType}
newFieldRequired={newFieldRequired}
setNewFieldRequired={setNewFieldRequired}
newFieldDescription={newFieldDescription}
setNewFieldDescription={setNewFieldDescription}
newFieldOptions={newFieldOptions}
setNewFieldOptions={setNewFieldOptions}
selectedSectionForField={selectedPage?.sections.find(s => s.id === selectedSectionForField) || null}
selectedPage={selectedPage || null}
itemMasterFields={itemMasterFields}
handleAddField={handleAddField}
setIsColumnDialogOpen={setIsColumnDialogOpen}
setEditingColumnId={setEditingColumnId}
setColumnName={setColumnName}
setColumnKey={setColumnKey}
/>
)}
{/* 항목 추가/수정 다이얼로그 - 모바일 (바텀시트) */}
{isMobile && (
<FieldDrawer
isOpen={isFieldDialogOpen}
onOpenChange={setIsFieldDialogOpen}
editingFieldId={editingFieldId}
setEditingFieldId={setEditingFieldId}
fieldInputMode={fieldInputMode}
setFieldInputMode={setFieldInputMode}
showMasterFieldList={showMasterFieldList}
setShowMasterFieldList={setShowMasterFieldList}
selectedMasterFieldId={selectedMasterFieldId}
setSelectedMasterFieldId={setSelectedMasterFieldId}
textboxColumns={textboxColumns}
setTextboxColumns={setTextboxColumns}
newFieldConditionEnabled={newFieldConditionEnabled}
setNewFieldConditionEnabled={setNewFieldConditionEnabled}
newFieldConditionTargetType={newFieldConditionTargetType}
setNewFieldConditionTargetType={setNewFieldConditionTargetType}
newFieldConditionFields={newFieldConditionFields}
setNewFieldConditionFields={setNewFieldConditionFields}
newFieldConditionSections={newFieldConditionSections}
setNewFieldConditionSections={setNewFieldConditionSections}
tempConditionValue={tempConditionValue}
setTempConditionValue={setTempConditionValue}
newFieldName={newFieldName}
setNewFieldName={setNewFieldName}
newFieldKey={newFieldKey}
setNewFieldKey={setNewFieldKey}
newFieldInputType={newFieldInputType}
setNewFieldInputType={setNewFieldInputType}
newFieldRequired={newFieldRequired}
setNewFieldRequired={setNewFieldRequired}
newFieldDescription={newFieldDescription}
setNewFieldDescription={setNewFieldDescription}
newFieldOptions={newFieldOptions}
setNewFieldOptions={setNewFieldOptions}
selectedSectionForField={selectedPage?.sections.find(s => s.id === selectedSectionForField) || null}
selectedPage={selectedPage || null}
itemMasterFields={itemMasterFields}
handleAddField={handleAddField}
setIsColumnDialogOpen={setIsColumnDialogOpen}
setEditingColumnId={setEditingColumnId}
setColumnName={setColumnName}
setColumnKey={setColumnKey}
/>
)}
{/* 텍스트박스 컬럼 추가/수정 다이얼로그 */}
<ColumnDialog
isColumnDialogOpen={isColumnDialogOpen}
setIsColumnDialogOpen={setIsColumnDialogOpen}
editingColumnId={editingColumnId}
setEditingColumnId={setEditingColumnId}
columnName={columnName}
setColumnName={setColumnName}
columnKey={columnKey}
setColumnKey={setColumnKey}
textboxColumns={textboxColumns}
setTextboxColumns={setTextboxColumns}
/>
<MasterFieldDialog
isMasterFieldDialogOpen={isMasterFieldDialogOpen}
setIsMasterFieldDialogOpen={setIsMasterFieldDialogOpen}
editingMasterFieldId={editingMasterFieldId}
setEditingMasterFieldId={setEditingMasterFieldId}
newMasterFieldName={newMasterFieldName}
setNewMasterFieldName={setNewMasterFieldName}
newMasterFieldKey={newMasterFieldKey}
setNewMasterFieldKey={setNewMasterFieldKey}
newMasterFieldInputType={newMasterFieldInputType}
setNewMasterFieldInputType={setNewMasterFieldInputType}
newMasterFieldRequired={newMasterFieldRequired}
setNewMasterFieldRequired={setNewMasterFieldRequired}
newMasterFieldCategory={newMasterFieldCategory}
setNewMasterFieldCategory={setNewMasterFieldCategory}
newMasterFieldDescription={newMasterFieldDescription}
setNewMasterFieldDescription={setNewMasterFieldDescription}
newMasterFieldOptions={newMasterFieldOptions}
setNewMasterFieldOptions={setNewMasterFieldOptions}
newMasterFieldAttributeType={newMasterFieldAttributeType}
setNewMasterFieldAttributeType={setNewMasterFieldAttributeType}
newMasterFieldMultiColumn={newMasterFieldMultiColumn}
setNewMasterFieldMultiColumn={setNewMasterFieldMultiColumn}
newMasterFieldColumnCount={newMasterFieldColumnCount}
setNewMasterFieldColumnCount={setNewMasterFieldColumnCount}
newMasterFieldColumnNames={newMasterFieldColumnNames}
setNewMasterFieldColumnNames={setNewMasterFieldColumnNames}
handleUpdateMasterField={handleUpdateMasterField}
handleAddMasterField={handleAddMasterField}
/>
<SectionTemplateDialog
isSectionTemplateDialogOpen={isSectionTemplateDialogOpen}
setIsSectionTemplateDialogOpen={setIsSectionTemplateDialogOpen}
editingSectionTemplateId={editingSectionTemplateId}
setEditingSectionTemplateId={setEditingSectionTemplateId}
newSectionTemplateTitle={newSectionTemplateTitle}
setNewSectionTemplateTitle={setNewSectionTemplateTitle}
newSectionTemplateDescription={newSectionTemplateDescription}
setNewSectionTemplateDescription={setNewSectionTemplateDescription}
newSectionTemplateCategory={newSectionTemplateCategory}
setNewSectionTemplateCategory={setNewSectionTemplateCategory}
newSectionTemplateType={newSectionTemplateType}
setNewSectionTemplateType={setNewSectionTemplateType}
handleUpdateSectionTemplate={handleUpdateSectionTemplate}
handleAddSectionTemplate={handleAddSectionTemplate}
/>
<TemplateFieldDialog
isTemplateFieldDialogOpen={isTemplateFieldDialogOpen}
setIsTemplateFieldDialogOpen={setIsTemplateFieldDialogOpen}
editingTemplateFieldId={editingTemplateFieldId}
setEditingTemplateFieldId={setEditingTemplateFieldId}
templateFieldName={templateFieldName}
setTemplateFieldName={setTemplateFieldName}
templateFieldKey={templateFieldKey}
setTemplateFieldKey={setTemplateFieldKey}
templateFieldInputType={templateFieldInputType}
setTemplateFieldInputType={setTemplateFieldInputType}
templateFieldRequired={templateFieldRequired}
setTemplateFieldRequired={setTemplateFieldRequired}
templateFieldOptions={templateFieldOptions}
setTemplateFieldOptions={setTemplateFieldOptions}
templateFieldDescription={templateFieldDescription}
setTemplateFieldDescription={setTemplateFieldDescription}
templateFieldMultiColumn={templateFieldMultiColumn}
setTemplateFieldMultiColumn={setTemplateFieldMultiColumn}
templateFieldColumnCount={templateFieldColumnCount}
setTemplateFieldColumnCount={setTemplateFieldColumnCount}
templateFieldColumnNames={templateFieldColumnNames}
setTemplateFieldColumnNames={setTemplateFieldColumnNames}
handleAddTemplateField={handleAddTemplateField}
itemMasterFields={itemMasterFields}
templateFieldInputMode={templateFieldInputMode}
setTemplateFieldInputMode={setTemplateFieldInputMode}
showMasterFieldList={templateFieldShowMasterFieldList}
setShowMasterFieldList={setTemplateFieldShowMasterFieldList}
selectedMasterFieldId={templateFieldSelectedMasterFieldId}
setSelectedMasterFieldId={setTemplateFieldSelectedMasterFieldId}
/>
<LoadTemplateDialog
isLoadTemplateDialogOpen={isLoadTemplateDialogOpen}
setIsLoadTemplateDialogOpen={setIsLoadTemplateDialogOpen}
sectionTemplates={sectionTemplates}
selectedTemplateId={selectedTemplateId}
setSelectedTemplateId={setSelectedTemplateId}
handleLoadTemplate={handleLoadTemplate}
/>
{/* 섹션 불러오기 다이얼로그 */}
<ImportSectionDialog
isOpen={isImportSectionDialogOpen}
setIsOpen={setIsImportSectionDialogOpen}
independentSections={independentSections}
selectedSectionId={selectedImportSectionId}
setSelectedSectionId={setSelectedImportSectionId}
onImport={handleImportSection}
onRefresh={refreshIndependentSections}
onGetUsage={getSectionUsage}
/>
{/* 필드 불러오기 다이얼로그 */}
<ImportFieldDialog
isOpen={isImportFieldDialogOpen}
setIsOpen={setIsImportFieldDialogOpen}
fields={itemMasterFields}
selectedFieldId={selectedImportFieldId}
setSelectedFieldId={setSelectedImportFieldId}
onImport={handleImportField}
onRefresh={refreshIndependentFields}
onGetUsage={getFieldUsage}
targetSectionTitle={
importFieldTargetSectionId
? selectedPage?.sections.find(s => s.id === importFieldTargetSectionId)?.title
: undefined
}
/>
</>
);
}

View File

@@ -1,2 +1,6 @@
export { DraggableSection } from './DraggableSection';
export { DraggableField } from './DraggableField';
export { DraggableField } from './DraggableField';
// 2025-12-24: Phase 2 UI 컴포넌트 분리
export { AttributeTabContent } from './AttributeTabContent';
// ItemMasterDialogs는 props가 너무 많아 사용하지 않음 (2025-12-24 결정)

View File

@@ -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<ErrorAlertContextType | null>(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<ErrorAlertState>({
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 (
<ErrorAlertContext.Provider value={{ showErrorAlert }}>
{children}
{/* 에러 알림 다이얼로그 */}
<AlertDialog open={errorAlert.open} onOpenChange={(isOpen) => !isOpen && closeErrorAlert()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2 text-destructive">
<AlertCircle className="h-5 w-5" />
{errorAlert.title}
</AlertDialogTitle>
<AlertDialogDescription className="text-base text-foreground">
{errorAlert.message}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={closeErrorAlert}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ErrorAlertContext.Provider>
);
}

View File

@@ -0,0 +1 @@
export { ErrorAlertProvider, useErrorAlert } from './ErrorAlertContext';

View File

@@ -17,4 +17,17 @@ export { useAttributeManagement } from './useAttributeManagement';
export type { UseAttributeManagementReturn } from './useAttributeManagement';
export { useTabManagement } from './useTabManagement';
export type { UseTabManagementReturn, CustomTab, AttributeSubTab } from './useTabManagement';
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';

View File

@@ -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<void>;
handleResetAllData: (
setUnitOptions: React.Dispatch<React.SetStateAction<UnitOption[]>>,
setMaterialOptions: React.Dispatch<React.SetStateAction<MaterialOption[]>>,
setSurfaceTreatmentOptions: React.Dispatch<React.SetStateAction<SurfaceTreatmentOption[]>>,
setCustomAttributeOptions: React.Dispatch<React.SetStateAction<Record<string, UnitOption[]>>>,
setAttributeColumns: React.Dispatch<React.SetStateAction<Record<string, OptionColumn[]>>>,
setBomItems: React.Dispatch<React.SetStateAction<BOMItem[]>>,
setCustomTabs: React.Dispatch<React.SetStateAction<CustomTab[]>>,
setAttributeSubTabs: React.Dispatch<React.SetStateAction<AttributeSubTab[]>>,
) => 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<React.SetStateAction<UnitOption[]>>,
setMaterialOptions: React.Dispatch<React.SetStateAction<MaterialOption[]>>,
setSurfaceTreatmentOptions: React.Dispatch<React.SetStateAction<SurfaceTreatmentOption[]>>,
setCustomAttributeOptions: React.Dispatch<React.SetStateAction<Record<string, UnitOption[]>>>,
setAttributeColumns: React.Dispatch<React.SetStateAction<Record<string, OptionColumn[]>>>,
setBomItems: React.Dispatch<React.SetStateAction<BOMItem[]>>,
setCustomTabs: React.Dispatch<React.SetStateAction<CustomTab[]>>,
setAttributeSubTabs: React.Dispatch<React.SetStateAction<AttributeSubTab[]>>,
) => {
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,
};
}

View File

@@ -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<ErrorAlertState>({
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,
};
}

View File

@@ -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<number | null>(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('항목 처리에 실패했습니다', '오류');
}
}
};

View File

@@ -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<React.SetStateAction<boolean>>;
selectedImportSectionId: number | null;
setSelectedImportSectionId: React.Dispatch<React.SetStateAction<number | null>>;
// 필드 Import 상태
isImportFieldDialogOpen: boolean;
setIsImportFieldDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
selectedImportFieldId: number | null;
setSelectedImportFieldId: React.Dispatch<React.SetStateAction<number | null>>;
importFieldTargetSectionId: number | null;
setImportFieldTargetSectionId: React.Dispatch<React.SetStateAction<number | null>>;
// 핸들러
handleImportSection: (selectedPageId: number | null) => Promise<void>;
handleImportField: (selectedPage: ItemPage | null) => Promise<void>;
handleCloneSection: (sectionId: number) => Promise<void>;
}
export function useImportManagement(): UseImportManagementReturn {
const {
linkSectionToPage,
linkFieldToSection,
cloneSection,
} = useItemMaster();
// 섹션 Import 상태
const [isImportSectionDialogOpen, setIsImportSectionDialogOpen] = useState(false);
const [selectedImportSectionId, setSelectedImportSectionId] = useState<number | null>(null);
// 필드 Import 상태
const [isImportFieldDialogOpen, setIsImportFieldDialogOpen] = useState(false);
const [selectedImportFieldId, setSelectedImportFieldId] = useState<number | null>(null);
const [importFieldTargetSectionId, setImportFieldTargetSectionId] = useState<number | null>(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,
};
}

View File

@@ -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<void>;
}
interface UseInitialDataLoadingProps {
setCustomTabs: React.Dispatch<React.SetStateAction<CustomTab[]>>;
setUnitOptions: React.Dispatch<React.SetStateAction<UnitOption[]>>;
}
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<string | null>(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,
};
}

View File

@@ -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<React.SetStateAction<string[]>>;
// 핸들러
handleAddMasterField: () => void;
handleAddMasterField: () => Promise<void>;
handleEditMasterField: (field: ItemMasterField) => void;
handleUpdateMasterField: () => void;
handleDeleteMasterField: (id: number) => void;
handleUpdateMasterField: () => Promise<void>;
handleDeleteMasterField: (id: number) => Promise<void>;
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<number | null>(null);
@@ -77,7 +82,7 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn {
const [newMasterFieldColumnNames, setNewMasterFieldColumnNames] = useState<string[]>(['컬럼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('항목 삭제에 실패했습니다');
}
}
}
};

View File

@@ -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<void>;
moveField: (selectedPage: ItemPage | null, sectionId: number, dragFieldId: number, hoverFieldId: number) => Promise<void>;
}
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,
};
}

View File

@@ -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<number | null>(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('항목 처리에 실패했습니다', '오류');
}
}
};

View File

@@ -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<string, any> | null; // 검증 규칙
properties: Record<string, any> | 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<string, any> | null; // 조건부 표시 설정 (displayCondition → display_condition)
validation_rules?: Record<string, any> | null; // 검증 규칙
options?: Array<{ label: string; value: string }> | null; // dropdown 옵션
properties?: Record<string, any> | 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);

View File

@@ -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<UnitOptionRequest>): Promise<ApiResponse<UnitOptionResponse>> => {
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<UnitOptionResponse> = 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<ApiResponse<void>> => {
const startTime = apiLogger.logRequest('DELETE', `${BASE_URL}/item-master/unit-options/${id}`);
@@ -1922,4 +1957,276 @@ export const itemMasterApi = {
}
},
},
// ============================================
// 재질 관리
// ============================================
materials: {
list: async (): Promise<ApiResponse<any[]>> => {
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<any[]> = 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<ApiResponse<any>> => {
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<any> = 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<ApiResponse<any>> => {
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<any> = 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<ApiResponse<void>> => {
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<void> = 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<ApiResponse<any[]>> => {
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<any[]> = 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<ApiResponse<any>> => {
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<any> = 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<ApiResponse<any>> => {
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<any> = 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<ApiResponse<void>> => {
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<void> = 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;
}
},
},
};

View File

@@ -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,
};
};

View File

@@ -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<PageEntity>
): Record<string, unknown> => ({
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<SectionEntity>
): Record<string, unknown> => {
// 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<FieldEntity>
): Record<string, unknown> => ({
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<BOMItemEntity>
): Record<string, unknown> => ({
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,
});

View File

@@ -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<string, unknown> | null;
validation_rules?: Record<string, unknown> | null;
options?: Array<{ label: string; value: string }> | null;
properties?: Record<string, unknown> | 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<string, unknown> | null;
properties: Record<string, unknown> | null;
created_by: number | null;
updated_by: number | null;
created_at: string;
updated_at: string;
}
// ===== 스토어 상태 타입 =====
/**
* 정규화된 엔티티 상태
*/
export interface EntitiesState {
pages: Record<number, PageEntity>;
sections: Record<number, SectionEntity>;
fields: Record<number, FieldEntity>;
bomItems: Record<number, BOMItemEntity>;
}
/**
* 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<PageEntity, 'id' | 'sectionIds' | 'created_at' | 'updated_at'>) => Promise<PageEntity>;
updatePage: (id: number, updates: Partial<PageEntity>) => Promise<void>;
deletePage: (id: number) => Promise<void>;
}
/**
* 섹션 액션
*/
export interface SectionActions {
loadSections: (sections: SectionEntity[]) => void;
createSection: (section: Omit<SectionEntity, 'id' | 'fieldIds' | 'bomItemIds' | 'created_at' | 'updated_at'>) => Promise<SectionEntity>;
createSectionInPage: (pageId: number, section: Omit<SectionEntity, 'id' | 'page_id' | 'fieldIds' | 'bomItemIds' | 'created_at' | 'updated_at'>) => Promise<SectionEntity>;
updateSection: (id: number, updates: Partial<SectionEntity>) => Promise<void>;
deleteSection: (id: number) => Promise<void>;
linkSectionToPage: (sectionId: number, pageId: number) => Promise<void>;
unlinkSectionFromPage: (sectionId: number) => Promise<void>;
reorderSections: (pageId: number, dragSectionId: number, hoverSectionId: number) => Promise<void>;
}
/**
* 필드 액션
*/
export interface FieldActions {
loadFields: (fields: FieldEntity[]) => void;
createField: (field: Omit<FieldEntity, 'id' | 'created_at' | 'updated_at'>) => Promise<FieldEntity>;
createFieldInSection: (sectionId: number, field: Omit<FieldEntity, 'id' | 'section_id' | 'created_at' | 'updated_at'>) => Promise<FieldEntity>;
updateField: (id: number, updates: Partial<FieldEntity>) => Promise<void>;
deleteField: (id: number) => Promise<void>;
linkFieldToSection: (fieldId: number, sectionId: number) => Promise<void>;
unlinkFieldFromSection: (fieldId: number) => Promise<void>;
reorderFields: (sectionId: number, dragFieldId: number, hoverFieldId: number) => Promise<void>;
}
/**
* BOM 액션
*/
export interface BOMActions {
loadBomItems: (items: BOMItemEntity[]) => void;
createBomItem: (item: Omit<BOMItemEntity, 'id' | 'created_at' | 'updated_at'>) => Promise<BOMItemEntity>;
updateBomItem: (id: number, updates: Partial<BOMItemEntity>) => Promise<void>;
deleteBomItem: (id: number) => Promise<void>;
}
/**
* 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<ItemUnitRef>;
updateUnit: (
id: string,
updates: {
unitCode?: string;
unitName?: string;
description?: string;
isActive?: boolean;
}
) => Promise<void>;
deleteUnit: (id: string) => Promise<void>;
// 재질 CRUD
addMaterial: (data: {
materialCode: string;
materialName: string;
materialType: MaterialType;
thickness?: string;
description?: string;
isActive?: boolean;
}) => Promise<ItemMaterialRef>;
updateMaterial: (
id: string,
updates: {
materialCode?: string;
materialName?: string;
materialType?: MaterialType;
thickness?: string;
description?: string;
isActive?: boolean;
}
) => Promise<void>;
deleteMaterial: (id: string) => Promise<void>;
// 표면처리 CRUD
addTreatment: (data: {
treatmentCode: string;
treatmentName: string;
treatmentType: TreatmentType;
description?: string;
isActive?: boolean;
}) => Promise<SurfaceTreatmentRef>;
updateTreatment: (
id: string,
updates: {
treatmentCode?: string;
treatmentName?: string;
treatmentType?: TreatmentType;
description?: string;
isActive?: boolean;
}
) => Promise<void>;
deleteTreatment: (id: string) => Promise<void>;
// 섹션 복제
cloneSection: (sectionId: number) => Promise<SectionEntity>;
}
/**
* 전체 스토어 타입
*/
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<void>;
}
// ===== 파생 상태 (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[];
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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<string, any> | null; // 검증 규칙
properties: Record<string, any> | 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<string, any> | null; // 조건부 표시 설정 (displayCondition → display_condition)
validation_rules?: Record<string, any> | null; // 검증 규칙
options?: Array<{ label: string; value: string }> | null; // dropdown 옵션
properties?: Record<string, any> | 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;
}