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:
@@ -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) |
|
||||
@@ -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. **마이그레이션**: 테스트 완료 후 기존 페이지 대체
|
||||
@@ -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 응답 정규화
|
||||
@@ -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 문서 확인하고 시작해.
|
||||
```
|
||||
270
claudedocs/item-master/[PLAN-2025-12-24] hook-extraction-plan.md
Normal file
270
claudedocs/item-master/[PLAN-2025-12-24] hook-extraction-plan.md
Normal 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
35
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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">
|
||||
항목 탭에서 추가한 "{masterField.field_name}" 항목의 속성값들입니다
|
||||
</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>에서 "{masterField.field_name}" 항목을 편집하여 추가/수정/삭제할 수 있습니다.
|
||||
</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">위 "추가" 버튼을 클릭하여 새로운 속성을 추가할 수 있습니다</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 결정)
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ErrorAlertProvider, useErrorAlert } from './ErrorAlertContext';
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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('항목 처리에 실패했습니다', '오류');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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('항목 삭제에 실패했습니다');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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('항목 처리에 실패했습니다', '오류');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
344
src/stores/item-master/normalizers.ts
Normal file
344
src/stores/item-master/normalizers.ts
Normal 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,
|
||||
});
|
||||
562
src/stores/item-master/types.ts
Normal file
562
src/stores/item-master/types.ts
Normal 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[];
|
||||
}
|
||||
1161
src/stores/item-master/useItemMasterStore.ts
Normal file
1161
src/stores/item-master/useItemMasterStore.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
392
src/types/item-master.types.ts
Normal file
392
src/types/item-master.types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user