refactor: 품목기준관리 hooks 분리 및 다이얼로그 개선

- ItemMasterDataManagement 컴포넌트에서 hooks 분리
- 다이얼로그 컴포넌트들 타입 및 구조 개선
- BOMManagementSection 개선
- HierarchyTab 업데이트

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-11-26 14:06:48 +09:00
parent 593644922a
commit b73603822b
25 changed files with 3559 additions and 1703 deletions

View File

@@ -12,13 +12,62 @@
### 1.1 목적
품목기준관리 화면에서 품목의 메타데이터(페이지, 섹션, 필드)를 동적으로 정의하기 위한 백엔드 API 개발 요청
### 1.2 프론트엔드 구현 현황
### 1.2 핵심 개념 정의
#### "페이지"의 의미
- **❌ 실제 라우팅 경로(URL path)가 아님**
- **✅ 품목유형별 필드 구성 템플릿**
```
품목기준관리의 "페이지" = 품목유형(FG, PT, SM, RM, CS)별 입력 필드 구성 정의
예시:
├─ 페이지: "제품 등록" (item_type: FG)
│ └─ 품목유형이 "제품(FG)"일 때 보여줄 섹션/필드 정의
└─ 페이지: "부품 등록" (item_type: PT)
└─ 품목유형이 "부품(PT)"일 때 보여줄 섹션/필드 정의
```
#### 실제 사용 흐름
```
[품목 등록 페이지] /items/create
├─ 사용자가 품목유형 선택 (예: "제품(FG)")
├─ API 호출: item_type='FG'인 페이지 설정 로드
└─ 해당 페이지에 정의된 섹션/필드로 입력 폼 동적 렌더링
```
#### 멀티테넌트 연동 구조
```
Tenant A (회사 A)
├─ 품목기준관리 설정
│ ├─ 제품(FG): 섹션 3개, 필드 15개
│ └─ 부품(PT): 섹션 2개, 필드 8개
└─ 품목관리 (/items)
└─ 품목유형 선택 시 Tenant A의 설정으로 폼 렌더링
Tenant B (회사 B)
├─ 품목기준관리 설정
│ ├─ 제품(FG): 섹션 5개, 필드 25개 ← 다른 구성
│ └─ 부품(PT): 섹션 1개, 필드 5개
└─ 품목관리 (/items)
└─ 품목유형 선택 시 Tenant B의 설정으로 폼 렌더링
```
**핵심**: 모든 테이블에 `tenant_id`가 있어 테넌트별 독립적인 설정 관리 가능
### 1.3 프론트엔드 구현 현황
- 프론트엔드 UI 구현 완료
- API 클라이언트 코드 작성 완료 (`src/lib/api/item-master.ts`)
- 타입 정의 완료 (`src/types/item-master-api.ts`)
- Next.js API 프록시 구조 적용 (HttpOnly 쿠키 인증)
### 1.3 API 기본 정보
### 1.4 API 기본 정보
| 항목 | 값 |
|------|-----|
| Base URL | `/api/v1/item-master` |
@@ -26,7 +75,7 @@
| Content-Type | `application/json` |
| 응답 형식 | 표준 API 응답 래퍼 사용 |
### 1.4 표준 응답 형식
### 1.5 표준 응답 형식
```json
{
"success": true,

View File

@@ -0,0 +1,116 @@
# 품목기준관리 - API 대기 및 다음 작업
**작성일**: 2025-11-26
**상태**: API 대기 중
---
## 1. 현재 대기 중인 API 작업
### 1.1 계층구조(페이지) 탭
| 기능 | 설명 | 상태 |
|------|------|------|
| 생성 데이터 연결 | 최하위 항목(필드)까지 공통으로 연결 | ⏳ 대기 |
| 페이지 삭제 | 실제 삭제 (Soft Delete) | ⏳ 대기 |
| 섹션 연결 끊기 | 삭제가 아닌 연결만 해제 (`page_id = null`) | ⏳ 대기 |
| 항목 연결 끊기 | 삭제가 아닌 연결만 해제 | ⏳ 대기 |
| 섹션 불러오기 | 기존 섹션 목록에서 선택하여 연결 | ⏳ 대기 |
| 섹션 리스트 조회 | 연결 가능한 섹션 목록 표시 | ⏳ 대기 |
| 항목 불러오기 | 기존 항목 목록에서 선택하여 연결 | ⏳ 대기 |
| 항목 리스트 조회 | 연결 가능한 항목 목록 표시 | ⏳ 대기 |
### 1.2 섹션 탭
| 기능 | 설명 | 상태 |
|------|------|------|
| 항목 불러오기 | 마스터 항목에서 선택하여 추가 | ⏳ 대기 |
| 항목 리스트 조회 | 추가 가능한 마스터 항목 목록 | ⏳ 대기 |
### 1.3 데이터 동기화
| 기능 | 설명 | 상태 |
|------|------|------|
| 개별 수정 시 연결된 데이터 동기화 | 마스터 항목 수정 → 연결된 모든 필드에 반영 | ⏳ 대기 |
---
## 2. 삭제 vs 연결 끊기 정리
```
[계층구조 탭에서]
├─ 페이지 삭제 → 실제 삭제 (Soft Delete)
├─ 섹션 제거 → 연결만 끊기 (page_id = null), 섹션 데이터는 유지
└─ 항목 제거 → 연결만 끊기 (section_id = null), 항목 데이터는 유지
[섹션 탭에서]
├─ 섹션 삭제 → 실제 삭제 (Soft Delete)
└─ 항목 삭제 → 실제 삭제 (Soft Delete)
[마스터 항목 탭에서]
└─ 마스터 항목 삭제 → 실제 삭제 (참조된 필드는 master_field_id = null)
```
---
## 3. 데이터 연결 구조
```
마스터 항목 (master_fields)
↓ 참조 (master_field_id)
섹션 템플릿 항목 (template_fields) ←──┐
↓ 복사 │
섹션 내 항목 (fields) ───────────────┘
↑ 소속 (section_id)
섹션 (sections)
↑ 소속 (page_id) - 연결/해제 가능
페이지 (pages) = 품목유형별 필드 구성
```
---
## 4. 작업 순서
### Step 1: 품목기준관리 API 연동 (현재 대기)
- 위 1~3번 항목 API 연동
- 품목기준관리 페이지 최종 완료
### Step 2: 품목관리 동적 렌더링 API 검토
- 품목기준관리 완료 시점에 필요한 API 다시 검토
- 추가 API 필요 여부 확인
- `[API-2025-11-24] item-management-dynamic-api-spec.md` 업데이트
### Step 3: 품목관리 페이지 동적 렌더링 구현
```
품목 등록 페이지 (/items/create)
├─ 품목유형 선택 (FG, PT, SM, RM, CS)
├─ GET /api/v1/item-master/pages?item_type={선택된유형}
└─ 응답받은 섹션/필드 구조로 동적 폼 생성
```
### 4.2 참고 문서
- `claudedocs/[API-2025-11-25] item-master-data-management-api-request.md`
- `claudedocs/[API-2025-11-24] item-management-dynamic-api-spec.md`
- `src/types/item-master-api.ts`
- `src/lib/api/item-master.ts`
---
## 5. 핵심 개념 (잊지 말 것!)
> **"페이지"는 실제 URL 경로가 아니라, 품목유형별 필드 구성 템플릿이다!**
```
품목기준관리의 "페이지"
= 품목유형(FG, PT, SM, RM, CS)별로
= 품목 등록 시 어떤 섹션/필드를 보여줄지 정의하는 템플릿
```
---
**다음 세션 시작 시**: 이 문서 확인 후 API 상태 체크하고 작업 진행

View File

@@ -0,0 +1,427 @@
# ItemMasterDataManagement 훅 분리 리팩토링
## 개요
- **목표**: 2000줄+ 컴포넌트를 도메인별 커스텀 훅으로 분리
- **예상 결과**: 메인 컴포넌트 ~200줄로 축소 (90% 감소)
- **시작일**: 2025-11-26
## 진행 상태
### Phase 1: 커스텀 훅 생성 ✅ 완료
| # | 훅 이름 | 상태 수 | 핸들러 수 | 상태 | 비고 |
|---|---------|---------|----------|------|------|
| 1 | usePageManagement | ~8 | 3 | [x] 완료 | 가장 단순, 먼저 시작 |
| 2 | useSectionManagement | ~10 | 5 | [x] 완료 | Page에 의존 |
| 3 | useFieldManagement | ~15 | 4 | [x] 완료 | Section에 의존 |
| 4 | useMasterFieldManagement | ~12 | 4 | [x] 완료 | 독립적 |
| 5 | useTemplateManagement | ~18 | 11 | [x] 완료 | 가장 복잡 (BOM 포함) |
| 6 | useAttributeManagement | ~15 | 6 | [x] 완료 | 옵션/칼럼 관리 |
| 7 | useTabManagement | ~12 | 14 | [x] 완료 | 탭 상태 관리 |
### Phase 2: 메인 컴포넌트 정리 ✅ 완료
| # | 작업 | 상태 | 비고 |
|---|------|------|------|
| 2.1 | 훅 import 추가 | [x] 완료 | 7개 훅 import |
| 2.2 | 훅 초기화 코드 추가 | [x] 완료 | 컴포넌트 상단에 훅 호출 |
| 2.3 | useState 제거 - 페이지 관련 | [x] 완료 | usePageManagement로 대체 |
| 2.4 | useState 제거 - 섹션 관련 | [x] 완료 | useSectionManagement로 대체 |
| 2.5 | useState 제거 - 필드 관련 | [x] 완료 | useFieldManagement로 대체 |
| 2.6 | useState 제거 - 마스터필드 관련 | [x] 완료 | useMasterFieldManagement로 대체 |
| 2.7 | useState 제거 - 템플릿 관련 | [x] 완료 | useTemplateManagement로 대체 |
| 2.8 | useState 제거 - 속성 관련 | [x] 완료 | useAttributeManagement로 대체 |
| 2.9 | useState 제거 - 탭 관련 | [x] 완료 | useTabManagement로 대체 |
| 2.10 | 핸들러 함수 제거 | [x] 완료 | 훅에서 가져온 핸들러로 대체 |
| 2.11 | useEffect 제거 | [x] 완료 | 훅 내부로 이동된 것들 |
| 2.12 | 하위 컴포넌트 props 연결 | [x] 완료 | 다이얼로그/탭 컴포넌트에 훅 값 전달 + wrapper 함수 |
| 2.13 | 타입 체크 | [x] 완료 | 기존 타입 에러만 남음 (훅과 무관) |
| 2.14 | 빌드 테스트 | [x] 완료 | npm run build 성공! |
| 2.15 | 기능 테스트 | [ ] 대기 | 브라우저에서 동작 확인 필요 |
---
## Phase 2 상세 가이드
### 2.1 훅 Import 추가
```typescript
// ItemMasterDataManagement.tsx 상단에 추가
import {
usePageManagement,
useSectionManagement,
useFieldManagement,
useMasterFieldManagement,
useTemplateManagement,
useAttributeManagement,
useTabManagement,
} from './ItemMasterDataManagement/hooks';
```
### 2.2 훅 초기화 코드
```typescript
// 컴포넌트 함수 내부 상단에 추가
export function ItemMasterDataManagement() {
const pageManagement = usePageManagement();
const sectionManagement = useSectionManagement();
const fieldManagement = useFieldManagement();
const masterFieldManagement = useMasterFieldManagement();
const templateManagement = useTemplateManagement();
const attributeManagement = useAttributeManagement();
const tabManagement = useTabManagement();
// ... 기존 코드
}
```
### 2.3~2.9 useState 제거 패턴
**변경 전:**
```typescript
const [selectedPageId, setSelectedPageId] = useState<number | null>(null);
```
**변경 후:**
```typescript
const { selectedPageId, setSelectedPageId } = pageManagement;
// 또는 구조분해 없이 pageManagement.selectedPageId 사용
```
### 2.10 핸들러 교체 패턴
**변경 전:**
```typescript
const handleAddPage = async () => {
// 100줄의 핸들러 코드
};
```
**변경 후:**
```typescript
// 삭제하고, pageManagement.handleAddPage 사용
// 또는 상단에서 구조분해
const { handleAddPage } = pageManagement;
```
### 2.12 하위 컴포넌트 Props 연결 예시
```typescript
<PageDialog
isOpen={pageManagement.isPageDialogOpen}
onClose={() => pageManagement.setIsPageDialogOpen(false)}
onSubmit={pageManagement.handleAddPage}
pageName={pageManagement.newPageName}
setPageName={pageManagement.setNewPageName}
// ...
/>
```
### Phase 2 완료 기준 (실제 결과)
- [x] 메인 컴포넌트 줄 수: 2893줄 → 1575줄 (45% 감소) *목표는 90%였으나 렌더링 로직이 많아 45% 달성*
- [x] 모든 useState가 훅으로 이동 (핵심 상태 ~100개)
- [x] 모든 핸들러가 훅에서 제공 (~50개 핸들러)
- [x] 타입 에러: 훅 관련 새 에러 없음 (기존 에러만 존재)
- [x] 빌드 성공
- [ ] 기능 동작 정상 (브라우저 테스트 대기)
---
## 상세 작업 내역
### 1. usePageManagement
**포함될 상태:**
```typescript
- selectedPageId
- editingPageId
- editingPageName
- isPageDialogOpen
- newPageName
- newPageItemType
- editingPathPageId
- editingAbsolutePath
```
**포함될 핸들러:**
```typescript
- handleAddPage
- handleDuplicatePage
- handleDeletePageWithTracking
```
**작업 체크리스트:**
- [ ] hooks/usePageManagement.ts 파일 생성
- [ ] 상태 이동
- [ ] 핸들러 이동
- [ ] 메인 컴포넌트에서 훅 사용으로 변경
- [ ] 기능 테스트
---
### 2. useSectionManagement
**포함될 상태:**
```typescript
- editingSectionId
- editingSectionTitle
- isSectionDialogOpen
- newSectionTitle
- newSectionDescription
- newSectionType
- sectionInputMode
- selectedSectionTemplateId
- expandedSections
```
**포함될 핸들러:**
```typescript
- handleAddSection
- handleLinkTemplate
- handleEditSectionTitle
- handleSaveSectionTitle
- handleDeleteSectionWithTracking
```
**작업 체크리스트:**
- [ ] hooks/useSectionManagement.ts 파일 생성
- [ ] 상태 이동
- [ ] 핸들러 이동
- [ ] 메인 컴포넌트에서 훅 사용으로 변경
- [ ] 기능 테스트
---
### 3. useFieldManagement
**포함될 상태:**
```typescript
- isFieldDialogOpen
- selectedSectionForField
- editingFieldId
- fieldInputMode
- showMasterFieldList
- selectedMasterFieldId
- newFieldName, newFieldKey, newFieldInputType, newFieldRequired
- newFieldOptions, newFieldDescription
- textboxColumns, isColumnDialogOpen, editingColumnId, columnName, columnKey
- newFieldConditionEnabled, newFieldConditionTargetType
- newFieldConditionFields, newFieldConditionSections
- tempConditionFieldKey, tempConditionValue
```
**포함될 핸들러:**
```typescript
- handleAddField
- handleEditField
- handleDeleteFieldWithTracking
- (useEffect for masterField selection)
```
**작업 체크리스트:**
- [ ] hooks/useFieldManagement.ts 파일 생성
- [ ] 상태 이동
- [ ] 핸들러 이동
- [ ] 메인 컴포넌트에서 훅 사용으로 변경
- [ ] 기능 테스트
---
### 4. useMasterFieldManagement
**포함될 상태:**
```typescript
- isMasterFieldDialogOpen
- editingMasterFieldId
- newMasterFieldName, newMasterFieldKey, newMasterFieldInputType
- newMasterFieldRequired, newMasterFieldCategory, newMasterFieldDescription
- newMasterFieldOptions, newMasterFieldAttributeType
- newMasterFieldMultiColumn, newMasterFieldColumnCount, newMasterFieldColumnNames
```
**포함될 핸들러:**
```typescript
- handleAddMasterField
- handleEditMasterField
- handleUpdateMasterField
- handleDeleteMasterField
```
**작업 체크리스트:**
- [ ] hooks/useMasterFieldManagement.ts 파일 생성
- [ ] 상태 이동
- [ ] 핸들러 이동
- [ ] 메인 컴포넌트에서 훅 사용으로 변경
- [ ] 기능 테스트
---
### 5. useTemplateManagement
**포함될 상태:**
```typescript
- isSectionTemplateDialogOpen
- editingSectionTemplateId
- newSectionTemplateTitle, newSectionTemplateDescription
- newSectionTemplateCategory, newSectionTemplateType
- isLoadTemplateDialogOpen, selectedTemplateId
- expandedTemplateId
- isTemplateFieldDialogOpen, currentTemplateId, editingTemplateFieldId
- templateFieldName, templateFieldKey, templateFieldInputType
- templateFieldRequired, templateFieldOptions, templateFieldDescription
- templateFieldMultiColumn, templateFieldColumnCount, templateFieldColumnNames
- templateFieldInputMode, templateFieldShowMasterFieldList, templateFieldSelectedMasterFieldId
```
**포함될 핸들러:**
```typescript
- handleAddSectionTemplate
- handleEditSectionTemplate
- handleUpdateSectionTemplate
- handleDeleteSectionTemplate
- handleLoadTemplate
- handleAddTemplateField
- handleEditTemplateField
- handleDeleteTemplateField
- handleAddBOMItemToTemplate
- handleUpdateBOMItemInTemplate
- handleDeleteBOMItemFromTemplate
```
**작업 체크리스트:**
- [ ] hooks/useTemplateManagement.ts 파일 생성
- [ ] 상태 이동
- [ ] 핸들러 이동
- [ ] 메인 컴포넌트에서 훅 사용으로 변경
- [ ] 기능 테스트
---
### 6. useAttributeManagement
**포함될 상태:**
```typescript
- unitOptions, materialOptions, surfaceTreatmentOptions
- customAttributeOptions
- isOptionDialogOpen, editingOptionType
- newOptionValue, newOptionLabel, newOptionColumnValues
- newOptionInputType, newOptionRequired, newOptionOptions
- newOptionPlaceholder, newOptionDefaultValue
- isColumnManageDialogOpen, managingColumnType
- attributeColumns
- newColumnName, newColumnKey, newColumnType, newColumnRequired
```
**포함될 핸들러:**
```typescript
- handleAddOption
- handleDeleteOption
- (useEffect for attribute sync)
```
**작업 체크리스트:**
- [ ] hooks/useAttributeManagement.ts 파일 생성
- [ ] 상태 이동
- [ ] 핸들러 이동
- [ ] 메인 컴포넌트에서 훅 사용으로 변경
- [ ] 기능 테스트
---
### 7. useTabManagement
**포함될 상태:**
```typescript
- customTabs
- activeTab
- attributeSubTabs, activeAttributeTab
- isAddTabDialogOpen, isManageTabsDialogOpen
- newTabLabel, editingTabId, deletingTabId, isDeleteTabDialogOpen
- isManageAttributeTabsDialogOpen, isAddAttributeTabDialogOpen
- newAttributeTabLabel, editingAttributeTabId
- deletingAttributeTabId, isDeleteAttributeTabDialogOpen
```
**포함될 핸들러:**
```typescript
- handleAddTab
- handleEditTab
- handleDeleteTab
- handleAddAttributeTab
- (useEffect for attributeSubTabs sync)
```
**작업 체크리스트:**
- [ ] hooks/useTabManagement.ts 파일 생성
- [ ] 상태 이동
- [ ] 핸들러 이동
- [ ] 메인 컴포넌트에서 훅 사용으로 변경
- [ ] 기능 테스트
---
## 작업 로그
### 2025-11-26
- 체크리스트 문서 생성
- 분석 완료: 총 7개 훅으로 분리 예정
- ✅ usePageManagement.ts 생성 완료 (타입 에러 없음)
- ✅ useSectionManagement.ts 생성 완료 (타입 에러 없음)
- ✅ useFieldManagement.ts 생성 완료 (타입 에러 수정 완료)
- ✅ useMasterFieldManagement.ts 생성 완료 (타입 에러 수정: 'ㄹ' 오타 제거)
- ✅ useTemplateManagement.ts 생성 완료 (타입 에러 없음, ~350줄)
- ✅ useAttributeManagement.ts 생성 완료 (타입 에러 없음, ~250줄)
- ✅ useTabManagement.ts 생성 완료 (타입 에러 없음, ~330줄)
- 🎉 **Phase 1 완료!** 모든 7개 훅 생성 완료
- 📁 hooks/index.ts에 모든 훅 export 완료
### Phase 2 완료 (2025-11-26 추가)
- ✅ 메인 컴포넌트에서 훅 import 및 연결 완료
- ✅ props drilling 정리 - wrapper 함수 패턴 적용
- ✅ 렌더링 로직만 남기기 - 2893줄 → 1575줄 (45% 감소)
- ✅ 빌드 테스트 성공
- ⏳ 기능 테스트 - 브라우저에서 확인 필요
### 주요 기술 결정 (Phase 2)
1. **Wrapper 함수 패턴**: 훅 함수에 selectedPage 바인딩 필요한 경우 wrapper 사용
- `handleAddSectionWrapper`, `handleLinkTemplateWrapper`
2. **타입 호환성**: setter 함수 타입 불일치는 `as any`로 우회
- `setNewSectionTypeWrapper`, `setNewPageItemTypeWrapper`
3. **기존 타입 에러**: `default_properties` 등 기존 타입 정의 문제는 Phase 2 범위 외
### 다음 단계
- [ ] 기능 테스트 (브라우저)
- [ ] 추가 최적화 (선택)
---
## 참고 사항
### 훅 간 의존성
```
usePageManagement (독립)
useSectionManagement (selectedPageId 필요)
useFieldManagement (selectedSectionId 필요)
useMasterFieldManagement (독립)
useTemplateManagement (독립, but masterFields 참조)
useAttributeManagement (독립, but masterFields와 연동)
useTabManagement (독립)
```
### 파일 구조 (목표)
```
ItemMasterDataManagement/
├── hooks/
│ ├── index.ts
│ ├── usePageManagement.ts
│ ├── useSectionManagement.ts
│ ├── useFieldManagement.ts
│ ├── useMasterFieldManagement.ts
│ ├── useTemplateManagement.ts
│ ├── useAttributeManagement.ts
│ └── useTabManagement.ts
├── tabs/
├── dialogs/
├── components/
├── utils/
├── types.ts
└── index.tsx (메인 컴포넌트, ~200줄 목표)
```

View File

@@ -41,6 +41,13 @@ export function BOMManagementSection({
const [unit, setUnit] = useState('EA');
const [itemType, setItemType] = useState('part');
const [note, setNote] = useState('');
const [isSubmitted, setIsSubmitted] = useState(false);
// 유효성 검사
const isItemCodeEmpty = !itemCode.trim();
const isItemNameEmpty = !itemName.trim();
const qty = parseFloat(quantity);
const isQuantityInvalid = isNaN(qty) || qty <= 0;
const handleOpenDialog = (item?: BOMItem) => {
if (item) {
@@ -61,16 +68,25 @@ export function BOMManagementSection({
setNote('');
}
setIsDialogOpen(true);
setIsSubmitted(false);
};
const handleClose = () => {
setIsDialogOpen(false);
setEditingId(null);
setItemCode('');
setItemName('');
setQuantity('1');
setUnit('EA');
setItemType('part');
setNote('');
setIsSubmitted(false);
};
const handleSave = () => {
if (!itemCode.trim() || !itemName.trim()) {
return toast.error('품목코드와 품목명을 입력해주세요');
}
const qty = parseFloat(quantity);
if (isNaN(qty) || qty <= 0) {
return toast.error('올바른 수량을 입력해주세요');
setIsSubmitted(true);
if (isItemCodeEmpty || isItemNameEmpty || isQuantityInvalid) {
return;
}
const itemData = {
@@ -89,7 +105,7 @@ export function BOMManagementSection({
toast.success('BOM 품목이 추가되었습니다');
}
setIsDialogOpen(false);
handleClose();
};
const handleDelete = (id: number) => {
@@ -170,10 +186,7 @@ export function BOMManagementSection({
<Dialog
open={isDialogOpen}
onOpenChange={(open) => {
setIsDialogOpen(open);
if (!open) {
setEditingId(null);
}
if (!open) handleClose();
}}
>
<DialogContent className="max-w-2xl">
@@ -191,7 +204,11 @@ export function BOMManagementSection({
value={itemCode}
onChange={(e) => setItemCode(e.target.value)}
placeholder="예: PART-001"
className={isSubmitted && isItemCodeEmpty ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{isSubmitted && isItemCodeEmpty && (
<p className="text-xs text-red-500 mt-1"> </p>
)}
</div>
<div>
<Label> *</Label>
@@ -199,7 +216,11 @@ export function BOMManagementSection({
value={itemName}
onChange={(e) => setItemName(e.target.value)}
placeholder="예: 샤프트"
className={isSubmitted && isItemNameEmpty ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{isSubmitted && isItemNameEmpty && (
<p className="text-xs text-red-500 mt-1"> </p>
)}
</div>
</div>
@@ -213,7 +234,11 @@ export function BOMManagementSection({
placeholder="1"
min="0"
step="0.01"
className={isSubmitted && isQuantityInvalid ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{isSubmitted && isQuantityInvalid && (
<p className="text-xs text-red-500 mt-1"> (0 )</p>
)}
</div>
<div>
<Label> *</Label>
@@ -257,9 +282,7 @@ export function BOMManagementSection({
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
</Button>
<Button variant="outline" onClick={handleClose}></Button>
<Button onClick={handleSave}></Button>
</DialogFooter>
</DialogContent>

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
'use client';
import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
@@ -31,9 +32,24 @@ export function ColumnDialog({
textboxColumns,
setTextboxColumns,
}: ColumnDialogProps) {
const [isSubmitted, setIsSubmitted] = useState(false);
// 유효성 검사
const isNameEmpty = !columnName.trim();
const isKeyEmpty = !columnKey.trim();
const handleClose = () => {
setIsColumnDialogOpen(false);
setEditingColumnId(null);
setColumnName('');
setColumnKey('');
setIsSubmitted(false);
};
const handleSubmit = () => {
if (!columnName.trim() || !columnKey.trim()) {
return toast.error('모든 필드를 입력해주세요');
setIsSubmitted(true);
if (isNameEmpty || isKeyEmpty) {
return;
}
if (editingColumnId) {
@@ -54,20 +70,14 @@ export function ColumnDialog({
toast.success('컬럼이 추가되었습니다');
}
setIsColumnDialogOpen(false);
setEditingColumnId(null);
setColumnName('');
setColumnKey('');
handleClose();
setIsSubmitted(false);
};
return (
<Dialog open={isColumnDialogOpen} onOpenChange={(open) => {
setIsColumnDialogOpen(open);
if (!open) {
setEditingColumnId(null);
setColumnName('');
setColumnKey('');
}
if (!open) handleClose();
else setIsColumnDialogOpen(open);
}}>
<DialogContent>
<DialogHeader>
@@ -83,7 +93,11 @@ export function ColumnDialog({
value={columnName}
onChange={(e) => setColumnName(e.target.value)}
placeholder="예: 가로"
className={isSubmitted && isNameEmpty ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{isSubmitted && isNameEmpty && (
<p className="text-xs text-red-500 mt-1"> </p>
)}
</div>
<div>
<Label> *</Label>
@@ -91,11 +105,15 @@ export function ColumnDialog({
value={columnKey}
onChange={(e) => setColumnKey(e.target.value)}
placeholder="예: width"
className={isSubmitted && isKeyEmpty ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{isSubmitted && isKeyEmpty && (
<p className="text-xs text-red-500 mt-1"> </p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsColumnDialogOpen(false)}></Button>
<Button variant="outline" onClick={handleClose}></Button>
<Button onClick={handleSubmit}>
{editingColumnId ? '수정' : '추가'}
</Button>

View File

@@ -1,5 +1,6 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -121,7 +122,14 @@ export function FieldDialog({
setColumnName,
setColumnKey,
}: FieldDialogProps) {
const [isSubmitted, setIsSubmitted] = useState(false);
// 유효성 검사
const isNameEmpty = !newFieldName.trim();
const isKeyEmpty = !newFieldKey.trim();
const handleClose = () => {
setIsSubmitted(false);
onOpenChange(false);
setEditingFieldId(null);
setFieldInputMode('custom');
@@ -268,7 +276,11 @@ export function FieldDialog({
value={newFieldName}
onChange={(e) => setNewFieldName(e.target.value)}
placeholder="예: 품목명"
className={isSubmitted && isNameEmpty ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{isSubmitted && isNameEmpty && (
<p className="text-xs text-red-500 mt-1"> </p>
)}
</div>
<div>
<Label> *</Label>
@@ -276,7 +288,11 @@ export function FieldDialog({
value={newFieldKey}
onChange={(e) => setNewFieldKey(e.target.value)}
placeholder="예: itemName"
className={isSubmitted && isKeyEmpty ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{isSubmitted && isKeyEmpty && (
<p className="text-xs text-red-500 mt-1"> </p>
)}
</div>
</div>
@@ -402,7 +418,12 @@ export function FieldDialog({
</div>
<DialogFooter className="shrink-0 bg-white z-10 px-6 py-4 border-t">
<Button variant="outline" onClick={handleClose}></Button>
<Button onClick={handleAddField}></Button>
<Button onClick={() => {
setIsSubmitted(true);
if ((fieldInputMode === 'custom' || editingFieldId) && (isNameEmpty || isKeyEmpty)) return;
handleAddField();
setIsSubmitted(false);
}}></Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -189,21 +189,22 @@ export function FieldDrawer({
<div
key={field.id}
className={`p-3 border rounded cursor-pointer transition-colors ${
selectedMasterFieldId === field.id
selectedMasterFieldId === String(field.id)
? 'bg-blue-50 border-blue-300'
: 'hover:bg-gray-50'
}`}
onClick={() => {
setSelectedMasterFieldId(field.id);
setNewFieldName(field.name);
setNewFieldKey(field.fieldKey);
setNewFieldInputType(field.property.inputType);
setNewFieldRequired(field.property.required);
setSelectedMasterFieldId(String(field.id));
setNewFieldName(field.field_name);
setNewFieldKey(`field_${field.id}`);
setNewFieldInputType(field.field_type);
setNewFieldRequired((field.properties as any)?.required ?? false);
setNewFieldDescription(field.description || '');
setNewFieldOptions(field.property.options?.join(', ') || '');
if (field.property.multiColumn && field.property.columnNames) {
setNewFieldOptions(field.options?.map(o => o.value).join(', ') || '');
const props = field.properties as any;
if (props?.multiColumn && props?.columnNames) {
setTextboxColumns(
field.property.columnNames.map((name, idx) => ({
props.columnNames.map((name: string, idx: number) => ({
id: `col-${idx}`,
name,
key: `column${idx + 1}`
@@ -215,28 +216,26 @@ export function FieldDrawer({
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{field.name}</span>
<span className="font-medium">{field.field_name}</span>
<Badge variant="outline" className="text-xs">
{INPUT_TYPE_OPTIONS.find(o => o.value === field.property.inputType)?.label}
{INPUT_TYPE_OPTIONS.find(o => o.value === field.field_type)?.label}
</Badge>
{field.property.required && (
{(field.properties as any)?.required && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
</div>
{field.description && (
<p className="text-xs text-muted-foreground mt-1">{field.description}</p>
)}
{Array.isArray(field.category) && field.category.length > 0 && (
{field.category && (
<div className="flex gap-1 mt-1">
{field.category.map((cat, idx) => (
<Badge key={idx} variant="secondary" className="text-xs">
{cat}
</Badge>
))}
<Badge variant="secondary" className="text-xs">
{field.category}
</Badge>
</div>
)}
</div>
{selectedMasterFieldId === field.id && (
{selectedMasterFieldId === String(field.id) && (
<Check className="h-5 w-5 text-blue-600" />
)}
</div>
@@ -585,22 +584,23 @@ export function FieldDrawer({
</p>
<div className="space-y-2 max-h-60 overflow-y-auto">
{selectedPage.sections
.filter(section => section.type !== 'bom')
.filter(section => section.section_type !== 'BOM')
.map(section => (
<label key={section.id} className="flex items-center gap-2 p-2 bg-muted rounded cursor-pointer hover:bg-muted/80">
<input
type="checkbox"
checked={newFieldConditionSections.includes(section.id)}
checked={newFieldConditionSections.includes(String(section.id))}
onChange={(e) => {
const sectionIdStr = String(section.id);
if (e.target.checked) {
setNewFieldConditionSections(prev => [...prev, section.id]);
setNewFieldConditionSections(prev => [...prev, sectionIdStr]);
} else {
setNewFieldConditionSections(prev => prev.filter(id => id !== section.id));
setNewFieldConditionSections(prev => prev.filter(id => id !== sectionIdStr));
}
}}
className="cursor-pointer"
/>
<span className="flex-1 text-sm">{section.title}</span>
<span className="flex-1 text-sm">{section.section_name}</span>
</label>
))}
</div>

View File

@@ -1,5 +1,6 @@
'use client';
import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
@@ -79,23 +80,45 @@ export function MasterFieldDialog({
handleUpdateMasterField,
handleAddMasterField,
}: MasterFieldDialogProps) {
const [isSubmitted, setIsSubmitted] = useState(false);
// 유효성 검사
const isNameEmpty = !newMasterFieldName.trim();
const isKeyEmpty = !newMasterFieldKey.trim();
const handleClose = () => {
setIsMasterFieldDialogOpen(false);
setEditingMasterFieldId(null);
setNewMasterFieldName('');
setNewMasterFieldKey('');
setNewMasterFieldInputType('textbox');
setNewMasterFieldRequired(false);
setNewMasterFieldCategory('공통');
setNewMasterFieldDescription('');
setNewMasterFieldOptions('');
setNewMasterFieldAttributeType('custom');
setNewMasterFieldMultiColumn(false);
setNewMasterFieldColumnCount(2);
setNewMasterFieldColumnNames(['컬럼1', '컬럼2']);
setIsSubmitted(false);
};
const handleSubmit = () => {
setIsSubmitted(true);
if (!isNameEmpty && !isKeyEmpty) {
if (editingMasterFieldId) {
handleUpdateMasterField();
} else {
handleAddMasterField();
}
setIsSubmitted(false);
}
};
return (
<Dialog open={isMasterFieldDialogOpen} onOpenChange={(open) => {
setIsMasterFieldDialogOpen(open);
if (!open) {
setEditingMasterFieldId(null);
setNewMasterFieldName('');
setNewMasterFieldKey('');
setNewMasterFieldInputType('textbox');
setNewMasterFieldRequired(false);
setNewMasterFieldCategory('공통');
setNewMasterFieldDescription('');
setNewMasterFieldOptions('');
setNewMasterFieldAttributeType('custom');
setNewMasterFieldMultiColumn(false);
setNewMasterFieldColumnCount(2);
setNewMasterFieldColumnNames(['컬럼1', '컬럼2']);
}
if (!open) handleClose();
else setIsMasterFieldDialogOpen(open);
}}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
@@ -112,7 +135,11 @@ export function MasterFieldDialog({
value={newMasterFieldName}
onChange={(e) => setNewMasterFieldName(e.target.value)}
placeholder="예: 품목명"
className={isSubmitted && isNameEmpty ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{isSubmitted && isNameEmpty && (
<p className="text-xs text-red-500 mt-1"> </p>
)}
</div>
<div>
<Label> *</Label>
@@ -120,7 +147,11 @@ export function MasterFieldDialog({
value={newMasterFieldKey}
onChange={(e) => setNewMasterFieldKey(e.target.value)}
placeholder="예: itemName"
className={isSubmitted && isKeyEmpty ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{isSubmitted && isKeyEmpty && (
<p className="text-xs text-red-500 mt-1"> </p>
)}
</div>
</div>
@@ -251,8 +282,8 @@ export function MasterFieldDialog({
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsMasterFieldDialogOpen(false)}></Button>
<Button onClick={editingMasterFieldId ? handleUpdateMasterField : handleAddMasterField}>
<Button variant="outline" onClick={handleClose}></Button>
<Button onClick={handleSubmit}>
{editingMasterFieldId ? '수정' : '추가'}
</Button>
</DialogFooter>

View File

@@ -1,5 +1,6 @@
'use client';
import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
@@ -72,19 +73,38 @@ export function OptionDialog({
attributeColumns,
handleAddOption,
}: OptionDialogProps) {
const [isSubmitted, setIsSubmitted] = useState(false);
// 유효성 검사
const isValueEmpty = !newOptionValue.trim();
const isLabelEmpty = !newOptionLabel.trim();
const isDropdownOptionsEmpty = newOptionInputType === 'dropdown' && !newOptionOptions.trim();
const handleClose = () => {
setIsOpen(false);
setNewOptionValue('');
setNewOptionLabel('');
setNewOptionColumnValues({});
setNewOptionInputType('textbox');
setNewOptionRequired(false);
setNewOptionOptions('');
setNewOptionPlaceholder('');
setNewOptionDefaultValue('');
setIsSubmitted(false);
};
const handleSubmit = () => {
setIsSubmitted(true);
if (!isValueEmpty && !isLabelEmpty && !isDropdownOptionsEmpty) {
handleAddOption();
setIsSubmitted(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => {
setIsOpen(open);
if (!open) {
setNewOptionValue('');
setNewOptionLabel('');
setNewOptionColumnValues({});
setNewOptionInputType('textbox');
setNewOptionRequired(false);
setNewOptionOptions('');
setNewOptionPlaceholder('');
setNewOptionDefaultValue('');
}
if (!open) handleClose();
else setIsOpen(open);
}}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
@@ -109,7 +129,11 @@ export function OptionDialog({
value={newOptionValue}
onChange={(e) => setNewOptionValue(e.target.value)}
placeholder="예: kg, stainless"
className={isSubmitted && isValueEmpty ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{isSubmitted && isValueEmpty && (
<p className="text-xs text-red-500 mt-1"> </p>
)}
</div>
<div>
<Label> () *</Label>
@@ -117,7 +141,11 @@ export function OptionDialog({
value={newOptionLabel}
onChange={(e) => setNewOptionLabel(e.target.value)}
placeholder="예: 킬로그램, 스테인리스"
className={isSubmitted && isLabelEmpty ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{isSubmitted && isLabelEmpty && (
<p className="text-xs text-red-500 mt-1"> </p>
)}
</div>
</div>
</div>
@@ -151,10 +179,15 @@ export function OptionDialog({
value={newOptionOptions}
onChange={(e) => setNewOptionOptions(e.target.value)}
placeholder="옵션1, 옵션2, 옵션3 (쉼표로 구분)"
className={isSubmitted && isDropdownOptionsEmpty ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
<p className="text-xs text-muted-foreground mt-1">
</p>
{isSubmitted && isDropdownOptionsEmpty ? (
<p className="text-xs text-red-500 mt-1"> </p>
) : (
<p className="text-xs text-muted-foreground mt-1">
</p>
)}
</div>
)}
@@ -213,8 +246,8 @@ export function OptionDialog({
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsOpen(false)}></Button>
<Button onClick={handleAddOption}></Button>
<Button variant="outline" onClick={handleClose}></Button>
<Button onClick={handleSubmit}></Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -1,5 +1,6 @@
'use client';
import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
@@ -33,27 +34,53 @@ export function PageDialog({
setNewPageItemType,
handleAddPage,
}: PageDialogProps) {
const [isSubmitted, setIsSubmitted] = useState(false);
// 유효성 검사
const isPageNameEmpty = !newPageName.trim();
const handleSubmit = () => {
setIsSubmitted(true);
if (!isPageNameEmpty) {
handleAddPage();
setIsSubmitted(false);
}
};
const handleClose = () => {
setIsPageDialogOpen(false);
setNewPageName('');
setNewPageItemType('FG');
setIsSubmitted(false);
};
return (
<Dialog open={isPageDialogOpen} onOpenChange={(open) => {
setIsPageDialogOpen(open);
if (!open) {
setNewPageName('');
setNewPageItemType('FG');
handleClose();
} else {
setIsPageDialogOpen(open);
}
}}>
<DialogContent>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> </DialogDescription>
<DialogTitle> </DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label> *</Label>
<Label> *</Label>
<Input
value={newPageName}
onChange={(e) => setNewPageName(e.target.value)}
placeholder="예: 품 등록"
placeholder="예: 품 등록"
className={isSubmitted && isPageNameEmpty ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{isSubmitted && isPageNameEmpty && (
<p className="text-xs text-red-500 mt-1">
</p>
)}
</div>
<div>
<Label> *</Label>
@@ -70,8 +97,8 @@ export function PageDialog({
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsPageDialogOpen(false)}></Button>
<Button onClick={handleAddPage}></Button>
<Button variant="outline" onClick={handleClose}></Button>
<Button onClick={handleSubmit}></Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -1,5 +1,6 @@
'use client';
import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
@@ -23,12 +24,21 @@ export function PathEditDialog({
updateItemPage,
trackChange,
}: PathEditDialogProps) {
const [isSubmitted, setIsSubmitted] = useState(false);
// 유효성 검사
const isPathEmpty = !editingAbsolutePath.trim();
const isPathInvalid = editingAbsolutePath.trim() && !editingAbsolutePath.startsWith('/');
const handleClose = () => {
setEditingPathPageId(null);
setEditingAbsolutePath('');
setIsSubmitted(false);
};
return (
<Dialog open={editingPathPageId !== null} onOpenChange={(open) => {
if (!open) {
setEditingPathPageId(null);
setEditingAbsolutePath('');
}
if (!open) handleClose();
}}>
<DialogContent>
<DialogHeader>
@@ -42,25 +52,30 @@ export function PathEditDialog({
value={editingAbsolutePath}
onChange={(e) => setEditingAbsolutePath(e.target.value)}
placeholder="/제품관리/제품등록"
className={isSubmitted && (isPathEmpty || isPathInvalid) ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
<p className="text-xs text-gray-500 mt-1">(/) , </p>
{isSubmitted && isPathEmpty && (
<p className="text-xs text-red-500 mt-1"> </p>
)}
{isSubmitted && isPathInvalid && (
<p className="text-xs text-red-500 mt-1"> (/) </p>
)}
{!isSubmitted || (!isPathEmpty && !isPathInvalid) ? (
<p className="text-xs text-gray-500 mt-1">(/) , </p>
) : null}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditingPathPageId(null)}></Button>
<Button variant="outline" onClick={handleClose}></Button>
<Button onClick={() => {
if (!editingAbsolutePath.trim()) {
toast.error('절대경로를 입력해주세요');
return;
}
if (!editingAbsolutePath.startsWith('/')) {
toast.error('절대경로는 슬래시(/)로 시작해야 합니다');
setIsSubmitted(true);
if (isPathEmpty || isPathInvalid) {
return;
}
if (editingPathPageId) {
updateItemPage(editingPathPageId, { absolutePath: editingAbsolutePath });
trackChange('pages', editingPathPageId, 'update', { absolutePath: editingAbsolutePath });
setEditingPathPageId(null);
trackChange('pages', String(editingPathPageId), 'update', { absolutePath: editingAbsolutePath });
handleClose();
toast.success('절대경로가 수정되었습니다 (저장 필요)');
}
}}></Button>

View File

@@ -1,12 +1,13 @@
'use client';
import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { FileText, Package, Check, X } from 'lucide-react';
import { FileText, Package, Check } from 'lucide-react';
import type { SectionTemplate } from '@/contexts/ItemMasterContext';
interface SectionDialogProps {
@@ -45,6 +46,11 @@ export function SectionDialog({
setSelectedTemplateId,
handleLinkTemplate,
}: SectionDialogProps) {
const [isSubmitted, setIsSubmitted] = useState(false);
// 유효성 검사
const isTitleEmpty = !newSectionTitle.trim();
const handleClose = () => {
setIsSectionDialogOpen(false);
setNewSectionType('fields');
@@ -52,6 +58,15 @@ export function SectionDialog({
setNewSectionDescription('');
setSectionInputMode('custom');
setSelectedTemplateId(null);
setIsSubmitted(false);
};
const handleSubmit = () => {
setIsSubmitted(true);
if (sectionInputMode === 'custom' && !isTitleEmpty) {
handleAddSection();
setIsSubmitted(false);
}
};
// 템플릿 선택 시 폼에 값 채우기
@@ -220,7 +235,11 @@ export function SectionDialog({
onChange={(e) => setNewSectionTitle(e.target.value)}
placeholder={newSectionType === 'bom' ? '예: BOM 구성' : '예: 기본 정보'}
disabled={sectionInputMode === 'template'}
className={isSubmitted && isTitleEmpty && sectionInputMode === 'custom' ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{isSubmitted && isTitleEmpty && sectionInputMode === 'custom' && (
<p className="text-xs text-red-500 mt-1"> </p>
)}
</div>
<div>
<Label> ()</Label>
@@ -269,7 +288,7 @@ export function SectionDialog({
</Button>
) : (
<Button
onClick={handleAddSection}
onClick={handleSubmit}
className="w-full sm:w-auto"
disabled={sectionInputMode === 'template' && !selectedTemplateId}
>

View File

@@ -1,5 +1,6 @@
'use client';
import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
@@ -47,16 +48,37 @@ export function SectionTemplateDialog({
handleUpdateSectionTemplate,
handleAddSectionTemplate,
}: SectionTemplateDialogProps) {
const [isSubmitted, setIsSubmitted] = useState(false);
// 유효성 검사
const isTitleEmpty = !newSectionTemplateTitle.trim();
const handleClose = () => {
setIsSectionTemplateDialogOpen(false);
setEditingSectionTemplateId(null);
setNewSectionTemplateTitle('');
setNewSectionTemplateDescription('');
setNewSectionTemplateCategory([]);
setNewSectionTemplateType('fields');
setIsSubmitted(false);
};
const handleSubmit = () => {
setIsSubmitted(true);
if (!isTitleEmpty) {
if (editingSectionTemplateId) {
handleUpdateSectionTemplate();
} else {
handleAddSectionTemplate();
}
setIsSubmitted(false);
}
};
return (
<Dialog open={isSectionTemplateDialogOpen} onOpenChange={(open) => {
setIsSectionTemplateDialogOpen(open);
if (!open) {
setEditingSectionTemplateId(null);
setNewSectionTemplateTitle('');
setNewSectionTemplateDescription('');
setNewSectionTemplateCategory([]);
setNewSectionTemplateType('fields');
}
if (!open) handleClose();
else setIsSectionTemplateDialogOpen(open);
}}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
@@ -72,7 +94,11 @@ export function SectionTemplateDialog({
value={newSectionTemplateTitle}
onChange={(e) => setNewSectionTemplateTitle(e.target.value)}
placeholder="예: 기본 정보"
className={isSubmitted && isTitleEmpty ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{isSubmitted && isTitleEmpty && (
<p className="text-xs text-red-500 mt-1"> </p>
)}
</div>
<div>
@@ -136,8 +162,8 @@ export function SectionTemplateDialog({
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsSectionTemplateDialogOpen(false)}></Button>
<Button onClick={editingSectionTemplateId ? handleUpdateSectionTemplate : handleAddSectionTemplate}>
<Button variant="outline" onClick={handleClose}></Button>
<Button onClick={handleSubmit}>
{editingSectionTemplateId ? '수정' : '추가'}
</Button>
</DialogFooter>

View File

@@ -1,5 +1,6 @@
'use client';
import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
@@ -87,7 +88,14 @@ export function TemplateFieldDialog({
selectedMasterFieldId = '',
setSelectedMasterFieldId,
}: TemplateFieldDialogProps) {
const [isSubmitted, setIsSubmitted] = useState(false);
// 유효성 검사
const isNameEmpty = !templateFieldName.trim();
const isKeyEmpty = !templateFieldKey.trim();
const handleClose = () => {
setIsSubmitted(false);
setIsTemplateFieldDialogOpen(false);
setEditingTemplateFieldId(null);
setTemplateFieldName('');
@@ -230,7 +238,11 @@ export function TemplateFieldDialog({
value={templateFieldName}
onChange={(e) => setTemplateFieldName(e.target.value)}
placeholder="예: 품목명"
className={isSubmitted && isNameEmpty ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{isSubmitted && isNameEmpty && (
<p className="text-xs text-red-500 mt-1"> </p>
)}
</div>
<div>
<Label> *</Label>
@@ -238,7 +250,11 @@ export function TemplateFieldDialog({
value={templateFieldKey}
onChange={(e) => setTemplateFieldKey(e.target.value)}
placeholder="예: itemName"
className={isSubmitted && isKeyEmpty ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{isSubmitted && isKeyEmpty && (
<p className="text-xs text-red-500 mt-1"> </p>
)}
</div>
</div>
@@ -334,8 +350,14 @@ export function TemplateFieldDialog({
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsTemplateFieldDialogOpen(false)}></Button>
<Button onClick={handleAddTemplateField}>
<Button variant="outline" onClick={handleClose}></Button>
<Button onClick={() => {
setIsSubmitted(true);
const shouldValidate = templateFieldInputMode === 'custom' || editingTemplateFieldId || !setTemplateFieldInputMode;
if (shouldValidate && (isNameEmpty || isKeyEmpty)) return;
handleAddTemplateField();
setIsSubmitted(false);
}}>
{editingTemplateFieldId ? '수정' : '추가'}
</Button>
</DialogFooter>

View File

@@ -0,0 +1,20 @@
export { usePageManagement } from './usePageManagement';
export type { UsePageManagementReturn } from './usePageManagement';
export { useSectionManagement } from './useSectionManagement';
export type { UseSectionManagementReturn } from './useSectionManagement';
export { useFieldManagement } from './useFieldManagement';
export type { UseFieldManagementReturn } from './useFieldManagement';
export { useMasterFieldManagement } from './useMasterFieldManagement';
export type { UseMasterFieldManagementReturn } from './useMasterFieldManagement';
export { useTemplateManagement } from './useTemplateManagement';
export type { UseTemplateManagementReturn } from './useTemplateManagement';
export { useAttributeManagement } from './useAttributeManagement';
export type { UseAttributeManagementReturn } from './useAttributeManagement';
export { useTabManagement } from './useTabManagement';
export type { UseTabManagementReturn, CustomTab, AttributeSubTab } from './useTabManagement';

View File

@@ -0,0 +1,351 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { toast } from 'sonner';
import { useItemMaster } from '@/contexts/ItemMasterContext';
import type { MasterOption, OptionColumn } from '../types';
export interface UseAttributeManagementReturn {
// 속성 옵션 상태
unitOptions: MasterOption[];
setUnitOptions: React.Dispatch<React.SetStateAction<MasterOption[]>>;
materialOptions: MasterOption[];
setMaterialOptions: React.Dispatch<React.SetStateAction<MasterOption[]>>;
surfaceTreatmentOptions: MasterOption[];
setSurfaceTreatmentOptions: React.Dispatch<React.SetStateAction<MasterOption[]>>;
customAttributeOptions: Record<string, MasterOption[]>;
setCustomAttributeOptions: React.Dispatch<React.SetStateAction<Record<string, MasterOption[]>>>;
// 옵션 다이얼로그 상태
isOptionDialogOpen: boolean;
setIsOptionDialogOpen: (open: boolean) => void;
editingOptionType: string | null;
setEditingOptionType: (type: string | null) => void;
// 옵션 폼 상태
newOptionValue: string;
setNewOptionValue: (value: string) => void;
newOptionLabel: string;
setNewOptionLabel: (label: string) => void;
newOptionColumnValues: Record<string, string>;
setNewOptionColumnValues: React.Dispatch<React.SetStateAction<Record<string, string>>>;
newOptionInputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
setNewOptionInputType: (type: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea') => 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;
// 칼럼 관리 상태
isColumnManageDialogOpen: boolean;
setIsColumnManageDialogOpen: (open: boolean) => void;
managingColumnType: string | null;
setManagingColumnType: (type: string | null) => void;
attributeColumns: Record<string, OptionColumn[]>;
setAttributeColumns: React.Dispatch<React.SetStateAction<Record<string, OptionColumn[]>>>;
// 칼럼 폼 상태
newColumnName: string;
setNewColumnName: (name: string) => void;
newColumnKey: string;
setNewColumnKey: (key: string) => void;
newColumnType: 'text' | 'number';
setNewColumnType: (type: 'text' | 'number') => void;
newColumnRequired: boolean;
setNewColumnRequired: (required: boolean) => void;
// 핸들러
handleAddOption: () => void;
handleDeleteOption: (type: string, id: string) => void;
handleAddColumn: () => void;
handleDeleteColumn: (columnKey: string) => void;
resetOptionForm: () => void;
resetColumnForm: () => void;
}
export function useAttributeManagement(): UseAttributeManagementReturn {
const {
itemMasterFields,
updateItemMasterField
} = useItemMaster();
// 속성 옵션 상태
const [unitOptions, setUnitOptions] = useState<MasterOption[]>([]);
const [materialOptions, setMaterialOptions] = useState<MasterOption[]>([]);
const [surfaceTreatmentOptions, setSurfaceTreatmentOptions] = useState<MasterOption[]>([]);
const [customAttributeOptions, setCustomAttributeOptions] = useState<Record<string, MasterOption[]>>({});
// 옵션 다이얼로그 상태
const [isOptionDialogOpen, setIsOptionDialogOpen] = useState(false);
const [editingOptionType, setEditingOptionType] = useState<string | null>(null);
// 옵션 폼 상태
const [newOptionValue, setNewOptionValue] = useState('');
const [newOptionLabel, setNewOptionLabel] = useState('');
const [newOptionColumnValues, setNewOptionColumnValues] = useState<Record<string, string>>({});
const [newOptionInputType, setNewOptionInputType] = useState<'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea'>('textbox');
const [newOptionRequired, setNewOptionRequired] = useState(false);
const [newOptionOptions, setNewOptionOptions] = useState('');
const [newOptionPlaceholder, setNewOptionPlaceholder] = useState('');
const [newOptionDefaultValue, setNewOptionDefaultValue] = useState('');
// 칼럼 관리 상태
const [isColumnManageDialogOpen, setIsColumnManageDialogOpen] = useState(false);
const [managingColumnType, setManagingColumnType] = useState<string | null>(null);
const [attributeColumns, setAttributeColumns] = useState<Record<string, OptionColumn[]>>({});
// 칼럼 폼 상태
const [newColumnName, setNewColumnName] = useState('');
const [newColumnKey, setNewColumnKey] = useState('');
const [newColumnType, setNewColumnType] = useState<'text' | 'number'>('text');
const [newColumnRequired, setNewColumnRequired] = useState(false);
// 이전 옵션 값 추적용 ref (무한 루프 방지)
const prevOptionsRef = useRef<string>('');
// 속성 변경 시 연동된 마스터 항목의 옵션 자동 업데이트
// 주의: itemMasterFields를 의존성에서 제거하여 무한 루프 방지
useEffect(() => {
// 현재 옵션 상태를 문자열로 직렬화
const currentOptionsState = JSON.stringify({
unit: unitOptions.map(o => o.label).sort(),
material: materialOptions.map(o => o.label).sort(),
surface: surfaceTreatmentOptions.map(o => o.label).sort(),
custom: Object.keys(customAttributeOptions).reduce((acc, key) => {
acc[key] = (customAttributeOptions[key] || []).map(o => o.label).sort();
return acc;
}, {} as Record<string, string[]>)
});
// 이전 상태와 동일하면 업데이트 스킵
if (prevOptionsRef.current === currentOptionsState) {
return;
}
prevOptionsRef.current = currentOptionsState;
// 실제 업데이트가 필요한 경우만 처리
itemMasterFields.forEach(field => {
// properties가 null/undefined인 경우 스킵
if (!field.properties) return;
const attributeType = (field.properties as any).attributeType;
if (attributeType && attributeType !== 'custom' && (field.properties as any)?.inputType === 'dropdown') {
let newOptions: string[] = [];
if (attributeType === 'unit') {
newOptions = unitOptions.map(opt => opt.label);
} else if (attributeType === 'material') {
newOptions = materialOptions.map(opt => opt.label);
} else if (attributeType === 'surface') {
newOptions = surfaceTreatmentOptions.map(opt => opt.label);
} else {
const customOpts = customAttributeOptions[attributeType] || [];
newOptions = customOpts.map(opt => opt.label);
}
const currentOptions = (field.properties as any)?.options || [];
const optionsChanged = JSON.stringify([...currentOptions].sort()) !== JSON.stringify([...newOptions].sort());
if (optionsChanged && newOptions.length > 0) {
updateItemMasterField(field.id, {
properties: {
...(field.properties || {}),
options: newOptions
}
});
}
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [unitOptions, materialOptions, surfaceTreatmentOptions, customAttributeOptions]);
// 옵션 추가
const handleAddOption = () => {
if (!editingOptionType || !newOptionValue.trim() || !newOptionLabel.trim()) {
toast.error('모든 항목을 입력해주세요');
return;
}
// dropdown일 경우 옵션 필수 체크
if (newOptionInputType === 'dropdown' && !newOptionOptions.trim()) {
toast.error('드롭다운 옵션을 입력해주세요');
return;
}
// 칼럼 필수 값 체크
const currentColumns = attributeColumns[editingOptionType] || [];
for (const column of currentColumns) {
if (column.required && !newOptionColumnValues[column.key]?.trim()) {
toast.error(`${column.name}은(는) 필수 입력 항목입니다`);
return;
}
}
const newOption: MasterOption = {
id: `${editingOptionType}-${Date.now()}`,
value: newOptionValue,
label: newOptionLabel,
isActive: true,
inputType: newOptionInputType,
required: newOptionRequired,
options: newOptionInputType === 'dropdown' ? newOptionOptions.split(',').map(o => o.trim()).filter(o => o) : undefined,
placeholder: newOptionPlaceholder || undefined,
defaultValue: newOptionDefaultValue || undefined,
columnValues: Object.keys(newOptionColumnValues).length > 0 ? { ...newOptionColumnValues } : undefined
};
if (editingOptionType === 'unit') {
setUnitOptions(prev => [...prev, newOption]);
} else if (editingOptionType === 'material') {
setMaterialOptions(prev => [...prev, newOption]);
} else if (editingOptionType === 'surface') {
setSurfaceTreatmentOptions(prev => [...prev, newOption]);
} else {
setCustomAttributeOptions(prev => ({
...prev,
[editingOptionType]: [...(prev[editingOptionType] || []), newOption]
}));
}
resetOptionForm();
toast.success('속성이 추가되었습니다 (저장 필요)');
};
// 옵션 삭제
const handleDeleteOption = (type: string, id: string) => {
if (type === 'unit') {
setUnitOptions(prev => prev.filter(o => o.id !== id));
} else if (type === 'material') {
setMaterialOptions(prev => prev.filter(o => o.id !== id));
} else if (type === 'surface') {
setSurfaceTreatmentOptions(prev => prev.filter(o => o.id !== id));
} else {
setCustomAttributeOptions(prev => ({
...prev,
[type]: (prev[type] || []).filter(o => o.id !== id)
}));
}
toast.success('삭제되었습니다');
};
// 칼럼 추가
const handleAddColumn = () => {
if (!managingColumnType || !newColumnName.trim() || !newColumnKey.trim()) {
toast.error('칼럼명과 키를 입력해주세요');
return;
}
const newColumn: OptionColumn = {
id: `col-${Date.now()}`,
key: newColumnKey,
name: newColumnName,
type: newColumnType,
required: newColumnRequired
};
setAttributeColumns(prev => ({
...prev,
[managingColumnType]: [...(prev[managingColumnType] || []), newColumn]
}));
resetColumnForm();
toast.success('칼럼이 추가되었습니다');
};
// 칼럼 삭제
const handleDeleteColumn = (columnKey: string) => {
if (!managingColumnType) return;
setAttributeColumns(prev => ({
...prev,
[managingColumnType]: (prev[managingColumnType] || []).filter(c => c.key !== columnKey)
}));
toast.success('칼럼이 삭제되었습니다');
};
// 옵션 폼 초기화
const resetOptionForm = () => {
setNewOptionValue('');
setNewOptionLabel('');
setNewOptionColumnValues({});
setNewOptionInputType('textbox');
setNewOptionRequired(false);
setNewOptionOptions('');
setNewOptionPlaceholder('');
setNewOptionDefaultValue('');
setIsOptionDialogOpen(false);
};
// 칼럼 폼 초기화
const resetColumnForm = () => {
setNewColumnName('');
setNewColumnKey('');
setNewColumnType('text');
setNewColumnRequired(false);
};
return {
// 속성 옵션 상태
unitOptions,
setUnitOptions,
materialOptions,
setMaterialOptions,
surfaceTreatmentOptions,
setSurfaceTreatmentOptions,
customAttributeOptions,
setCustomAttributeOptions,
// 옵션 다이얼로그 상태
isOptionDialogOpen,
setIsOptionDialogOpen,
editingOptionType,
setEditingOptionType,
// 옵션 폼 상태
newOptionValue,
setNewOptionValue,
newOptionLabel,
setNewOptionLabel,
newOptionColumnValues,
setNewOptionColumnValues,
newOptionInputType,
setNewOptionInputType,
newOptionRequired,
setNewOptionRequired,
newOptionOptions,
setNewOptionOptions,
newOptionPlaceholder,
setNewOptionPlaceholder,
newOptionDefaultValue,
setNewOptionDefaultValue,
// 칼럼 관리 상태
isColumnManageDialogOpen,
setIsColumnManageDialogOpen,
managingColumnType,
setManagingColumnType,
attributeColumns,
setAttributeColumns,
// 칼럼 폼 상태
newColumnName,
setNewColumnName,
newColumnKey,
setNewColumnKey,
newColumnType,
setNewColumnType,
newColumnRequired,
setNewColumnRequired,
// 핸들러
handleAddOption,
handleDeleteOption,
handleAddColumn,
handleDeleteColumn,
resetOptionForm,
resetColumnForm,
};
}

View File

@@ -0,0 +1,360 @@
'use client';
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { useItemMaster } from '@/contexts/ItemMasterContext';
import type { ItemPage, ItemField, ItemMasterField, FieldDisplayCondition } from '@/contexts/ItemMasterContext';
import { type ConditionalFieldConfig } from '../components/ConditionalDisplayUI';
export interface UseFieldManagementReturn {
// 다이얼로그 상태
isFieldDialogOpen: boolean;
setIsFieldDialogOpen: (open: boolean) => void;
selectedSectionForField: number | null;
setSelectedSectionForField: (id: number | null) => void;
editingFieldId: number | null;
setEditingFieldId: (id: number | null) => void;
// 입력 모드
fieldInputMode: 'master' | 'custom';
setFieldInputMode: (mode: 'master' | 'custom') => void;
showMasterFieldList: boolean;
setShowMasterFieldList: (show: boolean) => void;
selectedMasterFieldId: string;
setSelectedMasterFieldId: (id: string) => void;
// 필드 폼 상태
newFieldName: string;
setNewFieldName: (name: string) => void;
newFieldKey: string;
setNewFieldKey: (key: string) => void;
newFieldInputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
setNewFieldInputType: (type: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea') => void;
newFieldRequired: boolean;
setNewFieldRequired: (required: boolean) => void;
newFieldOptions: string;
setNewFieldOptions: (options: string) => void;
newFieldDescription: string;
setNewFieldDescription: (desc: string) => void;
// 텍스트박스 컬럼
textboxColumns: Array<{ id: string; name: string; key: string }>;
setTextboxColumns: React.Dispatch<React.SetStateAction<Array<{ id: string; name: string; key: string }>>>;
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;
// 조건부 필드
newFieldConditionEnabled: boolean;
setNewFieldConditionEnabled: (enabled: boolean) => void;
newFieldConditionTargetType: 'field' | 'section';
setNewFieldConditionTargetType: (type: 'field' | 'section') => void;
newFieldConditionFields: ConditionalFieldConfig[];
setNewFieldConditionFields: React.Dispatch<React.SetStateAction<ConditionalFieldConfig[]>>;
newFieldConditionSections: string[];
setNewFieldConditionSections: React.Dispatch<React.SetStateAction<string[]>>;
tempConditionValue: string;
setTempConditionValue: (value: string) => void;
// 핸들러
handleAddField: (selectedPage: ItemPage | undefined) => void;
handleEditField: (sectionId: string, field: ItemField) => void;
handleDeleteField: (pageId: string, sectionId: string, fieldId: string) => void;
resetFieldForm: () => void;
}
export function useFieldManagement(): UseFieldManagementReturn {
const {
itemMasterFields,
addFieldToSection,
updateField,
deleteField,
addItemMasterField,
updateItemMasterField,
} = useItemMaster();
// 다이얼로그 상태
const [isFieldDialogOpen, setIsFieldDialogOpen] = useState(false);
const [selectedSectionForField, setSelectedSectionForField] = useState<number | null>(null);
const [editingFieldId, setEditingFieldId] = useState<number | null>(null);
// 입력 모드
const [fieldInputMode, setFieldInputMode] = useState<'master' | 'custom'>('custom');
const [showMasterFieldList, setShowMasterFieldList] = useState(false);
const [selectedMasterFieldId, setSelectedMasterFieldId] = useState('');
// 필드 폼 상태
const [newFieldName, setNewFieldName] = useState('');
const [newFieldKey, setNewFieldKey] = useState('');
const [newFieldInputType, setNewFieldInputType] = useState<'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea'>('textbox');
const [newFieldRequired, setNewFieldRequired] = useState(false);
const [newFieldOptions, setNewFieldOptions] = useState('');
const [newFieldDescription, setNewFieldDescription] = useState('');
// 텍스트박스 컬럼
const [textboxColumns, setTextboxColumns] = useState<Array<{ id: string; name: string; key: string }>>([]);
const [isColumnDialogOpen, setIsColumnDialogOpen] = useState(false);
const [editingColumnId, setEditingColumnId] = useState<string | null>(null);
const [columnName, setColumnName] = useState('');
const [columnKey, setColumnKey] = useState('');
// 조건부 필드
const [newFieldConditionEnabled, setNewFieldConditionEnabled] = useState(false);
const [newFieldConditionTargetType, setNewFieldConditionTargetType] = useState<'field' | 'section'>('field');
const [newFieldConditionFields, setNewFieldConditionFields] = useState<ConditionalFieldConfig[]>([]);
const [newFieldConditionSections, setNewFieldConditionSections] = useState<string[]>([]);
const [tempConditionValue, setTempConditionValue] = useState('');
// 마스터 필드 선택 시 폼 자동 채우기
useEffect(() => {
if (fieldInputMode === 'master' && selectedMasterFieldId) {
const masterField = itemMasterFields.find(f => f.id === Number(selectedMasterFieldId));
if (masterField) {
setNewFieldName(masterField.field_name);
setNewFieldKey(masterField.id.toString());
setNewFieldInputType(masterField.field_type || 'textbox');
// properties에서 required 확인, 또는 validation_rules에서 확인
const isRequired = (masterField.properties as any)?.required || false;
setNewFieldRequired(isRequired);
setNewFieldOptions(masterField.options?.map(o => o.label).join(', ') || '');
setNewFieldDescription(masterField.description || '');
}
} else if (fieldInputMode === 'custom') {
// 직접 입력 모드로 전환 시 폼 초기화
setNewFieldName('');
setNewFieldKey('');
setNewFieldInputType('textbox');
setNewFieldRequired(false);
setNewFieldOptions('');
setNewFieldDescription('');
}
}, [fieldInputMode, selectedMasterFieldId, itemMasterFields]);
// 필드 추가
const handleAddField = (selectedPage: ItemPage | undefined) => {
if (!selectedPage || !selectedSectionForField || !newFieldName.trim() || !newFieldKey.trim()) {
toast.error('모든 필수 항목을 입력해주세요');
return;
}
// 조건부 표시 설정
const displayCondition: FieldDisplayCondition | undefined = newFieldConditionEnabled
? {
targetType: newFieldConditionTargetType,
fieldConditions: newFieldConditionTargetType === 'field' && newFieldConditionFields.length > 0
? newFieldConditionFields
: undefined,
sectionIds: newFieldConditionTargetType === 'section' && newFieldConditionSections.length > 0
? newFieldConditionSections
: undefined
}
: undefined;
// 텍스트박스 컬럼 설정
const hasColumns = newFieldInputType === 'textbox' && textboxColumns.length > 0;
// 마스터 항목에서 가져온 경우 master_field_id 설정
const masterFieldId = fieldInputMode === 'master' && selectedMasterFieldId
? Number(selectedMasterFieldId)
: null;
const newField: ItemField = {
id: editingFieldId ? Number(editingFieldId) : Date.now(),
section_id: Number(selectedSectionForField),
master_field_id: masterFieldId,
field_name: newFieldName,
field_type: newFieldInputType,
order_no: 0,
is_required: newFieldRequired,
placeholder: newFieldDescription || null,
default_value: null,
display_condition: displayCondition as Record<string, any> | null || null,
validation_rules: null,
options: newFieldInputType === 'dropdown' && newFieldOptions.trim()
? newFieldOptions.split(',').map(o => ({ label: o.trim(), value: o.trim() }))
: null,
properties: hasColumns ? {
multiColumn: true,
columnCount: textboxColumns.length,
columnNames: textboxColumns.map(c => c.name)
} : null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
if (editingFieldId) {
console.log('Updating field:', { pageId: selectedPage.id, sectionId: selectedSectionForField, fieldId: editingFieldId, fieldName: newField.field_name });
updateField(Number(editingFieldId), newField);
// 항목관리 탭의 마스터 항목도 업데이트 (동일한 fieldKey가 있으면)
const existingMasterField = itemMasterFields.find(mf => mf.id.toString() === newField.field_name);
if (existingMasterField) {
const updatedMasterField: Partial<ItemMasterField> = {
field_name: newField.field_name,
description: newField.placeholder ?? null,
properties: newField.properties,
updated_at: new Date().toISOString()
};
updateItemMasterField(existingMasterField.id, updatedMasterField);
}
toast.success('항목이 섹션에 수정되었습니다!');
} else {
console.log('Adding field to section:', { pageId: selectedPage.id, sectionId: selectedSectionForField, fieldName: newField.field_name });
// 1. 섹션에 항목 추가
addFieldToSection(Number(selectedSectionForField), newField);
// 2. 마스터 항목 선택이 아닌 경우에만 새 마스터 항목 자동 생성
const isFromMasterField = masterFieldId !== null;
const existingMasterField = itemMasterFields.find(mf => mf.id.toString() === newField.field_name);
if (!isFromMasterField && !existingMasterField) {
// ItemMasterField 타입에 맞게 필수 필드 포함
const newMasterFieldData: Omit<ItemMasterField, 'id' | 'tenant_id' | 'created_by' | 'updated_by' | 'created_at' | 'updated_at'> = {
field_name: newField.field_name,
field_type: newField.field_type,
description: newField.placeholder ?? null,
category: selectedPage.item_type,
is_common: false,
default_value: null,
options: newField.options ?? null,
validation_rules: null,
properties: newField.properties ?? null,
};
addItemMasterField(newMasterFieldData as any);
console.log('Field added to both section and master fields:', {
fieldId: newField.id,
fieldName: newMasterFieldData.field_name
});
toast.success('항목이 섹션에 추가되고 마스터 항목으로도 등록되었습니다!');
} else {
toast.success('항목이 섹션에 추가되었습니다!');
}
}
resetFieldForm();
};
// 필드 수정
const handleEditField = (sectionId: string, field: ItemField) => {
setSelectedSectionForField(Number(sectionId));
setEditingFieldId(field.id);
setNewFieldName(field.field_name);
setNewFieldKey(field.id.toString());
setNewFieldInputType(field.field_type);
setNewFieldRequired(field.is_required);
setNewFieldOptions(field.options?.map(opt => opt.value).join(', ') || '');
setNewFieldDescription('');
// 조건부 표시 설정 로드
if (field.display_condition) {
setNewFieldConditionEnabled(true);
setNewFieldConditionTargetType(field.display_condition.targetType);
setNewFieldConditionFields(field.display_condition.fieldConditions || []);
setNewFieldConditionSections(field.display_condition.sectionIds || []);
} else {
setNewFieldConditionEnabled(false);
setNewFieldConditionTargetType('field');
setNewFieldConditionFields([]);
setNewFieldConditionSections([]);
}
setIsFieldDialogOpen(true);
};
// 필드 삭제
const handleDeleteField = (pageId: string, sectionId: string, fieldId: string) => {
deleteField(Number(fieldId));
console.log('필드 삭제 완료:', fieldId);
};
// 폼 초기화
const resetFieldForm = () => {
setNewFieldName('');
setNewFieldKey('');
setNewFieldInputType('textbox');
setNewFieldRequired(false);
setNewFieldOptions('');
setNewFieldDescription('');
setNewFieldConditionEnabled(false);
setNewFieldConditionTargetType('field');
setNewFieldConditionFields([]);
setNewFieldConditionSections([]);
setTempConditionValue('');
setEditingFieldId(null);
setSelectedSectionForField(null);
setFieldInputMode('custom');
setSelectedMasterFieldId('');
setTextboxColumns([]);
setIsFieldDialogOpen(false);
};
return {
// 다이얼로그 상태
isFieldDialogOpen,
setIsFieldDialogOpen,
selectedSectionForField,
setSelectedSectionForField,
editingFieldId,
setEditingFieldId,
// 입력 모드
fieldInputMode,
setFieldInputMode,
showMasterFieldList,
setShowMasterFieldList,
selectedMasterFieldId,
setSelectedMasterFieldId,
// 필드 폼 상태
newFieldName,
setNewFieldName,
newFieldKey,
setNewFieldKey,
newFieldInputType,
setNewFieldInputType,
newFieldRequired,
setNewFieldRequired,
newFieldOptions,
setNewFieldOptions,
newFieldDescription,
setNewFieldDescription,
// 텍스트박스 컬럼
textboxColumns,
setTextboxColumns,
isColumnDialogOpen,
setIsColumnDialogOpen,
editingColumnId,
setEditingColumnId,
columnName,
setColumnName,
columnKey,
setColumnKey,
// 조건부 필드
newFieldConditionEnabled,
setNewFieldConditionEnabled,
newFieldConditionTargetType,
setNewFieldConditionTargetType,
newFieldConditionFields,
setNewFieldConditionFields,
newFieldConditionSections,
setNewFieldConditionSections,
tempConditionValue,
setTempConditionValue,
// 핸들러
handleAddField,
handleEditField,
handleDeleteField,
resetFieldForm,
};
}

View File

@@ -0,0 +1,214 @@
'use client';
import { useState } from 'react';
import { toast } from 'sonner';
import { useItemMaster } from '@/contexts/ItemMasterContext';
import type { ItemMasterField } from '@/contexts/ItemMasterContext';
export interface UseMasterFieldManagementReturn {
// 다이얼로그 상태
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: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
setNewMasterFieldInputType: (type: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea') => void;
newMasterFieldRequired: boolean;
setNewMasterFieldRequired: (required: boolean) => void;
newMasterFieldCategory: string;
setNewMasterFieldCategory: (category: string) => void;
newMasterFieldDescription: string;
setNewMasterFieldDescription: (desc: string) => void;
newMasterFieldOptions: string;
setNewMasterFieldOptions: (options: string) => void;
newMasterFieldAttributeType: 'custom' | 'unit' | 'material' | 'surface';
setNewMasterFieldAttributeType: (type: 'custom' | 'unit' | 'material' | 'surface') => void;
newMasterFieldMultiColumn: boolean;
setNewMasterFieldMultiColumn: (multi: boolean) => void;
newMasterFieldColumnCount: number;
setNewMasterFieldColumnCount: (count: number) => void;
newMasterFieldColumnNames: string[];
setNewMasterFieldColumnNames: React.Dispatch<React.SetStateAction<string[]>>;
// 핸들러
handleAddMasterField: () => void;
handleEditMasterField: (field: ItemMasterField) => void;
handleUpdateMasterField: () => void;
handleDeleteMasterField: (id: number) => void;
resetMasterFieldForm: () => void;
}
export function useMasterFieldManagement(): UseMasterFieldManagementReturn {
const {
itemMasterFields,
addItemMasterField,
updateItemMasterField,
deleteItemMasterField,
} = useItemMaster();
// 다이얼로그 상태
const [isMasterFieldDialogOpen, setIsMasterFieldDialogOpen] = useState(false);
const [editingMasterFieldId, setEditingMasterFieldId] = useState<number | null>(null);
// 폼 상태
const [newMasterFieldName, setNewMasterFieldName] = useState('');
const [newMasterFieldKey, setNewMasterFieldKey] = useState('');
const [newMasterFieldInputType, setNewMasterFieldInputType] = useState<'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea'>('textbox');
const [newMasterFieldRequired, setNewMasterFieldRequired] = useState(false);
const [newMasterFieldCategory, setNewMasterFieldCategory] = useState('공통');
const [newMasterFieldDescription, setNewMasterFieldDescription] = useState('');
const [newMasterFieldOptions, setNewMasterFieldOptions] = useState('');
const [newMasterFieldAttributeType, setNewMasterFieldAttributeType] = useState<'custom' | 'unit' | 'material' | 'surface'>('custom');
const [newMasterFieldMultiColumn, setNewMasterFieldMultiColumn] = useState(false);
const [newMasterFieldColumnCount, setNewMasterFieldColumnCount] = useState(2);
const [newMasterFieldColumnNames, setNewMasterFieldColumnNames] = useState<string[]>(['컬럼1', '컬럼2']);
// 마스터 항목 추가
const handleAddMasterField = () => {
if (!newMasterFieldName.trim() || !newMasterFieldKey.trim()) {
toast.error('항목명과 필드 키를 입력해주세요');
return;
}
// ItemMasterField 타입에 맞게 필수 필드 포함
const newMasterFieldData: Omit<ItemMasterField, 'id' | 'tenant_id' | 'created_by' | 'updated_by' | 'created_at' | 'updated_at'> = {
field_name: newMasterFieldName,
field_type: newMasterFieldInputType,
category: newMasterFieldCategory || null,
description: newMasterFieldDescription || null,
is_common: false,
default_value: null,
options: newMasterFieldInputType === 'dropdown' && newMasterFieldOptions.trim()
? newMasterFieldOptions.split(',').map(o => ({ label: o.trim(), value: o.trim() }))
: null,
validation_rules: null,
properties: {
required: newMasterFieldRequired,
attributeType: newMasterFieldInputType === 'dropdown' ? newMasterFieldAttributeType : undefined,
multiColumn: (newMasterFieldInputType === 'textbox' || newMasterFieldInputType === 'textarea') ? newMasterFieldMultiColumn : undefined,
columnCount: (newMasterFieldInputType === 'textbox' || newMasterFieldInputType === 'textarea') && newMasterFieldMultiColumn ? newMasterFieldColumnCount : undefined,
columnNames: (newMasterFieldInputType === 'textbox' || newMasterFieldInputType === 'textarea') && newMasterFieldMultiColumn ? newMasterFieldColumnNames : undefined
},
};
addItemMasterField(newMasterFieldData as any);
resetMasterFieldForm();
toast.success('마스터 항목이 추가되었습니다 (저장 필요)');
};
// 마스터 항목 수정 시작
const handleEditMasterField = (field: ItemMasterField) => {
setEditingMasterFieldId(field.id);
setNewMasterFieldName(field.field_name);
setNewMasterFieldKey(field.id.toString());
setNewMasterFieldInputType(field.field_type || 'textbox');
setNewMasterFieldRequired((field.properties as any)?.required || false);
setNewMasterFieldCategory(field.category || '공통');
setNewMasterFieldDescription(field.description || '');
setNewMasterFieldOptions(field.options?.map(o => o.label).join(', ') || '');
setNewMasterFieldAttributeType((field.properties as any)?.attributeType || 'custom');
setNewMasterFieldMultiColumn((field.properties as any)?.multiColumn || false);
setNewMasterFieldColumnCount((field.properties as any)?.columnCount || 2);
setNewMasterFieldColumnNames((field.properties as any)?.columnNames || ['컬럼1', '컬럼2']);
setIsMasterFieldDialogOpen(true);
};
// 마스터 항목 업데이트
const handleUpdateMasterField = () => {
if (!editingMasterFieldId || !newMasterFieldName.trim() || !newMasterFieldKey.trim()) {
toast.error('항목명과 필드 키를 입력해주세요');
return;
}
const updateData: Partial<ItemMasterField> = {
field_name: newMasterFieldName,
field_type: newMasterFieldInputType,
category: newMasterFieldCategory || null,
description: newMasterFieldDescription || null,
options: newMasterFieldInputType === 'dropdown' && newMasterFieldOptions.trim()
? newMasterFieldOptions.split(',').map(o => ({ label: o.trim(), value: o.trim() }))
: null,
properties: {
required: newMasterFieldRequired,
attributeType: newMasterFieldInputType === 'dropdown' ? newMasterFieldAttributeType : undefined,
multiColumn: (newMasterFieldInputType === 'textbox' || newMasterFieldInputType === 'textarea') ? newMasterFieldMultiColumn : undefined,
columnCount: (newMasterFieldInputType === 'textbox' || newMasterFieldInputType === 'textarea') && newMasterFieldMultiColumn ? newMasterFieldColumnCount : undefined,
columnNames: (newMasterFieldInputType === 'textbox' || newMasterFieldInputType === 'textarea') && newMasterFieldMultiColumn ? newMasterFieldColumnNames : undefined
},
};
updateItemMasterField(editingMasterFieldId, updateData);
resetMasterFieldForm();
toast.success('마스터 항목이 수정되었습니다 (저장 필요)');
};
// 마스터 항목 삭제
const handleDeleteMasterField = (id: number) => {
if (confirm('이 마스터 항목을 삭제하시겠습니까?')) {
deleteItemMasterField(id);
toast.success('마스터 항목이 삭제되었습니다');
}
};
// 폼 초기화
const resetMasterFieldForm = () => {
setEditingMasterFieldId(null);
setNewMasterFieldName('');
setNewMasterFieldKey('');
setNewMasterFieldInputType('textbox');
setNewMasterFieldRequired(false);
setNewMasterFieldCategory('공통');
setNewMasterFieldDescription('');
setNewMasterFieldOptions('');
setNewMasterFieldAttributeType('custom');
setNewMasterFieldMultiColumn(false);
setNewMasterFieldColumnCount(2);
setNewMasterFieldColumnNames(['컬럼1', '컬럼2']);
setIsMasterFieldDialogOpen(false);
};
return {
// 다이얼로그 상태
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,
// 핸들러
handleAddMasterField,
handleEditMasterField,
handleUpdateMasterField,
handleDeleteMasterField,
resetMasterFieldForm,
};
}

View File

@@ -0,0 +1,259 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { toast } from 'sonner';
import { useItemMaster } from '@/contexts/ItemMasterContext';
import type { ItemPage } from '@/contexts/ItemMasterContext';
import { ApiError, getErrorMessage } from '@/lib/api/error-handler';
import { generateAbsolutePath } from '../utils/pathUtils';
export interface UsePageManagementReturn {
// 상태
selectedPageId: number | null;
setSelectedPageId: (id: number | null) => void;
selectedPage: ItemPage | undefined;
editingPageId: number | null;
setEditingPageId: (id: number | null) => void;
editingPageName: string;
setEditingPageName: (name: string) => void;
isPageDialogOpen: boolean;
setIsPageDialogOpen: (open: boolean) => void;
newPageName: string;
setNewPageName: (name: string) => void;
newPageItemType: 'FG' | 'PT' | 'SM' | 'RM' | 'CS';
setNewPageItemType: (type: 'FG' | 'PT' | 'SM' | 'RM' | 'CS') => void;
editingPathPageId: number | null;
setEditingPathPageId: (id: number | null) => void;
editingAbsolutePath: string;
setEditingAbsolutePath: (path: string) => void;
isLoading: boolean;
// 핸들러
handleAddPage: () => Promise<void>;
handleDuplicatePage: (pageId: number) => Promise<void>;
handleDeletePage: (pageId: number) => void;
handleUpdatePageName: (pageId: number, newName: string) => Promise<void>;
handleUpdateAbsolutePath: (pageId: number, newPath: string) => Promise<void>;
}
export function usePageManagement(): UsePageManagementReturn {
const {
itemPages,
addItemPage,
updateItemPage,
deleteItemPage,
} = useItemMaster();
// 상태
const [selectedPageId, setSelectedPageId] = useState<number | null>(itemPages[0]?.id || null);
const [editingPageId, setEditingPageId] = useState<number | null>(null);
const [editingPageName, setEditingPageName] = useState('');
const [isPageDialogOpen, setIsPageDialogOpen] = useState(false);
const [newPageName, setNewPageName] = useState('');
const [newPageItemType, setNewPageItemType] = useState<'FG' | 'PT' | 'SM' | 'RM' | 'CS'>('FG');
const [editingPathPageId, setEditingPathPageId] = useState<number | null>(null);
const [editingAbsolutePath, setEditingAbsolutePath] = useState('');
const [isLoading, setIsLoading] = useState(false);
// 선택된 페이지
const selectedPage = itemPages.find(p => p.id === selectedPageId);
// 마이그레이션 완료 추적용 ref
const migrationDoneRef = useRef<Set<number>>(new Set());
// 기존 페이지들에 절대경로 자동 생성 (마이그레이션)
useEffect(() => {
// itemPages가 비어있으면 스킵
if (itemPages.length === 0) return;
const pagesToMigrate = itemPages.filter(
page => !page.absolute_path && !migrationDoneRef.current.has(page.id)
);
// 마이그레이션할 페이지가 없으면 스킵
if (pagesToMigrate.length === 0) return;
// 마이그레이션 실행 (한 번에 처리)
pagesToMigrate.forEach(page => {
const absolutePath = generateAbsolutePath(page.item_type, page.page_name);
updateItemPage(page.id, { absolute_path: absolutePath });
migrationDoneRef.current.add(page.id);
});
console.log(`절대경로가 자동으로 생성되었습니다 (${pagesToMigrate.length}개 페이지)`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemPages.length]); // itemPages 길이가 변경될 때만 체크
// 페이지 추가
const handleAddPage = async () => {
if (!newPageName.trim()) {
toast.error('섹션명을 입력해주세요');
return;
}
try {
setIsLoading(true);
const absolutePath = generateAbsolutePath(newPageItemType, newPageName);
const newPage = await addItemPage({
page_name: newPageName,
item_type: newPageItemType,
absolute_path: absolutePath,
is_active: true,
sections: [],
order_no: 0,
});
// 새로 생성된 페이지를 선택
setSelectedPageId(newPage.id);
// 폼 초기화
setNewPageName('');
setNewPageItemType('FG');
setIsPageDialogOpen(false);
toast.success('페이지가 추가되었습니다');
} 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);
} else {
const errorMessage = getErrorMessage(err);
toast.error(errorMessage);
}
console.error('❌ Failed to create page:', err);
} finally {
setIsLoading(false);
}
};
// 페이지 복제
const handleDuplicatePage = async (pageId: number) => {
const originalPage = itemPages.find(p => p.id === pageId);
if (!originalPage) {
toast.error('페이지를 찾을 수 없습니다');
return;
}
try {
setIsLoading(true);
const duplicatedPageName = `${originalPage.page_name} (복제)`;
const absolutePath = generateAbsolutePath(originalPage.item_type, duplicatedPageName);
const newPage = await addItemPage({
page_name: duplicatedPageName,
item_type: originalPage.item_type,
sections: [], // 섹션은 별도 API로 복제해야 함
is_active: true,
absolute_path: absolutePath,
order_no: 0,
});
setSelectedPageId(newPage.id);
toast.success('페이지가 복제되었습니다');
// TODO: 원본 페이지의 섹션들도 복제 필요 (별도 API 호출)
} catch (err) {
const errorMessage = getErrorMessage(err);
toast.error(errorMessage);
console.error('❌ Failed to duplicate page:', err);
} finally {
setIsLoading(false);
}
};
// 페이지 삭제
const handleDeletePage = (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);
// 삭제된 페이지가 선택된 페이지였다면 다른 페이지 선택
if (selectedPageId === pageId) {
const remainingPages = itemPages.filter(p => p.id !== pageId);
setSelectedPageId(remainingPages[0]?.id || null);
}
console.log('페이지 삭제 완료:', {
pageId,
removedSections: sectionIds.length,
removedFields: fieldIds.length
});
};
// 페이지명 수정
const handleUpdatePageName = async (pageId: number, newName: string) => {
if (!newName.trim()) {
toast.error('페이지명을 입력해주세요');
return;
}
try {
setIsLoading(true);
await updateItemPage(pageId, { page_name: newName });
setEditingPageId(null);
setEditingPageName('');
toast.success('페이지명이 수정되었습니다');
} catch (err) {
const errorMessage = getErrorMessage(err);
toast.error(errorMessage);
} finally {
setIsLoading(false);
}
};
// 절대경로 수정
const handleUpdateAbsolutePath = async (pageId: number, newPath: string) => {
if (!newPath.trim()) {
toast.error('절대경로를 입력해주세요');
return;
}
try {
setIsLoading(true);
await updateItemPage(pageId, { absolute_path: newPath });
setEditingPathPageId(null);
setEditingAbsolutePath('');
toast.success('절대경로가 수정되었습니다');
} catch (err) {
const errorMessage = getErrorMessage(err);
toast.error(errorMessage);
} finally {
setIsLoading(false);
}
};
return {
// 상태
selectedPageId,
setSelectedPageId,
selectedPage,
editingPageId,
setEditingPageId,
editingPageName,
setEditingPageName,
isPageDialogOpen,
setIsPageDialogOpen,
newPageName,
setNewPageName,
newPageItemType,
setNewPageItemType,
editingPathPageId,
setEditingPathPageId,
editingAbsolutePath,
setEditingAbsolutePath,
isLoading,
// 핸들러
handleAddPage,
handleDuplicatePage,
handleDeletePage,
handleUpdatePageName,
handleUpdateAbsolutePath,
};
}

View File

@@ -0,0 +1,247 @@
'use client';
import { useState } from 'react';
import { toast } from 'sonner';
import { useItemMaster } from '@/contexts/ItemMasterContext';
import type { ItemPage, ItemSection, SectionTemplate } from '@/contexts/ItemMasterContext';
export interface UseSectionManagementReturn {
// 상태
editingSectionId: number | null;
setEditingSectionId: (id: number | null) => void;
editingSectionTitle: string;
setEditingSectionTitle: (title: string) => void;
isSectionDialogOpen: boolean;
setIsSectionDialogOpen: (open: boolean) => void;
newSectionTitle: string;
setNewSectionTitle: (title: string) => void;
newSectionDescription: string;
setNewSectionDescription: (desc: string) => void;
newSectionType: 'fields' | 'bom';
setNewSectionType: (type: 'fields' | 'bom') => void;
sectionInputMode: 'custom' | 'template';
setSectionInputMode: (mode: 'custom' | 'template') => void;
selectedSectionTemplateId: number | null;
setSelectedSectionTemplateId: (id: number | null) => void;
expandedSections: Record<string, boolean>;
setExpandedSections: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
// 핸들러
handleAddSection: (selectedPage: ItemPage | undefined) => void;
handleLinkTemplate: (template: SectionTemplate, selectedPage: ItemPage | undefined) => void;
handleEditSectionTitle: (sectionId: number, currentTitle: string) => void;
handleSaveSectionTitle: (selectedPage: ItemPage | undefined) => void;
handleDeleteSection: (pageId: number, sectionId: number) => void;
toggleSection: (sectionId: string) => void;
resetSectionForm: () => void;
}
export function useSectionManagement(): UseSectionManagementReturn {
const {
itemPages,
addSectionToPage,
updateSection,
deleteSection,
addSectionTemplate,
tenantId,
} = useItemMaster();
// 상태
const [editingSectionId, setEditingSectionId] = useState<number | null>(null);
const [editingSectionTitle, setEditingSectionTitle] = useState('');
const [isSectionDialogOpen, setIsSectionDialogOpen] = useState(false);
const [newSectionTitle, setNewSectionTitle] = useState('');
const [newSectionDescription, setNewSectionDescription] = useState('');
const [newSectionType, setNewSectionType] = useState<'fields' | 'bom'>('fields');
const [sectionInputMode, setSectionInputMode] = useState<'custom' | 'template'>('custom');
const [selectedSectionTemplateId, setSelectedSectionTemplateId] = useState<number | null>(null);
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
// 섹션 추가
const handleAddSection = (selectedPage: ItemPage | undefined) => {
if (!selectedPage || !newSectionTitle.trim()) {
toast.error('하위섹션 제목을 입력해주세요');
return;
}
const sectionType: 'BASIC' | 'BOM' | 'CUSTOM' = newSectionType === 'bom' ? 'BOM' : 'BASIC';
const newSection: ItemSection = {
id: Date.now(),
page_id: selectedPage.id,
section_name: newSectionTitle,
section_type: sectionType,
description: newSectionDescription || undefined,
order_no: selectedPage.sections.length + 1,
is_collapsible: true,
is_default_open: true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
fields: [],
bomItems: sectionType === 'BOM' ? [] : undefined
};
console.log('Adding section to page:', {
pageId: selectedPage.id,
page_name: selectedPage.page_name,
sectionTitle: newSection.section_name,
sectionType: newSection.section_type,
currentSectionCount: selectedPage.sections.length,
newSection: newSection
});
// 1. 페이지에 섹션 추가
addSectionToPage(selectedPage.id, newSection);
// 2. 섹션관리 탭에도 템플릿으로 자동 추가
const newTemplateData = {
tenant_id: tenantId ?? 0,
template_name: newSection.section_name,
section_type: newSection.section_type as 'BASIC' | 'BOM' | 'CUSTOM',
description: newSection.description ?? null,
default_fields: null,
created_by: null,
updated_by: null,
};
addSectionTemplate(newTemplateData);
console.log('Section added to both page and template:', {
sectionId: newSection.id,
templateTitle: newTemplateData.template_name
});
resetSectionForm();
toast.success(`${newSectionType === 'bom' ? 'BOM' : '일반'} 섹션이 페이지에 추가되고 템플릿으로도 등록되었습니다!`);
};
// 섹션 템플릿을 페이지에 연결
const handleLinkTemplate = (template: SectionTemplate, selectedPage: ItemPage | undefined) => {
if (!selectedPage) {
toast.error('페이지를 먼저 선택해주세요');
return;
}
// 템플릿을 섹션으로 변환하여 페이지에 추가
const newSection: Omit<ItemSection, 'id' | 'created_at' | 'updated_at'> = {
page_id: selectedPage.id,
section_name: template.template_name,
section_type: template.section_type,
description: template.description || undefined,
order_no: selectedPage.sections.length + 1,
is_collapsible: true,
is_default_open: true,
fields: template.fields ? template.fields.map((field, idx) => ({
id: Date.now() + idx,
section_id: 0, // 추후 업데이트됨
field_name: field.name,
field_type: field.property.inputType,
order_no: idx + 1,
is_required: field.property.required,
placeholder: field.description || null,
default_value: null,
display_condition: null,
validation_rules: null,
options: field.property.options
? field.property.options.map(opt => ({ label: opt, value: opt }))
: null,
properties: field.property.multiColumn ? {
multiColumn: true,
columnCount: field.property.columnCount,
columnNames: field.property.columnNames
} : null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
})) : [],
bomItems: template.section_type === 'BOM' ? (template.bomItems || []) : undefined
};
console.log('Linking template to page:', {
templateId: template.id,
templateName: template.template_name,
pageId: selectedPage.id,
newSection
});
addSectionToPage(selectedPage.id, newSection);
resetSectionForm();
toast.success(`"${template.template_name}" 템플릿이 페이지에 연결되었습니다!`);
};
// 섹션 제목 수정 시작
const handleEditSectionTitle = (sectionId: number, currentTitle: string) => {
setEditingSectionId(sectionId);
setEditingSectionTitle(currentTitle);
};
// 섹션 제목 저장
const handleSaveSectionTitle = (selectedPage: ItemPage | undefined) => {
if (!selectedPage || !editingSectionId || !editingSectionTitle.trim()) {
toast.error('하위섹션 제목을 입력해주세요');
return;
}
updateSection(editingSectionId, { section_name: editingSectionTitle });
setEditingSectionId(null);
setEditingSectionTitle('');
toast.success('하위섹션 제목이 수정되었습니다 (저장 필요)');
};
// 섹션 삭제
const handleDeleteSection = (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(sectionId);
console.log('섹션 삭제 완료:', {
sectionId,
removedFields: fieldIds.length
});
};
// 섹션 확장/축소 토글
const toggleSection = (sectionId: string) => {
setExpandedSections(prev => ({ ...prev, [sectionId]: !prev[sectionId] }));
};
// 폼 초기화
const resetSectionForm = () => {
setNewSectionTitle('');
setNewSectionDescription('');
setNewSectionType('fields');
setSectionInputMode('custom');
setSelectedSectionTemplateId(null);
setIsSectionDialogOpen(false);
};
return {
// 상태
editingSectionId,
setEditingSectionId,
editingSectionTitle,
setEditingSectionTitle,
isSectionDialogOpen,
setIsSectionDialogOpen,
newSectionTitle,
setNewSectionTitle,
newSectionDescription,
setNewSectionDescription,
newSectionType,
setNewSectionType,
sectionInputMode,
setSectionInputMode,
selectedSectionTemplateId,
setSelectedSectionTemplateId,
expandedSections,
setExpandedSections,
// 핸들러
handleAddSection,
handleLinkTemplate,
handleEditSectionTitle,
handleSaveSectionTitle,
handleDeleteSection,
toggleSection,
resetSectionForm,
};
}

View File

@@ -0,0 +1,441 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { toast } from 'sonner';
import { useItemMaster } from '@/contexts/ItemMasterContext';
import {
FolderTree,
ListTree,
FileText,
Settings,
Layers,
Database,
Plus,
Folder
} from 'lucide-react';
export interface CustomTab {
id: string;
label: string;
icon: string;
isDefault: boolean;
order: number;
}
export interface AttributeSubTab {
id: string;
label: string;
key: string;
isDefault: boolean;
order: number;
}
export interface UseTabManagementReturn {
// 메인 탭 상태
customTabs: CustomTab[];
setCustomTabs: React.Dispatch<React.SetStateAction<CustomTab[]>>;
activeTab: string;
setActiveTab: (tab: string) => void;
// 속성 하위 탭 상태
attributeSubTabs: AttributeSubTab[];
setAttributeSubTabs: React.Dispatch<React.SetStateAction<AttributeSubTab[]>>;
activeAttributeTab: string;
setActiveAttributeTab: (tab: string) => void;
// 메인 탭 다이얼로그 상태
isAddTabDialogOpen: boolean;
setIsAddTabDialogOpen: (open: boolean) => void;
isManageTabsDialogOpen: boolean;
setIsManageTabsDialogOpen: (open: boolean) => void;
newTabLabel: string;
setNewTabLabel: (label: string) => void;
editingTabId: string | null;
setEditingTabId: (id: string | null) => void;
deletingTabId: string | null;
setDeletingTabId: (id: string | null) => void;
isDeleteTabDialogOpen: boolean;
setIsDeleteTabDialogOpen: (open: boolean) => void;
// 속성 하위 탭 다이얼로그 상태
isManageAttributeTabsDialogOpen: boolean;
setIsManageAttributeTabsDialogOpen: (open: boolean) => void;
isAddAttributeTabDialogOpen: boolean;
setIsAddAttributeTabDialogOpen: (open: boolean) => void;
newAttributeTabLabel: string;
setNewAttributeTabLabel: (label: string) => void;
editingAttributeTabId: string | null;
setEditingAttributeTabId: (id: string | null) => void;
deletingAttributeTabId: string | null;
setDeletingAttributeTabId: (id: string | null) => void;
isDeleteAttributeTabDialogOpen: boolean;
setIsDeleteAttributeTabDialogOpen: (open: boolean) => void;
// 핸들러
handleAddTab: () => void;
handleUpdateTab: () => void;
handleDeleteTab: (tabId: string) => void;
confirmDeleteTab: () => void;
handleAddAttributeTab: () => void;
handleUpdateAttributeTab: () => void;
handleDeleteAttributeTab: (tabId: string) => void;
confirmDeleteAttributeTab: () => void;
moveTabUp: (tabId: string) => void;
moveTabDown: (tabId: string) => void;
moveAttributeTabUp: (tabId: string) => void;
moveAttributeTabDown: (tabId: string) => void;
getTabIcon: (iconName: string) => any;
handleEditTabFromManage: (tab: CustomTab) => void;
}
export function useTabManagement(): UseTabManagementReturn {
const { itemMasterFields } = useItemMaster();
// 메인 탭 상태
const [customTabs, setCustomTabs] = useState<CustomTab[]>([
{ 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 }
]);
const [activeTab, setActiveTab] = useState('hierarchy');
// 속성 하위 탭 상태
const [attributeSubTabs, setAttributeSubTabs] = useState<AttributeSubTab[]>([]);
const [activeAttributeTab, setActiveAttributeTab] = useState('units');
// 메인 탭 다이얼로그 상태
const [isAddTabDialogOpen, setIsAddTabDialogOpen] = useState(false);
const [isManageTabsDialogOpen, setIsManageTabsDialogOpen] = useState(false);
const [newTabLabel, setNewTabLabel] = useState('');
const [editingTabId, setEditingTabId] = useState<string | null>(null);
const [deletingTabId, setDeletingTabId] = useState<string | null>(null);
const [isDeleteTabDialogOpen, setIsDeleteTabDialogOpen] = useState(false);
// 속성 하위 탭 다이얼로그 상태
const [isManageAttributeTabsDialogOpen, setIsManageAttributeTabsDialogOpen] = useState(false);
const [isAddAttributeTabDialogOpen, setIsAddAttributeTabDialogOpen] = useState(false);
const [newAttributeTabLabel, setNewAttributeTabLabel] = useState('');
const [editingAttributeTabId, setEditingAttributeTabId] = useState<string | null>(null);
const [deletingAttributeTabId, setDeletingAttributeTabId] = useState<string | null>(null);
const [isDeleteAttributeTabDialogOpen, setIsDeleteAttributeTabDialogOpen] = useState(false);
// 이전 필드 상태 추적용 ref (무한 루프 방지)
const prevFieldsRef = useRef<string>('');
// 마스터 항목이 추가/수정될 때 속성 탭 자동 생성
useEffect(() => {
// 현재 필드 상태를 문자열로 직렬화
const currentFieldsState = JSON.stringify(
itemMasterFields.map(f => ({ id: f.id, name: f.field_name })).sort((a, b) => a.id - b.id)
);
// 이전 상태와 동일하면 업데이트 스킵
if (prevFieldsRef.current === currentFieldsState) {
return;
}
prevFieldsRef.current = currentFieldsState;
setAttributeSubTabs(prev => {
const newTabs: AttributeSubTab[] = [];
const updates: { key: string; label: string }[] = [];
itemMasterFields.forEach(field => {
const existingTab = prev.find(tab => tab.key === field.id.toString());
if (!existingTab) {
const maxOrder = Math.max(...prev.map(t => t.order), ...newTabs.map(t => t.order), -1);
newTabs.push({
id: `attr-${field.id.toString()}`,
label: field.field_name,
key: field.id.toString(),
isDefault: false,
order: maxOrder + 1
});
} else if (existingTab.label !== field.field_name) {
updates.push({ key: existingTab.key, label: field.field_name });
}
});
// 변경사항 없으면 이전 상태 그대로 반환
if (newTabs.length === 0 && updates.length === 0) {
return prev;
}
let result = prev.map(tab => {
const update = updates.find(u => u.key === tab.key);
return update ? { ...tab, label: update.label } : tab;
});
result = [...result, ...newTabs];
// 중복 제거
return result.filter((tab, index, self) =>
index === self.findIndex(t => t.key === tab.key)
);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemMasterFields]);
// 메인 탭 핸들러
const handleAddTab = () => {
if (!newTabLabel.trim()) {
toast.error('탭 이름을 입력해주세요');
return;
}
const newTab: CustomTab = {
id: Date.now().toString(),
label: newTabLabel,
icon: 'FileText',
isDefault: false,
order: customTabs.length + 1
};
setCustomTabs(prev => [...prev, newTab]);
setNewTabLabel('');
setIsAddTabDialogOpen(false);
toast.success('탭이 추가되었습니다');
};
const handleUpdateTab = () => {
if (!newTabLabel.trim() || !editingTabId) {
toast.error('탭 이름을 입력해주세요');
return;
}
setCustomTabs(prev => prev.map(tab =>
tab.id === editingTabId ? { ...tab, label: newTabLabel } : tab
));
setEditingTabId(null);
setNewTabLabel('');
setIsAddTabDialogOpen(false);
setIsManageTabsDialogOpen(true);
toast.success('탭이 수정되었습니다');
};
const handleDeleteTab = (tabId: string) => {
const tab = customTabs.find(t => t.id === tabId);
if (!tab || tab.isDefault) {
toast.error('기본 탭은 삭제할 수 없습니다');
return;
}
setDeletingTabId(tabId);
setIsDeleteTabDialogOpen(true);
};
const confirmDeleteTab = () => {
if (!deletingTabId) return;
setCustomTabs(prev => prev.filter(t => t.id !== deletingTabId));
if (activeTab === deletingTabId) {
setActiveTab('hierarchy');
}
setIsDeleteTabDialogOpen(false);
setDeletingTabId(null);
toast.success('탭이 삭제되었습니다');
};
// 속성 하위 탭 핸들러
const handleAddAttributeTab = () => {
if (!newAttributeTabLabel.trim()) {
toast.error('탭 이름을 입력해주세요');
return;
}
const newTab: AttributeSubTab = {
id: `attr-${Date.now()}`,
label: newAttributeTabLabel,
key: `custom-${Date.now()}`,
isDefault: false,
order: attributeSubTabs.length
};
setAttributeSubTabs(prev => [...prev, newTab]);
setNewAttributeTabLabel('');
setIsAddAttributeTabDialogOpen(false);
toast.success('속성 탭이 추가되었습니다');
};
const handleUpdateAttributeTab = () => {
if (!newAttributeTabLabel.trim() || !editingAttributeTabId) {
toast.error('탭 이름을 입력해주세요');
return;
}
setAttributeSubTabs(prev => prev.map(tab =>
tab.id === editingAttributeTabId ? { ...tab, label: newAttributeTabLabel } : tab
));
setEditingAttributeTabId(null);
setNewAttributeTabLabel('');
setIsAddAttributeTabDialogOpen(false);
setIsManageAttributeTabsDialogOpen(true);
toast.success('속성 탭이 수정되었습니다');
};
const handleDeleteAttributeTab = (tabId: string) => {
const tab = attributeSubTabs.find(t => t.id === tabId);
if (!tab || tab.isDefault) {
toast.error('기본 속성 탭은 삭제할 수 없습니다');
return;
}
setDeletingAttributeTabId(tabId);
setIsDeleteAttributeTabDialogOpen(true);
};
const confirmDeleteAttributeTab = () => {
if (!deletingAttributeTabId) return;
setAttributeSubTabs(prev => prev.filter(t => t.id !== deletingAttributeTabId));
if (activeAttributeTab === deletingAttributeTabId) {
const firstTab = attributeSubTabs.find(t => t.id !== deletingAttributeTabId);
if (firstTab) {
setActiveAttributeTab(firstTab.key);
}
}
setIsDeleteAttributeTabDialogOpen(false);
setDeletingAttributeTabId(null);
toast.success('속성 탭이 삭제되었습니다');
};
// 탭 순서 변경 핸들러
const moveTabUp = (tabId: string) => {
const tabIndex = customTabs.findIndex(t => t.id === tabId);
if (tabIndex <= 0) return;
const newTabs = [...customTabs];
const temp = newTabs[tabIndex - 1].order;
newTabs[tabIndex - 1].order = newTabs[tabIndex].order;
newTabs[tabIndex].order = temp;
setCustomTabs(newTabs.sort((a, b) => a.order - b.order));
toast.success('탭 순서가 변경되었습니다');
};
const moveTabDown = (tabId: string) => {
const tabIndex = customTabs.findIndex(t => t.id === tabId);
if (tabIndex >= customTabs.length - 1) return;
const newTabs = [...customTabs];
const temp = newTabs[tabIndex + 1].order;
newTabs[tabIndex + 1].order = newTabs[tabIndex].order;
newTabs[tabIndex].order = temp;
setCustomTabs(newTabs.sort((a, b) => a.order - b.order));
toast.success('탭 순서가 변경되었습니다');
};
const moveAttributeTabUp = (tabId: string) => {
const tabIndex = attributeSubTabs.findIndex(t => t.id === tabId);
if (tabIndex <= 0) return;
const newTabs = [...attributeSubTabs];
const temp = newTabs[tabIndex - 1].order;
newTabs[tabIndex - 1].order = newTabs[tabIndex].order;
newTabs[tabIndex].order = temp;
setAttributeSubTabs(newTabs.sort((a, b) => a.order - b.order));
};
const moveAttributeTabDown = (tabId: string) => {
const tabIndex = attributeSubTabs.findIndex(t => t.id === tabId);
if (tabIndex >= attributeSubTabs.length - 1) return;
const newTabs = [...attributeSubTabs];
const temp = newTabs[tabIndex + 1].order;
newTabs[tabIndex + 1].order = newTabs[tabIndex].order;
newTabs[tabIndex].order = temp;
setAttributeSubTabs(newTabs.sort((a, b) => a.order - b.order));
};
// 아이콘 헬퍼
const getTabIcon = (iconName: string) => {
const icons: Record<string, any> = {
FolderTree,
ListTree,
FileText,
Settings,
Layers,
Database,
Plus,
Folder
};
return icons[iconName] || FileText;
};
// 탭 관리에서 수정 시작
const handleEditTabFromManage = (tab: CustomTab) => {
if (tab.isDefault) {
toast.error('기본 탭은 수정할 수 없습니다');
return;
}
setEditingTabId(tab.id);
setNewTabLabel(tab.label);
setIsManageTabsDialogOpen(false);
setIsAddTabDialogOpen(true);
};
return {
// 메인 탭 상태
customTabs,
setCustomTabs,
activeTab,
setActiveTab,
// 속성 하위 탭 상태
attributeSubTabs,
setAttributeSubTabs,
activeAttributeTab,
setActiveAttributeTab,
// 메인 탭 다이얼로그 상태
isAddTabDialogOpen,
setIsAddTabDialogOpen,
isManageTabsDialogOpen,
setIsManageTabsDialogOpen,
newTabLabel,
setNewTabLabel,
editingTabId,
setEditingTabId,
deletingTabId,
setDeletingTabId,
isDeleteTabDialogOpen,
setIsDeleteTabDialogOpen,
// 속성 하위 탭 다이얼로그 상태
isManageAttributeTabsDialogOpen,
setIsManageAttributeTabsDialogOpen,
isAddAttributeTabDialogOpen,
setIsAddAttributeTabDialogOpen,
newAttributeTabLabel,
setNewAttributeTabLabel,
editingAttributeTabId,
setEditingAttributeTabId,
deletingAttributeTabId,
setDeletingAttributeTabId,
isDeleteAttributeTabDialogOpen,
setIsDeleteAttributeTabDialogOpen,
// 핸들러
handleAddTab,
handleUpdateTab,
handleDeleteTab,
confirmDeleteTab,
handleAddAttributeTab,
handleUpdateAttributeTab,
handleDeleteAttributeTab,
confirmDeleteAttributeTab,
moveTabUp,
moveTabDown,
moveAttributeTabUp,
moveAttributeTabDown,
getTabIcon,
handleEditTabFromManage,
};
}

View File

@@ -0,0 +1,470 @@
'use client';
import { useState } from 'react';
import { toast } from 'sonner';
import { useItemMaster } from '@/contexts/ItemMasterContext';
import type { ItemPage, SectionTemplate, TemplateField, BOMItem, ItemMasterField } from '@/contexts/ItemMasterContext';
export interface UseTemplateManagementReturn {
// 섹션 템플릿 다이얼로그 상태
isSectionTemplateDialogOpen: boolean;
setIsSectionTemplateDialogOpen: (open: boolean) => void;
editingSectionTemplateId: number | null;
setEditingSectionTemplateId: (id: number | null) => void;
// 섹션 템플릿 폼 상태
newSectionTemplateTitle: string;
setNewSectionTemplateTitle: (title: string) => void;
newSectionTemplateDescription: string;
setNewSectionTemplateDescription: (desc: string) => void;
newSectionTemplateCategory: string[];
setNewSectionTemplateCategory: React.Dispatch<React.SetStateAction<string[]>>;
newSectionTemplateType: 'fields' | 'bom';
setNewSectionTemplateType: (type: 'fields' | 'bom') => void;
// 템플릿 불러오기 다이얼로그
isLoadTemplateDialogOpen: boolean;
setIsLoadTemplateDialogOpen: (open: boolean) => void;
selectedTemplateId: string | null;
setSelectedTemplateId: (id: string | null) => void;
// 템플릿 필드 다이얼로그 상태
isTemplateFieldDialogOpen: boolean;
setIsTemplateFieldDialogOpen: (open: boolean) => void;
currentTemplateId: number | null;
setCurrentTemplateId: (id: number | null) => void;
editingTemplateFieldId: number | null;
setEditingTemplateFieldId: (id: number | null) => void;
// 템플릿 필드 폼 상태
templateFieldName: string;
setTemplateFieldName: (name: string) => void;
templateFieldKey: string;
setTemplateFieldKey: (key: string) => void;
templateFieldInputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
setTemplateFieldInputType: (type: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea') => void;
templateFieldRequired: boolean;
setTemplateFieldRequired: (required: boolean) => void;
templateFieldOptions: string;
setTemplateFieldOptions: (options: string) => void;
templateFieldDescription: string;
setTemplateFieldDescription: (desc: string) => void;
templateFieldMultiColumn: boolean;
setTemplateFieldMultiColumn: (multi: boolean) => void;
templateFieldColumnCount: number;
setTemplateFieldColumnCount: (count: number) => void;
templateFieldColumnNames: string[];
setTemplateFieldColumnNames: React.Dispatch<React.SetStateAction<string[]>>;
// 템플릿 필드 마스터 항목 관련
templateFieldInputMode: 'custom' | 'master';
setTemplateFieldInputMode: (mode: 'custom' | 'master') => void;
templateFieldShowMasterFieldList: boolean;
setTemplateFieldShowMasterFieldList: (show: boolean) => void;
templateFieldSelectedMasterFieldId: string;
setTemplateFieldSelectedMasterFieldId: (id: string) => void;
// 핸들러
handleAddSectionTemplate: () => void;
handleEditSectionTemplate: (template: SectionTemplate) => void;
handleUpdateSectionTemplate: () => void;
handleDeleteSectionTemplate: (id: number) => void;
handleLoadTemplate: (selectedPage: ItemPage | undefined) => void;
handleAddTemplateField: () => void;
handleEditTemplateField: (templateId: number, field: TemplateField) => void;
handleDeleteTemplateField: (templateId: number, fieldId: string) => void;
handleAddBOMItemToTemplate: (templateId: number, item: Omit<BOMItem, 'id' | 'created_at' | 'updated_at' | 'tenant_id' | 'section_id'>) => void;
handleUpdateBOMItemInTemplate: (templateId: number, itemId: number, item: Partial<BOMItem>) => void;
handleDeleteBOMItemFromTemplate: (templateId: number, itemId: number) => void;
resetSectionTemplateForm: () => void;
resetTemplateFieldForm: () => void;
}
export function useTemplateManagement(): UseTemplateManagementReturn {
const {
sectionTemplates,
addSectionTemplate,
updateSectionTemplate,
deleteSectionTemplate,
addSectionToPage,
addItemMasterField,
itemMasterFields,
tenantId
} = useItemMaster();
// 섹션 템플릿 다이얼로그 상태
const [isSectionTemplateDialogOpen, setIsSectionTemplateDialogOpen] = useState(false);
const [editingSectionTemplateId, setEditingSectionTemplateId] = useState<number | null>(null);
// 섹션 템플릿 폼 상태
const [newSectionTemplateTitle, setNewSectionTemplateTitle] = useState('');
const [newSectionTemplateDescription, setNewSectionTemplateDescription] = useState('');
const [newSectionTemplateCategory, setNewSectionTemplateCategory] = useState<string[]>([]);
const [newSectionTemplateType, setNewSectionTemplateType] = useState<'fields' | 'bom'>('fields');
// 템플릿 불러오기 다이얼로그
const [isLoadTemplateDialogOpen, setIsLoadTemplateDialogOpen] = useState(false);
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
// 템플릿 필드 다이얼로그 상태
const [isTemplateFieldDialogOpen, setIsTemplateFieldDialogOpen] = useState(false);
const [currentTemplateId, setCurrentTemplateId] = useState<number | null>(null);
const [editingTemplateFieldId, setEditingTemplateFieldId] = useState<number | null>(null);
// 템플릿 필드 폼 상태
const [templateFieldName, setTemplateFieldName] = useState('');
const [templateFieldKey, setTemplateFieldKey] = useState('');
const [templateFieldInputType, setTemplateFieldInputType] = useState<'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea'>('textbox');
const [templateFieldRequired, setTemplateFieldRequired] = useState(false);
const [templateFieldOptions, setTemplateFieldOptions] = useState('');
const [templateFieldDescription, setTemplateFieldDescription] = useState('');
const [templateFieldMultiColumn, setTemplateFieldMultiColumn] = useState(false);
const [templateFieldColumnCount, setTemplateFieldColumnCount] = useState(2);
const [templateFieldColumnNames, setTemplateFieldColumnNames] = useState<string[]>(['컬럼1', '컬럼2']);
// 템플릿 필드 마스터 항목 관련
const [templateFieldInputMode, setTemplateFieldInputMode] = useState<'custom' | 'master'>('custom');
const [templateFieldShowMasterFieldList, setTemplateFieldShowMasterFieldList] = useState(false);
const [templateFieldSelectedMasterFieldId, setTemplateFieldSelectedMasterFieldId] = useState('');
// 섹션 템플릿 추가
const handleAddSectionTemplate = () => {
if (!newSectionTemplateTitle.trim()) {
toast.error('섹션 제목을 입력해주세요');
return;
}
const newTemplateData = {
tenant_id: tenantId ?? 0,
template_name: newSectionTemplateTitle,
section_type: (newSectionTemplateType === 'bom' ? 'BOM' : 'BASIC') as 'BASIC' | 'BOM' | 'CUSTOM',
description: newSectionTemplateDescription || null,
default_fields: null,
category: newSectionTemplateCategory,
created_by: null,
updated_by: null,
};
console.log('Adding section template:', newTemplateData);
addSectionTemplate(newTemplateData);
resetSectionTemplateForm();
toast.success('섹션 템플릿이 추가되었습니다!');
};
// 섹션 템플릿 수정 시작
const handleEditSectionTemplate = (template: SectionTemplate) => {
setEditingSectionTemplateId(template.id);
setNewSectionTemplateTitle(template.template_name);
setNewSectionTemplateDescription(template.description || '');
setNewSectionTemplateCategory(template.category || []);
setNewSectionTemplateType(template.section_type === 'BOM' ? 'bom' : 'fields');
setIsSectionTemplateDialogOpen(true);
};
// 섹션 템플릿 업데이트
const handleUpdateSectionTemplate = () => {
if (!editingSectionTemplateId || !newSectionTemplateTitle.trim()) {
toast.error('섹션 제목을 입력해주세요');
return;
}
const updateData = {
template_name: newSectionTemplateTitle,
description: newSectionTemplateDescription || undefined,
category: newSectionTemplateCategory.length > 0 ? newSectionTemplateCategory : undefined,
section_type: (newSectionTemplateType === 'bom' ? 'BOM' : 'BASIC') as 'BASIC' | 'BOM' | 'CUSTOM'
};
console.log('Updating section template:', { id: editingSectionTemplateId, updateData });
updateSectionTemplate(editingSectionTemplateId, updateData);
resetSectionTemplateForm();
toast.success('섹션이 수정되었습니다 (저장 필요)');
};
// 섹션 템플릿 삭제
const handleDeleteSectionTemplate = (id: number) => {
if (confirm('이 섹션을 삭제하시겠습니까?')) {
deleteSectionTemplate(id);
toast.success('섹션이 삭제되었습니다');
}
};
// 템플릿 불러오기
const handleLoadTemplate = (selectedPage: ItemPage | undefined) => {
if (!selectedTemplateId || !selectedPage) {
toast.error('템플릿을 선택해주세요');
return;
}
const template = sectionTemplates.find(t => t.id === Number(selectedTemplateId));
if (!template) {
toast.error('템플릿을 찾을 수 없습니다');
return;
}
const newSection = {
page_id: selectedPage.id,
section_name: template.template_name,
section_type: template.section_type === 'BOM' ? 'BOM' as const : 'BASIC' as const,
description: template.description || undefined,
order_no: selectedPage.sections.length + 1,
is_collapsible: true,
is_default_open: true,
fields: [],
bomItems: template.section_type === 'BOM' ? [] : undefined
};
console.log('Loading template to section:', template.template_name, 'newSection:', newSection);
addSectionToPage(selectedPage.id, newSection);
setSelectedTemplateId(null);
setIsLoadTemplateDialogOpen(false);
toast.success('섹션이 불러와졌습니다');
};
// 템플릿 필드 추가
const handleAddTemplateField = () => {
if (!currentTemplateId || !templateFieldName.trim() || !templateFieldKey.trim()) {
toast.error('모든 필수 항목을 입력해주세요');
return;
}
const template = sectionTemplates.find(t => t.id === currentTemplateId);
if (!template) return;
// 마스터 필드에 없으면 자동 추가
const existingMasterField = itemMasterFields.find(f => f.id.toString() === templateFieldKey);
if (!existingMasterField && !editingTemplateFieldId) {
const newMasterFieldData: Omit<ItemMasterField, 'id' | 'tenant_id' | 'created_by' | 'updated_by' | 'created_at' | 'updated_at'> = {
field_name: templateFieldName,
field_type: templateFieldInputType,
category: '공통',
description: templateFieldDescription || null,
is_common: false,
default_value: null,
options: templateFieldInputType === 'dropdown' && templateFieldOptions.trim()
? templateFieldOptions.split(',').map(o => ({ label: o.trim(), value: o.trim() }))
: null,
validation_rules: null,
properties: {
inputType: templateFieldInputType,
required: templateFieldRequired,
multiColumn: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') ? templateFieldMultiColumn : undefined,
columnCount: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') && templateFieldMultiColumn ? templateFieldColumnCount : undefined,
columnNames: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') && templateFieldMultiColumn ? templateFieldColumnNames : undefined
},
};
addItemMasterField(newMasterFieldData as any);
toast.success('항목 탭에 자동으로 추가되었습니다');
}
// TemplateField 형식으로 생성
const newField: TemplateField = {
id: String(editingTemplateFieldId || Date.now()),
name: templateFieldName,
fieldKey: templateFieldKey,
property: {
inputType: templateFieldInputType,
required: templateFieldRequired,
options: templateFieldInputType === 'dropdown' && templateFieldOptions.trim()
? templateFieldOptions.split(',').map(o => o.trim())
: undefined,
multiColumn: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') ? templateFieldMultiColumn : undefined,
columnCount: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') && templateFieldMultiColumn ? templateFieldColumnCount : undefined,
columnNames: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') && templateFieldMultiColumn ? templateFieldColumnNames : undefined
},
description: templateFieldDescription || undefined
};
let updatedFields;
const currentFields = template.default_fields
? (typeof template.default_fields === 'string' ? JSON.parse(template.default_fields) : template.default_fields)
: [];
if (editingTemplateFieldId) {
updatedFields = Array.isArray(currentFields)
? currentFields.map((f: any) => String(f.id) === String(editingTemplateFieldId) ? newField : f)
: [];
toast.success('항목이 수정되었습니다');
} else {
updatedFields = Array.isArray(currentFields) ? [...currentFields, newField] : [newField];
toast.success('항목이 추가되었습니다');
}
updateSectionTemplate(currentTemplateId, { default_fields: updatedFields });
resetTemplateFieldForm();
};
// 템플릿 필드 수정 시작
const handleEditTemplateField = (templateId: number, field: TemplateField) => {
setCurrentTemplateId(templateId);
setEditingTemplateFieldId(Number(field.id));
setTemplateFieldName(field.name);
setTemplateFieldKey(field.fieldKey);
setTemplateFieldInputType(field.property.inputType);
setTemplateFieldRequired(field.property.required);
setTemplateFieldOptions(field.property.options?.join(', ') || '');
setTemplateFieldDescription(field.description || '');
setTemplateFieldMultiColumn(field.property.multiColumn || false);
setTemplateFieldColumnCount(field.property.columnCount || 2);
setTemplateFieldColumnNames(field.property.columnNames || ['컬럼1', '컬럼2']);
setIsTemplateFieldDialogOpen(true);
};
// 템플릿 필드 삭제
const handleDeleteTemplateField = (templateId: number, fieldId: string) => {
if (!confirm('이 항목을 삭제하시겠습니까?')) return;
const template = sectionTemplates.find(t => t.id === templateId);
if (!template) return;
const currentFields = template.default_fields
? (typeof template.default_fields === 'string' ? JSON.parse(template.default_fields) : template.default_fields)
: [];
const updatedFields = Array.isArray(currentFields)
? currentFields.filter((f: any) => String(f.id) !== String(fieldId))
: [];
updateSectionTemplate(templateId, { default_fields: updatedFields });
toast.success('항목이 삭제되었습니다');
};
// BOM 항목 추가
const handleAddBOMItemToTemplate = (templateId: number, item: Omit<BOMItem, 'id' | 'created_at' | 'updated_at' | 'tenant_id' | 'section_id'>) => {
const newItem: BOMItem = {
...item,
id: Date.now(),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
tenant_id: tenantId ?? 0,
section_id: 0
};
const template = sectionTemplates.find(t => t.id === templateId);
if (!template) return;
const updatedBomItems = [...(template.bomItems || []), newItem];
updateSectionTemplate(templateId, { bomItems: updatedBomItems });
};
// BOM 항목 수정
const handleUpdateBOMItemInTemplate = (templateId: number, itemId: number, item: Partial<BOMItem>) => {
const template = sectionTemplates.find(t => t.id === templateId);
if (!template || !template.bomItems) return;
const updatedBomItems = template.bomItems.map(bom =>
bom.id === itemId ? { ...bom, ...item } : bom
);
updateSectionTemplate(templateId, { bomItems: updatedBomItems });
};
// BOM 항목 삭제
const handleDeleteBOMItemFromTemplate = (templateId: number, itemId: number) => {
const template = sectionTemplates.find(t => t.id === templateId);
if (!template || !template.bomItems) return;
const updatedBomItems = template.bomItems.filter(bom => bom.id !== itemId);
updateSectionTemplate(templateId, { bomItems: updatedBomItems });
};
// 섹션 템플릿 폼 초기화
const resetSectionTemplateForm = () => {
setEditingSectionTemplateId(null);
setNewSectionTemplateTitle('');
setNewSectionTemplateDescription('');
setNewSectionTemplateCategory([]);
setNewSectionTemplateType('fields');
setIsSectionTemplateDialogOpen(false);
};
// 템플릿 필드 폼 초기화
const resetTemplateFieldForm = () => {
setTemplateFieldName('');
setTemplateFieldKey('');
setTemplateFieldInputType('textbox');
setTemplateFieldRequired(false);
setTemplateFieldOptions('');
setTemplateFieldDescription('');
setTemplateFieldMultiColumn(false);
setTemplateFieldColumnCount(2);
setTemplateFieldColumnNames(['컬럼1', '컬럼2']);
setEditingTemplateFieldId(null);
setTemplateFieldInputMode('custom');
setTemplateFieldShowMasterFieldList(false);
setTemplateFieldSelectedMasterFieldId('');
setIsTemplateFieldDialogOpen(false);
};
return {
// 섹션 템플릿 다이얼로그 상태
isSectionTemplateDialogOpen,
setIsSectionTemplateDialogOpen,
editingSectionTemplateId,
setEditingSectionTemplateId,
// 섹션 템플릿 폼 상태
newSectionTemplateTitle,
setNewSectionTemplateTitle,
newSectionTemplateDescription,
setNewSectionTemplateDescription,
newSectionTemplateCategory,
setNewSectionTemplateCategory,
newSectionTemplateType,
setNewSectionTemplateType,
// 템플릿 불러오기 다이얼로그
isLoadTemplateDialogOpen,
setIsLoadTemplateDialogOpen,
selectedTemplateId,
setSelectedTemplateId,
// 템플릿 필드 다이얼로그 상태
isTemplateFieldDialogOpen,
setIsTemplateFieldDialogOpen,
currentTemplateId,
setCurrentTemplateId,
editingTemplateFieldId,
setEditingTemplateFieldId,
// 템플릿 필드 폼 상태
templateFieldName,
setTemplateFieldName,
templateFieldKey,
setTemplateFieldKey,
templateFieldInputType,
setTemplateFieldInputType,
templateFieldRequired,
setTemplateFieldRequired,
templateFieldOptions,
setTemplateFieldOptions,
templateFieldDescription,
setTemplateFieldDescription,
templateFieldMultiColumn,
setTemplateFieldMultiColumn,
templateFieldColumnCount,
setTemplateFieldColumnCount,
templateFieldColumnNames,
setTemplateFieldColumnNames,
// 템플릿 필드 마스터 항목 관련
templateFieldInputMode,
setTemplateFieldInputMode,
templateFieldShowMasterFieldList,
setTemplateFieldShowMasterFieldList,
templateFieldSelectedMasterFieldId,
setTemplateFieldSelectedMasterFieldId,
// 핸들러
handleAddSectionTemplate,
handleEditSectionTemplate,
handleUpdateSectionTemplate,
handleDeleteSectionTemplate,
handleLoadTemplate,
handleAddTemplateField,
handleEditTemplateField,
handleDeleteTemplateField,
handleAddBOMItemToTemplate,
handleUpdateBOMItemInTemplate,
handleDeleteBOMItemFromTemplate,
resetSectionTemplateForm,
resetTemplateFieldForm,
};
}

View File

@@ -25,8 +25,8 @@ interface HierarchyTabProps {
setEditingPathPageId: (id: number | null) => void;
editingAbsolutePath: string;
setEditingAbsolutePath: (path: string) => void;
editingSectionId: string | null;
setEditingSectionId: (id: string | null) => void;
editingSectionId: number | null;
setEditingSectionId: (id: number | null) => void;
editingSectionTitle: string;
setEditingSectionTitle: (title: string) => void;
hasUnsavedChanges: boolean;
@@ -51,7 +51,7 @@ interface HierarchyTabProps {
setIsPageDialogOpen: (open: boolean) => void;
setIsSectionDialogOpen: (open: boolean) => void;
setIsFieldDialogOpen: (open: boolean) => void;
handleEditSectionTitle: (sectionId: string, title: string) => void;
handleEditSectionTitle: (sectionId: number, title: string) => void;
handleSaveSectionTitle: () => void;
moveSection: (dragIndex: number, hoverIndex: number) => void;
deleteSection: (pageId: number, sectionId: number) => void;

File diff suppressed because one or more lines are too long