feat: 품목관리 동적 렌더링 시스템 구현
- DynamicItemForm 컴포넌트 구조 생성 - DynamicField: 필드 타입별 렌더링 - DynamicSection: 섹션 단위 렌더링 - DynamicFormRenderer: 페이지 전체 렌더링 - 필드 타입별 컴포넌트 (TextField, NumberField, DropdownField, CheckboxField, DateField, FileField, CustomField) - 커스텀 훅 (useDynamicFormState, useFormStructure, useConditionalFields) - DataTable 공통 컴포넌트 (테이블, 페이지네이션, 검색, 탭필터, 통계카드) - ItemFormWrapper: Feature Flag 기반 폼 선택 - 타입 정의 및 문서화 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -39,7 +39,8 @@ claudedocs/
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `[API-REQUEST-2025-11-28] dynamic-page-rendering-api.md` | ⭐ **최신** - 동적 페이지 렌더링 API 요청서 |
|
||||
| `[PLAN-2025-11-28] dynamic-item-form-implementation.md` | ✅ **Phase 1-6 완료** - 품목관리 동적 렌더링 구현 (타입, 훅, 필드, 렌더러, 메인폼, Feature Flag) |
|
||||
| `[API-REQUEST-2025-11-28] dynamic-page-rendering-api.md` | ⭐ **v3.1** - 동적 페이지 렌더링 API 요청서 (ID 기반 통일) |
|
||||
| `[PLAN-2025-11-27] item-form-component-separation.md` | ✅ **완료** - ItemForm 컴포넌트 분리 (1607→415줄, 74% 감소) |
|
||||
| `[IMPL-2025-11-27] realtime-sync-fixes.md` | 실시간 동기화 수정 (BOM, 섹션 복제, 항목 수정, **페이지 삭제 시 섹션 동기화** 2025-11-28) |
|
||||
| `item-master-api-pending-tasks.md` | 진행중인 API 연동 작업 |
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
# 품목관리 페이지 동적 렌더링 구현 계획
|
||||
|
||||
## 작업 일자: 2025-11-28
|
||||
|
||||
## 문서 버전
|
||||
| 버전 | 날짜 | 작성자 | 내용 |
|
||||
|------|------|--------|------|
|
||||
| 1.0 | 2025-11-28 | Claude | 초안 작성 |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 목표
|
||||
|
||||
**현재**: 하드코딩된 품목 유형별 폼 (ProductForm, PartForm, MaterialForm 등)
|
||||
**목표**: API 기반 동적 폼 렌더링 (품목기준관리에서 설정한 구조대로 자동 생성)
|
||||
|
||||
### 기대 효과
|
||||
- 코드 수정 없이 폼 구조 변경 가능
|
||||
- 품목 유형별 필드 추가/삭제가 관리자 UI에서 가능
|
||||
- 새로운 품목 유형 추가 시 개발 공수 대폭 감소
|
||||
- 일관된 폼 렌더링 로직으로 유지보수 용이
|
||||
|
||||
---
|
||||
|
||||
## 📊 현재 구조 분석
|
||||
|
||||
### 현재 파일 구조
|
||||
```
|
||||
src/components/items/ItemForm/
|
||||
├── index.tsx (415줄) ← 메인 컴포넌트
|
||||
├── constants.ts
|
||||
├── types.ts
|
||||
├── ValidationAlert.tsx
|
||||
├── FormHeader.tsx
|
||||
├── BendingDiagramSection.tsx
|
||||
├── BOMSection.tsx
|
||||
├── context/
|
||||
├── hooks/
|
||||
│ └── useItemFormState.ts (364줄) ← 25+ useState 통합
|
||||
└── forms/
|
||||
├── ProductForm.tsx (FG)
|
||||
├── MaterialForm.tsx (RM/SM/CS)
|
||||
├── PartForm.tsx (PT)
|
||||
└── parts/
|
||||
├── AssemblyPartForm.tsx
|
||||
├── BendingPartForm.tsx
|
||||
└── PurchasedPartForm.tsx
|
||||
```
|
||||
|
||||
### 현재 렌더링 방식 (하드코딩)
|
||||
```typescript
|
||||
// index.tsx:234-321
|
||||
{selectedItemType === 'FG' && <ProductForm ... />}
|
||||
{selectedItemType === 'PT' && <PartForm ... />}
|
||||
{(selectedItemType === 'RM' || selectedItemType === 'SM' || selectedItemType === 'CS') &&
|
||||
<MaterialForm ... />}
|
||||
```
|
||||
|
||||
### 문제점
|
||||
1. **품목 유형별 하드코딩**: 새 유형 추가 시 코드 수정 필요
|
||||
2. **필드 변경 시 개발 필요**: 관리자가 직접 변경 불가
|
||||
3. **조건부 렌더링 복잡**: PT의 ASSEMBLY/BENDING/PURCHASED 분기가 복잡
|
||||
4. **상태 관리 복잡**: 25+ useState가 하드코딩된 필드에 종속
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 동적 렌더링 아키텍처
|
||||
|
||||
### 목표 파일 구조
|
||||
```
|
||||
src/components/items/
|
||||
├── DynamicItemForm/ # 🆕 신규 생성
|
||||
│ ├── index.tsx # 메인 동적 폼
|
||||
│ ├── DynamicFormRenderer.tsx # 섹션/필드 렌더러
|
||||
│ ├── DynamicSection.tsx # 동적 섹션 컴포넌트
|
||||
│ ├── DynamicField.tsx # 동적 필드 컴포넌트
|
||||
│ ├── fields/ # 필드 타입별 컴포넌트
|
||||
│ │ ├── TextField.tsx
|
||||
│ │ ├── DropdownField.tsx
|
||||
│ │ ├── NumberField.tsx
|
||||
│ │ ├── DateField.tsx
|
||||
│ │ ├── FileField.tsx
|
||||
│ │ ├── CheckboxField.tsx
|
||||
│ │ └── CustomField.tsx # 특수 컴포넌트 (BOM, 전개도 등)
|
||||
│ ├── hooks/
|
||||
│ │ ├── useFormStructure.ts # API에서 폼 구조 로드
|
||||
│ │ ├── useDynamicFormState.ts # 동적 상태 관리
|
||||
│ │ └── useConditionalFields.ts # 조건부 필드 로직
|
||||
│ └── types.ts # 타입 정의
|
||||
├── ItemForm/ # 기존 (백업/하이브리드용)
|
||||
└── ItemFormLegacy/ # 마이그레이션 완료 후 이동
|
||||
```
|
||||
|
||||
### 데이터 흐름
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 품목기준관리 │
|
||||
│ (Pages → Sections → Fields 구조 정의) │
|
||||
└───────────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ GET /api/v1/item-master/form-structure/{item_type} │
|
||||
│ - page 정보 │
|
||||
│ - sections[] (순서, 접기 가능 여부) │
|
||||
│ - fields[] (타입, 필수 여부, 옵션) │
|
||||
│ - bom_config (BOM 섹션인 경우) │
|
||||
│ - conditional_sections (조건부 렌더링) │
|
||||
└───────────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ DynamicItemForm │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ useFormStructure(itemType) │ │
|
||||
│ │ - formStructure: { page, sections, conditionalSections } │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ DynamicFormRenderer │ │
|
||||
│ │ - sections.map(section => <DynamicSection />) │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ DynamicSection │ │
|
||||
│ │ - Card 렌더링 (collapsible, default_open) │ │
|
||||
│ │ - fields.map(field => <DynamicField />) │ │
|
||||
│ │ - BOM 섹션인 경우 → <BOMSection /> │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ DynamicField │ │
|
||||
│ │ - field_type에 따라 적절한 컴포넌트 렌더링 │ │
|
||||
│ │ - textbox → <TextField /> │ │
|
||||
│ │ - dropdown → <DropdownField /> │ │
|
||||
│ │ - file → <FileField /> │ │
|
||||
│ │ - custom → <CustomField /> (전개도, BOM 등) │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 구현 체크리스트
|
||||
|
||||
### Phase 1: 기반 작업 (API 프록시 + 타입) ✅ 완료 (2025-11-28)
|
||||
- [x] Next.js API 프록시 라우트 → 기존 catch-all 프록시 활용 (`/api/proxy/[...path]/route.ts`)
|
||||
- [x] 동적 폼 구조 타입 정의 → `DynamicItemForm/types.ts`
|
||||
- [x] `FormStructure`, `DynamicSection`, `DynamicField`, `ConditionalSection` 등
|
||||
|
||||
### Phase 2: 폼 구조 로딩 훅 ✅ 완료 (2025-11-28)
|
||||
- [x] `useFormStructure.ts` 구현
|
||||
- [x] API 호출 로직 + Mock 데이터 폴백
|
||||
- [x] 로딩/에러 상태 관리
|
||||
- [x] 캐싱 전략 (5분 TTL)
|
||||
- [x] `useConditionalFields.ts` 구현
|
||||
- [x] 조건부 섹션/필드 표시 로직
|
||||
- [x] 의존성 필드 값 변경 감지
|
||||
|
||||
### Phase 3: 동적 필드 컴포넌트 ✅ 완료 (2025-11-28)
|
||||
- [x] 기본 필드 컴포넌트
|
||||
- [x] `TextField.tsx` (textbox, textarea)
|
||||
- [x] `DropdownField.tsx` (dropdown, searchable-dropdown)
|
||||
- [x] `NumberField.tsx` (number, currency)
|
||||
- [x] `DateField.tsx` (date)
|
||||
- [x] `CheckboxField.tsx` (checkbox, switch)
|
||||
- [x] `FileField.tsx` (file upload)
|
||||
- [x] 특수 필드 컴포넌트
|
||||
- [x] `CustomField.tsx` (플레이스홀더 - 기존 컴포넌트 통합 예정)
|
||||
|
||||
### Phase 4: 동적 폼 렌더러 ✅ 완료 (2025-11-28)
|
||||
- [x] `DynamicField.tsx` 구현 (field_type → 컴포넌트 매핑)
|
||||
- [x] `DynamicSection.tsx` 구현 (Card, Collapsible, 그리드 레이아웃)
|
||||
- [x] `DynamicFormRenderer.tsx` 구현 (섹션 렌더링, 조건부 처리)
|
||||
|
||||
### Phase 5: 메인 폼 컴포넌트 ✅ 완료 (2025-11-28)
|
||||
- [x] `DynamicItemForm/index.tsx` 구현
|
||||
- [x] 커스텀 상태 관리 (react-hook-form 대신 useDynamicFormState)
|
||||
- [x] 동적 유효성 검증
|
||||
- [x] 폼 제출 로직
|
||||
- [x] `useDynamicFormState.ts` 구현
|
||||
- [x] 동적 상태 관리
|
||||
- [x] 필드 값 변경 핸들러
|
||||
- [x] 유효성 검증 로직
|
||||
|
||||
### Phase 6: 기존 폼 통합/마이그레이션 ✅ 완료 (2025-11-28)
|
||||
- [x] 기존 ItemForm과 DynamicItemForm 하이브리드 전환
|
||||
- [x] Feature flag로 전환 가능하게 (`ItemFormWrapper.tsx`)
|
||||
- [x] 품목 유형별 점진적 마이그레이션 (`ENABLED_ITEM_TYPES`)
|
||||
- [x] 개발 모드 토글 UI (localStorage 오버라이드)
|
||||
- [x] 기존 특수 컴포넌트 재사용 (CustomField.tsx에 통합)
|
||||
- [x] DrawingCanvasSimple (파일 업로드 기반)
|
||||
- [x] BOMTable (품목 구성 관리)
|
||||
- [x] BendingDetailTable (전개도 상세 입력 + 자동 계산)
|
||||
- [ ] 페이지 라우트 업데이트 (API 구현 후 진행)
|
||||
- [ ] `/items/create/page.tsx`
|
||||
- [ ] `/items/[id]/edit/page.tsx`
|
||||
|
||||
### Phase 7: 테스트 및 검증
|
||||
- [ ] 품목 유형별 테스트
|
||||
- [ ] FG (제품) 등록/수정 테스트
|
||||
- [ ] PT (부품) 등록/수정 테스트
|
||||
- [ ] RM/SM/CS 등록/수정 테스트
|
||||
- [ ] 조건부 필드 테스트
|
||||
- [ ] PT → ASSEMBLY/BENDING/PURCHASED 분기
|
||||
- [ ] BOM 섹션 조건부 표시
|
||||
- [ ] 에러 처리 테스트
|
||||
- [ ] API 실패 시 폴백
|
||||
- [ ] 유효성 검증 에러 표시
|
||||
|
||||
---
|
||||
|
||||
## 📋 API 응답 구조 (참조용)
|
||||
|
||||
### GET /api/v1/item-master/form-structure/{item_type}
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"page": {
|
||||
"id": 1,
|
||||
"page_name": "제품 등록",
|
||||
"item_type": "FG"
|
||||
},
|
||||
"sections": [
|
||||
{
|
||||
"id": 101,
|
||||
"title": "기본 정보",
|
||||
"section_type": "BASIC",
|
||||
"order_no": 1,
|
||||
"is_collapsible": false,
|
||||
"is_default_open": true,
|
||||
"fields": [
|
||||
{
|
||||
"id": 1001,
|
||||
"field_name": "품목명",
|
||||
"field_key": "item_name",
|
||||
"field_type": "textbox",
|
||||
"order_no": 1,
|
||||
"is_required": true,
|
||||
"placeholder": "품목명을 입력하세요",
|
||||
"validation_rules": { "maxLength": 100 },
|
||||
"grid_row": 1,
|
||||
"grid_col": 1,
|
||||
"grid_span": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 103,
|
||||
"title": "부품 구성 (BOM)",
|
||||
"section_type": "BOM",
|
||||
"order_no": 3,
|
||||
"bom_config": {
|
||||
"columns": [...],
|
||||
"allow_search": true,
|
||||
"search_endpoint": "/api/v1/items/search"
|
||||
}
|
||||
}
|
||||
],
|
||||
"conditional_sections": [
|
||||
{
|
||||
"condition": {
|
||||
"field_key": "needs_bom",
|
||||
"operator": "equals",
|
||||
"value": true
|
||||
},
|
||||
"show_sections": [103]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 필드 타입 → 컴포넌트 매핑
|
||||
|
||||
| field_type | 컴포넌트 | 설명 |
|
||||
|------------|----------|------|
|
||||
| `textbox` | TextField | 단일 텍스트 입력 |
|
||||
| `textarea` | TextField (multiline) | 여러 줄 텍스트 |
|
||||
| `dropdown` | DropdownField | 선택 목록 |
|
||||
| `searchable-dropdown` | DropdownField (searchable) | 검색 가능 선택 |
|
||||
| `number` | NumberField | 숫자 입력 |
|
||||
| `currency` | NumberField (currency) | 통화 입력 |
|
||||
| `date` | DateField | 날짜 선택 |
|
||||
| `date-range` | DateField (range) | 기간 선택 |
|
||||
| `checkbox` | CheckboxField | 체크박스 |
|
||||
| `switch` | CheckboxField (switch) | 토글 스위치 |
|
||||
| `file` | FileField | 파일 업로드 |
|
||||
| `custom:drawing-canvas` | DrawingCanvas | 전개도 그리기 |
|
||||
| `custom:bending-detail-table` | BendingDetailTable | 전개도 상세 입력 |
|
||||
| `custom:bom-table` | BOMSection | BOM 관리 테이블 |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
### 1. 기존 기능 보존
|
||||
- 품목코드 자동 생성 로직 유지
|
||||
- 전개도 폭 합계 자동 계산 유지
|
||||
- BOM 검색/추가 기능 유지
|
||||
|
||||
### 2. 점진적 마이그레이션
|
||||
- Feature flag로 신/구 폼 전환 가능하게
|
||||
- 문제 발생 시 즉시 롤백 가능
|
||||
- 품목 유형별로 순차 적용 (FG → PT → RM/SM/CS)
|
||||
|
||||
### 3. 성능 고려
|
||||
- 폼 구조 캐싱 (5분 TTL)
|
||||
- 동적 스키마 생성 최적화
|
||||
- 불필요한 리렌더링 방지
|
||||
|
||||
### 4. 백엔드 의존성
|
||||
- API 미구현 시 Mock 데이터로 개발
|
||||
- API 응답 형식 변경 시 transformer 레이어에서 처리
|
||||
|
||||
---
|
||||
|
||||
## 📅 예상 일정
|
||||
|
||||
| Phase | 작업 | 예상 작업량 |
|
||||
|-------|------|------------|
|
||||
| Phase 1 | API 프록시 + 타입 | 작음 |
|
||||
| Phase 2 | 폼 구조 로딩 훅 | 중간 |
|
||||
| Phase 3 | 동적 필드 컴포넌트 | 중간 |
|
||||
| Phase 4 | 동적 폼 렌더러 | 중간 |
|
||||
| Phase 5 | 메인 폼 컴포넌트 | 큼 |
|
||||
| Phase 6 | 마이그레이션 | 큼 |
|
||||
| Phase 7 | 테스트 | 중간 |
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- `[API-REQUEST-2025-11-28] dynamic-page-rendering-api.md` - API 명세
|
||||
- `[PLAN-2025-11-27] item-form-component-separation.md` - 기존 컴포넌트 분리
|
||||
- `src/contexts/ItemMasterContext.tsx` - 품목기준관리 Context
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 버전 | 변경 내용 |
|
||||
|------|------|----------|
|
||||
| 2025-11-28 | 1.0 | 초안 작성 |
|
||||
550
src/components/common/DataTable/DataTable.tsx
Normal file
550
src/components/common/DataTable/DataTable.tsx
Normal file
@@ -0,0 +1,550 @@
|
||||
/**
|
||||
* DataTable Component
|
||||
*
|
||||
* 범용 데이터 테이블 컴포넌트
|
||||
* - 검색/필터링
|
||||
* - 정렬
|
||||
* - 페이지네이션
|
||||
* - 행 선택
|
||||
* - 반응형 (데스크톱: 테이블, 모바일: 카드)
|
||||
*
|
||||
* @example
|
||||
* <DataTable
|
||||
* data={items}
|
||||
* columns={columns}
|
||||
* search={{ placeholder: '검색...' }}
|
||||
* pagination={{ pageSize: 20 }}
|
||||
* selection={{ enabled: true }}
|
||||
* rowActions={[
|
||||
* { key: 'view', icon: Search, onClick: handleView },
|
||||
* { key: 'edit', icon: Edit, onClick: handleEdit },
|
||||
* ]}
|
||||
* />
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Pagination } from './Pagination';
|
||||
import { TabFilter } from './TabFilter';
|
||||
import { SearchFilter } from './SearchFilter';
|
||||
import type {
|
||||
DataTableProps,
|
||||
BaseDataItem,
|
||||
SortState,
|
||||
ColumnDef,
|
||||
} from './types';
|
||||
|
||||
export function DataTable<T extends BaseDataItem>({
|
||||
data,
|
||||
columns,
|
||||
loading = false,
|
||||
|
||||
// 검색/필터
|
||||
search,
|
||||
tabFilter,
|
||||
defaultFilterValue = 'all',
|
||||
|
||||
// 선택
|
||||
selection,
|
||||
onSelectionChange,
|
||||
|
||||
// 페이지네이션
|
||||
pagination = { pageSize: 20 },
|
||||
|
||||
// 정렬
|
||||
defaultSort,
|
||||
onSortChange,
|
||||
|
||||
// 액션
|
||||
rowActions = [],
|
||||
bulkActions = [],
|
||||
|
||||
// 스타일
|
||||
striped = false,
|
||||
hoverable = true,
|
||||
|
||||
// 빈 상태
|
||||
emptyState,
|
||||
|
||||
// 기타
|
||||
onRowClick,
|
||||
getRowKey = (row) => row.id,
|
||||
}: DataTableProps<T>) {
|
||||
// 상태
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterValue, setFilterValue] = useState(defaultFilterValue);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(pagination.pageSize);
|
||||
const [sort, setSort] = useState<SortState>(defaultSort || { column: null, direction: null });
|
||||
|
||||
// 검색/필터/정렬 적용된 데이터
|
||||
const processedData = useMemo(() => {
|
||||
let result = [...data];
|
||||
|
||||
// 탭 필터 적용
|
||||
if (tabFilter && filterValue !== 'all') {
|
||||
result = result.filter((item) => {
|
||||
const value = item[tabFilter.key];
|
||||
return value === filterValue;
|
||||
});
|
||||
}
|
||||
|
||||
// 검색 적용
|
||||
if (search && searchTerm) {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const searchFields = search.searchFields || columns.map((c) => c.key);
|
||||
|
||||
result = result.filter((item) =>
|
||||
searchFields.some((field) => {
|
||||
const value = item[field];
|
||||
if (value == null) return false;
|
||||
return String(value).toLowerCase().includes(searchLower);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 정렬 적용
|
||||
if (sort.column && sort.direction) {
|
||||
result.sort((a, b) => {
|
||||
const aVal = a[sort.column!];
|
||||
const bVal = b[sort.column!];
|
||||
|
||||
if (aVal == null && bVal == null) return 0;
|
||||
if (aVal == null) return 1;
|
||||
if (bVal == null) return -1;
|
||||
|
||||
let comparison = 0;
|
||||
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||
comparison = aVal - bVal;
|
||||
} else {
|
||||
comparison = String(aVal).localeCompare(String(bVal));
|
||||
}
|
||||
|
||||
return sort.direction === 'desc' ? -comparison : comparison;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [data, tabFilter, filterValue, search, searchTerm, sort, columns]);
|
||||
|
||||
// 페이지네이션 적용
|
||||
const totalPages = Math.ceil(processedData.length / pageSize);
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const paginatedData = processedData.slice(startIndex, endIndex);
|
||||
|
||||
// 전체 선택 상태
|
||||
const selectAll = useMemo(() => {
|
||||
if (paginatedData.length === 0) return false;
|
||||
return paginatedData.every((row) => selectedIds.has(getRowKey(row)));
|
||||
}, [paginatedData, selectedIds, getRowKey]);
|
||||
|
||||
// 핸들러
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchTerm(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleFilterChange = useCallback((value: string) => {
|
||||
setFilterValue(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleSort = useCallback((column: string) => {
|
||||
setSort((prev) => {
|
||||
let newDirection: SortState['direction'] = 'asc';
|
||||
if (prev.column === column) {
|
||||
if (prev.direction === 'asc') newDirection = 'desc';
|
||||
else if (prev.direction === 'desc') newDirection = null;
|
||||
}
|
||||
|
||||
const newSort: SortState = {
|
||||
column: newDirection ? column : null,
|
||||
direction: newDirection,
|
||||
};
|
||||
|
||||
onSortChange?.(newSort);
|
||||
return newSort;
|
||||
});
|
||||
}, [onSortChange]);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
const newSelected = new Set(selectedIds);
|
||||
if (selectAll) {
|
||||
paginatedData.forEach((row) => newSelected.delete(getRowKey(row)));
|
||||
} else {
|
||||
paginatedData.forEach((row) => newSelected.add(getRowKey(row)));
|
||||
}
|
||||
setSelectedIds(newSelected);
|
||||
onSelectionChange?.(newSelected);
|
||||
}, [selectAll, paginatedData, selectedIds, getRowKey, onSelectionChange]);
|
||||
|
||||
const handleSelectRow = useCallback((row: T) => {
|
||||
const key = getRowKey(row);
|
||||
const newSelected = new Set(selectedIds);
|
||||
|
||||
if (selection?.single) {
|
||||
newSelected.clear();
|
||||
if (!selectedIds.has(key)) {
|
||||
newSelected.add(key);
|
||||
}
|
||||
} else {
|
||||
if (newSelected.has(key)) {
|
||||
newSelected.delete(key);
|
||||
} else {
|
||||
newSelected.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedIds(newSelected);
|
||||
onSelectionChange?.(newSelected);
|
||||
}, [selectedIds, selection, getRowKey, onSelectionChange]);
|
||||
|
||||
const handlePageSizeChange = useCallback((size: number) => {
|
||||
setPageSize(size);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 정렬 아이콘 렌더링
|
||||
const renderSortIcon = (column: ColumnDef<T>) => {
|
||||
if (!column.sortable) return null;
|
||||
|
||||
if (sort.column !== column.key) {
|
||||
return <ArrowUpDown className="ml-1 h-4 w-4 opacity-50" />;
|
||||
}
|
||||
if (sort.direction === 'asc') {
|
||||
return <ArrowUp className="ml-1 h-4 w-4" />;
|
||||
}
|
||||
if (sort.direction === 'desc') {
|
||||
return <ArrowDown className="ml-1 h-4 w-4" />;
|
||||
}
|
||||
return <ArrowUpDown className="ml-1 h-4 w-4 opacity-50" />;
|
||||
};
|
||||
|
||||
// 빈 상태 렌더링
|
||||
const renderEmptyState = () => {
|
||||
const isFiltered = searchTerm || filterValue !== 'all';
|
||||
const title = isFiltered
|
||||
? emptyState?.filteredTitle || '검색 결과가 없습니다.'
|
||||
: emptyState?.title || '데이터가 없습니다.';
|
||||
const description = isFiltered
|
||||
? emptyState?.filteredDescription
|
||||
: emptyState?.description;
|
||||
|
||||
return (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
{emptyState?.icon && (
|
||||
<emptyState.icon className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
)}
|
||||
<p className="text-lg font-medium">{title}</p>
|
||||
{description && <p className="text-sm mt-1">{description}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 선택된 항목들
|
||||
const selectedItems = useMemo(() => {
|
||||
return data.filter((item) => selectedIds.has(getRowKey(item)));
|
||||
}, [data, selectedIds, getRowKey]);
|
||||
|
||||
// 필터 옵션에 카운트 추가
|
||||
const filterOptionsWithCount = useMemo(() => {
|
||||
if (!tabFilter) return [];
|
||||
return tabFilter.options.map((opt) => ({
|
||||
...opt,
|
||||
count: opt.value === 'all'
|
||||
? data.length
|
||||
: data.filter((item) => item[tabFilter.key] === opt.value).length,
|
||||
}));
|
||||
}, [tabFilter, data]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 검색/필터 */}
|
||||
{(search || tabFilter) && (
|
||||
<Card>
|
||||
<CardContent className="p-4 md:p-6">
|
||||
<SearchFilter
|
||||
searchValue={searchTerm}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchConfig={search}
|
||||
filterValue={filterValue}
|
||||
onFilterChange={tabFilter ? handleFilterChange : undefined}
|
||||
filterOptions={filterOptionsWithCount}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 벌크 액션 */}
|
||||
{bulkActions.length > 0 && selectedIds.size > 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-3 flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{selectedIds.size}개 선택됨
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
{bulkActions
|
||||
.filter((action) => !action.minSelected || selectedIds.size >= action.minSelected)
|
||||
.map((action) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
variant={action.variant || 'outline'}
|
||||
size="sm"
|
||||
onClick={() => action.onClick(selectedItems)}
|
||||
>
|
||||
{action.icon && <action.icon className="w-4 h-4 mr-1" />}
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 메인 테이블 카드 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm md:text-base">
|
||||
{tabFilter
|
||||
? `${filterOptionsWithCount.find((o) => o.value === filterValue)?.label || ''} 목록`
|
||||
: '전체 목록'}{' '}
|
||||
({processedData.length}개)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 md:p-6">
|
||||
{/* 탭 필터 */}
|
||||
{tabFilter && (
|
||||
<div className="mb-6">
|
||||
<TabFilter
|
||||
value={filterValue}
|
||||
onChange={handleFilterChange}
|
||||
options={filterOptionsWithCount}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 로딩 상태 */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
<span className="ml-3 text-muted-foreground">로딩 중...</span>
|
||||
</div>
|
||||
) : paginatedData.length === 0 ? (
|
||||
renderEmptyState()
|
||||
) : (
|
||||
<>
|
||||
{/* 모바일 카드 뷰 */}
|
||||
<div className="lg:hidden space-y-3">
|
||||
{paginatedData.map((row, index) => (
|
||||
<div
|
||||
key={getRowKey(row)}
|
||||
className={cn(
|
||||
'border rounded-lg p-4 space-y-3 bg-card transition-colors',
|
||||
hoverable && 'hover:bg-muted/50',
|
||||
onRowClick && 'cursor-pointer'
|
||||
)}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{selection?.enabled && (
|
||||
<Checkbox
|
||||
checked={selectedIds.has(getRowKey(row))}
|
||||
onCheckedChange={() => handleSelectRow(row)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 space-y-2">
|
||||
{columns
|
||||
.filter((col) => col.renderMobile !== null)
|
||||
.map((col) => {
|
||||
const value = row[col.key];
|
||||
const rendered = col.renderMobile
|
||||
? col.renderMobile(value, row, index)
|
||||
: col.render
|
||||
? col.render(value, row, index)
|
||||
: value;
|
||||
if (rendered === null) return null;
|
||||
return (
|
||||
<div key={col.key}>
|
||||
{rendered}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{rowActions.length > 0 && (
|
||||
<div className="flex items-center justify-end gap-1 pt-2 border-t">
|
||||
{rowActions
|
||||
.filter((action) => !action.visible || action.visible(row))
|
||||
.map((action) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
variant={action.variant || 'ghost'}
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick(row);
|
||||
}}
|
||||
title={action.tooltip}
|
||||
className="h-8 px-3"
|
||||
>
|
||||
{action.icon && <action.icon className="h-4 w-4" />}
|
||||
{action.label && (
|
||||
<span className="ml-1 text-xs">{action.label}</span>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 데스크톱 테이블 */}
|
||||
<div className="hidden lg:block rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{selection?.enabled && (
|
||||
<TableHead className="w-[50px]">
|
||||
{!selection.single && (
|
||||
<Checkbox
|
||||
checked={selectAll}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
)}
|
||||
</TableHead>
|
||||
)}
|
||||
{columns
|
||||
.filter((col) => !col.hideOnMobile || !col.hideOnTablet)
|
||||
.map((col) => (
|
||||
<TableHead
|
||||
key={col.key}
|
||||
className={cn(
|
||||
col.width && `w-[${col.width}]`,
|
||||
col.minWidth && `min-w-[${col.minWidth}]`,
|
||||
col.hideOnMobile && 'hidden md:table-cell',
|
||||
col.hideOnTablet && 'hidden lg:table-cell',
|
||||
col.sortable && 'cursor-pointer select-none',
|
||||
col.align === 'center' && 'text-center',
|
||||
col.align === 'right' && 'text-right'
|
||||
)}
|
||||
onClick={() => col.sortable && handleSort(col.key)}
|
||||
>
|
||||
<div className={cn(
|
||||
'flex items-center',
|
||||
col.align === 'center' && 'justify-center',
|
||||
col.align === 'right' && 'justify-end'
|
||||
)}>
|
||||
{col.header}
|
||||
{renderSortIcon(col)}
|
||||
</div>
|
||||
</TableHead>
|
||||
))}
|
||||
{rowActions.length > 0 && (
|
||||
<TableHead className="text-right min-w-[100px]">작업</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedData.map((row, index) => (
|
||||
<TableRow
|
||||
key={getRowKey(row)}
|
||||
className={cn(
|
||||
hoverable && 'hover:bg-muted/50',
|
||||
striped && index % 2 === 1 && 'bg-muted/25',
|
||||
onRowClick && 'cursor-pointer'
|
||||
)}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
>
|
||||
{selection?.enabled && (
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(getRowKey(row))}
|
||||
onCheckedChange={() => handleSelectRow(row)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{columns
|
||||
.filter((col) => !col.hideOnMobile || !col.hideOnTablet)
|
||||
.map((col) => {
|
||||
const value = row[col.key];
|
||||
return (
|
||||
<TableCell
|
||||
key={col.key}
|
||||
className={cn(
|
||||
col.hideOnMobile && 'hidden md:table-cell',
|
||||
col.hideOnTablet && 'hidden lg:table-cell',
|
||||
col.align === 'center' && 'text-center',
|
||||
col.align === 'right' && 'text-right'
|
||||
)}
|
||||
>
|
||||
{col.render ? col.render(value, row, index) : String(value ?? '-')}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
{rowActions.length > 0 && (
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{rowActions
|
||||
.filter((action) => !action.visible || action.visible(row))
|
||||
.map((action) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
variant={action.variant || 'ghost'}
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick(row);
|
||||
}}
|
||||
title={action.tooltip || action.label}
|
||||
>
|
||||
{action.icon && <action.icon className="w-4 h-4" />}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={processedData.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={endIndex}
|
||||
onPageChange={setCurrentPage}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={pagination.showPageSizeSelector ? handlePageSizeChange : undefined}
|
||||
pageSizeOptions={pagination.pageSizeOptions}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DataTable;
|
||||
143
src/components/common/DataTable/Pagination.tsx
Normal file
143
src/components/common/DataTable/Pagination.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Pagination Component
|
||||
*
|
||||
* 테이블 페이지네이션 공통 컴포넌트
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { PaginationProps } from './types';
|
||||
|
||||
export function Pagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
startIndex,
|
||||
endIndex,
|
||||
onPageChange,
|
||||
pageSize,
|
||||
onPageSizeChange,
|
||||
pageSizeOptions,
|
||||
}: PaginationProps) {
|
||||
if (totalItems === 0) return null;
|
||||
|
||||
// 표시할 페이지 번호 계산
|
||||
const getVisiblePages = () => {
|
||||
const pages: (number | 'ellipsis')[] = [];
|
||||
const maxVisible = 5;
|
||||
|
||||
if (totalPages <= maxVisible + 2) {
|
||||
// 전체 페이지가 적으면 모두 표시
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
// 항상 첫 페이지
|
||||
pages.push(1);
|
||||
|
||||
// 현재 페이지 주변
|
||||
const start = Math.max(2, currentPage - 1);
|
||||
const end = Math.min(totalPages - 1, currentPage + 1);
|
||||
|
||||
if (start > 2) {
|
||||
pages.push('ellipsis');
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
if (end < totalPages - 1) {
|
||||
pages.push('ellipsis');
|
||||
}
|
||||
|
||||
// 항상 마지막 페이지
|
||||
pages.push(totalPages);
|
||||
}
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
전체 {totalItems}개 중 {startIndex + 1}-{Math.min(endIndex, totalItems)}개 표시
|
||||
</span>
|
||||
{pageSizeOptions && onPageSizeChange && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">페이지당</span>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(value) => onPageSizeChange(Number(value))}
|
||||
>
|
||||
<SelectTrigger className="w-[70px] h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pageSizeOptions.map((size) => (
|
||||
<SelectItem key={size} value={String(size)}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">이전</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{getVisiblePages().map((page, index) => {
|
||||
if (page === 'ellipsis') {
|
||||
return (
|
||||
<span key={`ellipsis-${index}`} className="px-2 text-muted-foreground">
|
||||
...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
variant={currentPage === page ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page)}
|
||||
className="w-8 h-8 p-0"
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<span className="hidden sm:inline">다음</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Pagination;
|
||||
58
src/components/common/DataTable/SearchFilter.tsx
Normal file
58
src/components/common/DataTable/SearchFilter.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* SearchFilter Component
|
||||
*
|
||||
* 검색 입력과 드롭다운 필터를 제공하는 공통 컴포넌트
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Search } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { SearchFilterProps } from './types';
|
||||
|
||||
export function SearchFilter({
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
searchConfig,
|
||||
filterValue,
|
||||
onFilterChange,
|
||||
filterOptions,
|
||||
}: SearchFilterProps) {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={searchConfig?.placeholder || '검색...'}
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
{filterOptions && filterOptions.length > 0 && onFilterChange && (
|
||||
<Select value={filterValue} onValueChange={onFilterChange}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="필터" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filterOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{option.count !== undefined && ` (${option.count})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchFilter;
|
||||
46
src/components/common/DataTable/StatCards.tsx
Normal file
46
src/components/common/DataTable/StatCards.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* StatCards Component
|
||||
*
|
||||
* 통계 카드 그리드 컴포넌트
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import type { StatCardsProps } from './types';
|
||||
|
||||
export function StatCards({ stats, onStatClick }: StatCardsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{stats.map((stat, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
className={onStatClick && stat.filterValue ? 'cursor-pointer hover:shadow-md transition-shadow' : ''}
|
||||
onClick={() => {
|
||||
if (onStatClick && stat.filterValue) {
|
||||
onStatClick(stat.filterValue);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent className="p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{stat.label}
|
||||
</p>
|
||||
<p className="text-3xl md:text-4xl font-bold mt-2">{stat.value}</p>
|
||||
</div>
|
||||
{stat.icon && (
|
||||
<stat.icon
|
||||
className={`w-10 h-10 md:w-12 md:h-12 opacity-15 ${stat.iconColor || ''}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StatCards;
|
||||
31
src/components/common/DataTable/TabFilter.tsx
Normal file
31
src/components/common/DataTable/TabFilter.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* TabFilter Component
|
||||
*
|
||||
* 탭 형태의 필터 컴포넌트
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import type { TabFilterProps } from './types';
|
||||
|
||||
export function TabFilter({ value, onChange, options }: TabFilterProps) {
|
||||
return (
|
||||
<Tabs value={value} onValueChange={onChange} className="w-full">
|
||||
<div className="overflow-x-auto -mx-2 px-2">
|
||||
<TabsList className="inline-flex w-auto min-w-full md:grid md:w-full md:max-w-2xl" style={{
|
||||
gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))`
|
||||
}}>
|
||||
{options.map((option) => (
|
||||
<TabsTrigger key={option.value} value={option.value} className="whitespace-nowrap">
|
||||
{option.label}
|
||||
{option.count !== undefined && ` (${option.count})`}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
export default TabFilter;
|
||||
14
src/components/common/DataTable/index.ts
Normal file
14
src/components/common/DataTable/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* DataTable 공통 컴포넌트 모듈
|
||||
*
|
||||
* 모든 목록 페이지에서 재사용 가능한 테이블 컴포넌트
|
||||
*/
|
||||
|
||||
export { DataTable } from './DataTable';
|
||||
export { SearchFilter } from './SearchFilter';
|
||||
export { Pagination } from './Pagination';
|
||||
export { TabFilter } from './TabFilter';
|
||||
export { StatCards } from './StatCards';
|
||||
export * from './types';
|
||||
|
||||
export default DataTable;
|
||||
248
src/components/common/DataTable/types.ts
Normal file
248
src/components/common/DataTable/types.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* DataTable 공통 컴포넌트 타입 정의
|
||||
*
|
||||
* 모든 목록 페이지에서 재사용 가능한 테이블 컴포넌트
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
// ===== 기본 타입 =====
|
||||
|
||||
/** 데이터 항목의 기본 인터페이스 */
|
||||
export interface BaseDataItem {
|
||||
id: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** 컬럼 정렬 방향 */
|
||||
export type SortDirection = 'asc' | 'desc' | null;
|
||||
|
||||
/** 컬럼 정렬 상태 */
|
||||
export interface SortState {
|
||||
column: string | null;
|
||||
direction: SortDirection;
|
||||
}
|
||||
|
||||
// ===== 컬럼 정의 =====
|
||||
|
||||
/** 컬럼 정의 인터페이스 */
|
||||
export interface ColumnDef<T extends BaseDataItem> {
|
||||
/** 컬럼 키 (데이터 필드명) */
|
||||
key: string;
|
||||
/** 컬럼 헤더 텍스트 */
|
||||
header: string;
|
||||
/** 컬럼 너비 (CSS 값) */
|
||||
width?: string;
|
||||
/** 최소 너비 */
|
||||
minWidth?: string;
|
||||
/** 정렬 가능 여부 */
|
||||
sortable?: boolean;
|
||||
/** 모바일에서 숨김 여부 */
|
||||
hideOnMobile?: boolean;
|
||||
/** 태블릿에서 숨김 여부 */
|
||||
hideOnTablet?: boolean;
|
||||
/** 텍스트 정렬 */
|
||||
align?: 'left' | 'center' | 'right';
|
||||
/** 셀 렌더링 함수 */
|
||||
render?: (value: unknown, row: T, index: number) => ReactNode;
|
||||
/** 모바일 카드에서의 렌더링 (null이면 표시 안함) */
|
||||
renderMobile?: (value: unknown, row: T, index: number) => ReactNode | null;
|
||||
}
|
||||
|
||||
// ===== 검색/필터 =====
|
||||
|
||||
/** 필터 옵션 */
|
||||
export interface FilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/** 탭 필터 설정 */
|
||||
export interface TabFilter {
|
||||
key: string;
|
||||
options: FilterOption[];
|
||||
}
|
||||
|
||||
/** 검색 설정 */
|
||||
export interface SearchConfig {
|
||||
placeholder?: string;
|
||||
/** 검색 대상 필드 키 배열 */
|
||||
searchFields?: string[];
|
||||
}
|
||||
|
||||
// ===== 선택 =====
|
||||
|
||||
/** 선택 설정 */
|
||||
export interface SelectionConfig {
|
||||
/** 선택 기능 활성화 */
|
||||
enabled: boolean;
|
||||
/** 단일 선택 모드 */
|
||||
single?: boolean;
|
||||
}
|
||||
|
||||
// ===== 페이지네이션 =====
|
||||
|
||||
/** 페이지네이션 설정 */
|
||||
export interface PaginationConfig {
|
||||
/** 페이지당 항목 수 */
|
||||
pageSize: number;
|
||||
/** 페이지 사이즈 옵션 */
|
||||
pageSizeOptions?: number[];
|
||||
/** 페이지 사이즈 변경 가능 여부 */
|
||||
showPageSizeSelector?: boolean;
|
||||
}
|
||||
|
||||
// ===== 액션 버튼 =====
|
||||
|
||||
/** 행 액션 정의 */
|
||||
export interface RowAction<T extends BaseDataItem> {
|
||||
key: string;
|
||||
icon?: LucideIcon;
|
||||
label?: string;
|
||||
tooltip?: string;
|
||||
variant?: 'default' | 'ghost' | 'outline' | 'destructive';
|
||||
/** 표시 조건 */
|
||||
visible?: (row: T) => boolean;
|
||||
/** 클릭 핸들러 */
|
||||
onClick: (row: T) => void;
|
||||
}
|
||||
|
||||
/** 벌크 액션 정의 (선택된 항목에 대한 액션) */
|
||||
export interface BulkAction<T extends BaseDataItem> {
|
||||
key: string;
|
||||
icon?: LucideIcon;
|
||||
label: string;
|
||||
variant?: 'default' | 'outline' | 'destructive';
|
||||
/** 활성화 조건 (선택된 항목 수) */
|
||||
minSelected?: number;
|
||||
/** 클릭 핸들러 */
|
||||
onClick: (selectedItems: T[]) => void;
|
||||
}
|
||||
|
||||
// ===== 통계 카드 =====
|
||||
|
||||
/** 통계 카드 항목 */
|
||||
export interface StatItem {
|
||||
label: string;
|
||||
value: number | string;
|
||||
icon?: LucideIcon;
|
||||
iconColor?: string;
|
||||
/** 클릭 시 해당 필터로 이동 */
|
||||
filterValue?: string;
|
||||
}
|
||||
|
||||
// ===== 빈 상태 =====
|
||||
|
||||
/** 빈 상태 설정 */
|
||||
export interface EmptyStateConfig {
|
||||
/** 빈 상태 아이콘 */
|
||||
icon?: LucideIcon;
|
||||
/** 빈 상태 제목 */
|
||||
title?: string;
|
||||
/** 빈 상태 설명 */
|
||||
description?: string;
|
||||
/** 필터 적용 시 빈 상태 제목 */
|
||||
filteredTitle?: string;
|
||||
/** 필터 적용 시 빈 상태 설명 */
|
||||
filteredDescription?: string;
|
||||
}
|
||||
|
||||
// ===== 메인 Props =====
|
||||
|
||||
/** DataTable 메인 Props */
|
||||
export interface DataTableProps<T extends BaseDataItem> {
|
||||
/** 데이터 배열 */
|
||||
data: T[];
|
||||
/** 컬럼 정의 */
|
||||
columns: ColumnDef<T>[];
|
||||
/** 로딩 상태 */
|
||||
loading?: boolean;
|
||||
|
||||
// === 검색/필터 ===
|
||||
/** 검색 설정 */
|
||||
search?: SearchConfig;
|
||||
/** 탭 필터 설정 */
|
||||
tabFilter?: TabFilter;
|
||||
/** 기본 필터 값 */
|
||||
defaultFilterValue?: string;
|
||||
|
||||
// === 선택 ===
|
||||
/** 선택 설정 */
|
||||
selection?: SelectionConfig;
|
||||
/** 선택 변경 핸들러 */
|
||||
onSelectionChange?: (selectedIds: Set<string>) => void;
|
||||
|
||||
// === 페이지네이션 ===
|
||||
/** 페이지네이션 설정 */
|
||||
pagination?: PaginationConfig;
|
||||
|
||||
// === 정렬 ===
|
||||
/** 기본 정렬 */
|
||||
defaultSort?: SortState;
|
||||
/** 정렬 변경 핸들러 (서버 사이드 정렬용) */
|
||||
onSortChange?: (sort: SortState) => void;
|
||||
|
||||
// === 액션 ===
|
||||
/** 행 액션 */
|
||||
rowActions?: RowAction<T>[];
|
||||
/** 벌크 액션 */
|
||||
bulkActions?: BulkAction<T>[];
|
||||
|
||||
// === 스타일 ===
|
||||
/** 테이블 최소 높이 */
|
||||
minHeight?: string;
|
||||
/** 스트라이프 스타일 */
|
||||
striped?: boolean;
|
||||
/** 호버 스타일 */
|
||||
hoverable?: boolean;
|
||||
|
||||
// === 빈 상태 ===
|
||||
/** 빈 상태 설정 */
|
||||
emptyState?: EmptyStateConfig;
|
||||
|
||||
// === 기타 ===
|
||||
/** 행 클릭 핸들러 */
|
||||
onRowClick?: (row: T) => void;
|
||||
/** 행 키 추출 함수 (기본: row.id) */
|
||||
getRowKey?: (row: T) => string;
|
||||
}
|
||||
|
||||
// ===== 서브 컴포넌트 Props =====
|
||||
|
||||
/** SearchFilter Props */
|
||||
export interface SearchFilterProps {
|
||||
searchValue: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
searchConfig?: SearchConfig;
|
||||
filterValue?: string;
|
||||
onFilterChange?: (value: string) => void;
|
||||
filterOptions?: FilterOption[];
|
||||
}
|
||||
|
||||
/** Pagination Props */
|
||||
export interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
onPageChange: (page: number) => void;
|
||||
pageSize?: number;
|
||||
onPageSizeChange?: (size: number) => void;
|
||||
pageSizeOptions?: number[];
|
||||
}
|
||||
|
||||
/** TabFilter Props */
|
||||
export interface TabFilterProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: FilterOption[];
|
||||
}
|
||||
|
||||
/** StatCards Props */
|
||||
export interface StatCardsProps {
|
||||
stats: StatItem[];
|
||||
onStatClick?: (filterValue: string) => void;
|
||||
}
|
||||
63
src/components/items/DynamicItemForm/DynamicField.tsx
Normal file
63
src/components/items/DynamicItemForm/DynamicField.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* DynamicField Component
|
||||
*
|
||||
* 필드 타입에 따라 적절한 필드 컴포넌트를 렌더링
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import type { DynamicFieldProps, FieldType } from './types';
|
||||
import {
|
||||
TextField,
|
||||
DropdownField,
|
||||
NumberField,
|
||||
DateField,
|
||||
CheckboxField,
|
||||
FileField,
|
||||
CustomField,
|
||||
} from './fields';
|
||||
|
||||
/**
|
||||
* 필드 타입 → 컴포넌트 매핑
|
||||
*/
|
||||
const FIELD_COMPONENTS: Record<
|
||||
FieldType,
|
||||
React.ComponentType<DynamicFieldProps>
|
||||
> = {
|
||||
textbox: TextField,
|
||||
textarea: TextField,
|
||||
dropdown: DropdownField,
|
||||
'searchable-dropdown': DropdownField,
|
||||
number: NumberField,
|
||||
currency: NumberField,
|
||||
date: DateField,
|
||||
'date-range': DateField,
|
||||
checkbox: CheckboxField,
|
||||
switch: CheckboxField,
|
||||
file: FileField,
|
||||
'custom:drawing-canvas': CustomField,
|
||||
'custom:bending-detail-table': CustomField,
|
||||
'custom:bom-table': CustomField,
|
||||
};
|
||||
|
||||
export function DynamicField(props: DynamicFieldProps) {
|
||||
const { field } = props;
|
||||
|
||||
// 필드 타입에 맞는 컴포넌트 선택
|
||||
const FieldComponent = FIELD_COMPONENTS[field.field_type];
|
||||
|
||||
if (!FieldComponent) {
|
||||
console.warn(`[DynamicField] Unknown field type: ${field.field_type}`);
|
||||
return (
|
||||
<div className="p-4 border border-red-200 bg-red-50 rounded-md">
|
||||
<p className="text-sm text-red-600">
|
||||
알 수 없는 필드 타입: {field.field_type}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <FieldComponent {...props} />;
|
||||
}
|
||||
|
||||
export default DynamicField;
|
||||
75
src/components/items/DynamicItemForm/DynamicFormRenderer.tsx
Normal file
75
src/components/items/DynamicItemForm/DynamicFormRenderer.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* DynamicFormRenderer Component
|
||||
*
|
||||
* 전체 폼 구조를 렌더링하는 메인 렌더러
|
||||
* - 섹션 순서대로 렌더링
|
||||
* - 조건부 섹션/필드 처리
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { DynamicSection } from './DynamicSection';
|
||||
import { useConditionalFields } from './hooks/useConditionalFields';
|
||||
import type { DynamicFormRendererProps, DynamicSection as DynamicSectionType } from './types';
|
||||
|
||||
export function DynamicFormRenderer({
|
||||
sections,
|
||||
conditionalSections,
|
||||
conditionalFields,
|
||||
values,
|
||||
errors,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled,
|
||||
}: DynamicFormRendererProps) {
|
||||
// 조건부 표시 로직
|
||||
const { isSectionVisible, isFieldVisible } = useConditionalFields({
|
||||
sections,
|
||||
conditionalSections,
|
||||
conditionalFields,
|
||||
values,
|
||||
});
|
||||
|
||||
// 섹션 순서대로 정렬
|
||||
const sortedSections = [...sections].sort((a, b) => a.order_no - b.order_no);
|
||||
|
||||
// 표시할 섹션만 필터링
|
||||
const visibleSections = sortedSections.filter((section) =>
|
||||
isSectionVisible(section.id)
|
||||
);
|
||||
|
||||
// 각 섹션의 표시할 필드만 필터링
|
||||
const sectionsWithVisibleFields: DynamicSectionType[] = visibleSections.map((section) => ({
|
||||
...section,
|
||||
fields: section.fields.filter((field) =>
|
||||
isFieldVisible(section.id, field.id)
|
||||
),
|
||||
}));
|
||||
|
||||
if (sectionsWithVisibleFields.length === 0) {
|
||||
return (
|
||||
<div className="p-8 text-center text-gray-500 border-2 border-dashed rounded-lg">
|
||||
<p>표시할 섹션이 없습니다.</p>
|
||||
<p className="text-sm mt-1">품목 유형을 선택하거나 필수 필드를 입력해주세요.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{sectionsWithVisibleFields.map((section) => (
|
||||
<DynamicSection
|
||||
key={section.id}
|
||||
section={section}
|
||||
values={values}
|
||||
errors={errors}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DynamicFormRenderer;
|
||||
162
src/components/items/DynamicItemForm/DynamicSection.tsx
Normal file
162
src/components/items/DynamicItemForm/DynamicSection.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* DynamicSection Component
|
||||
*
|
||||
* 동적 섹션 렌더링 (Card + 접기/펼치기 + 필드 그리드)
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DynamicField } from './DynamicField';
|
||||
import type { DynamicSectionProps, DynamicField as DynamicFieldType } from './types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* 필드를 그리드 레이아웃으로 정렬
|
||||
*/
|
||||
function organizeFieldsIntoGrid(fields: DynamicFieldType[]): DynamicFieldType[][] {
|
||||
if (fields.length === 0) return [];
|
||||
|
||||
// 정렬: order_no → grid_row → grid_col
|
||||
const sortedFields = [...fields].sort((a, b) => {
|
||||
if (a.order_no !== b.order_no) return a.order_no - b.order_no;
|
||||
if ((a.grid_row || 1) !== (b.grid_row || 1)) return (a.grid_row || 1) - (b.grid_row || 1);
|
||||
return (a.grid_col || 1) - (b.grid_col || 1);
|
||||
});
|
||||
|
||||
// 행별로 그룹화
|
||||
const rows: Map<number, DynamicFieldType[]> = new Map();
|
||||
|
||||
for (const field of sortedFields) {
|
||||
const row = field.grid_row || 1;
|
||||
if (!rows.has(row)) {
|
||||
rows.set(row, []);
|
||||
}
|
||||
rows.get(row)!.push(field);
|
||||
}
|
||||
|
||||
// 배열로 변환 (행 번호 순서대로)
|
||||
return Array.from(rows.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([, fields]) => fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* grid_span을 Tailwind 클래스로 변환
|
||||
*/
|
||||
function getGridSpanClass(span: number = 1): string {
|
||||
const spanClasses: Record<number, string> = {
|
||||
1: 'col-span-1',
|
||||
2: 'col-span-2',
|
||||
3: 'col-span-3',
|
||||
4: 'col-span-4',
|
||||
};
|
||||
return spanClasses[span] || 'col-span-1';
|
||||
}
|
||||
|
||||
export function DynamicSection({
|
||||
section,
|
||||
values,
|
||||
errors,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled,
|
||||
}: DynamicSectionProps) {
|
||||
const [isOpen, setIsOpen] = useState(section.is_default_open);
|
||||
|
||||
// BOM 섹션은 별도 처리
|
||||
const isBomSection = section.section_type === 'BOM';
|
||||
|
||||
// 필드를 그리드로 정렬
|
||||
const fieldRows = organizeFieldsIntoGrid(section.fields);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (section.is_collapsible) {
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="mb-4">
|
||||
<CardHeader
|
||||
className={cn(
|
||||
'py-4',
|
||||
section.is_collapsible && 'cursor-pointer hover:bg-gray-50 transition-colors'
|
||||
)}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
{section.is_collapsible && (
|
||||
<span className="text-gray-400">
|
||||
{isOpen ? (
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
) : (
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{section.title}
|
||||
</CardTitle>
|
||||
|
||||
{section.description && (
|
||||
<p className="text-sm text-muted-foreground">{section.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{(isOpen || !section.is_collapsible) && (
|
||||
<CardContent className="pt-0">
|
||||
{isBomSection ? (
|
||||
// BOM 섹션: 별도 컴포넌트로 처리
|
||||
<div className="p-4 border-2 border-dashed border-gray-300 rounded-lg text-center bg-gray-50">
|
||||
<p className="text-gray-500 font-medium">부품 구성 (BOM)</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
기존 BOMSection 컴포넌트 통합 예정
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
// 일반 섹션: 필드 그리드 렌더링
|
||||
<div className="space-y-4">
|
||||
{fieldRows.map((rowFields, rowIndex) => (
|
||||
<div
|
||||
key={`row-${rowIndex}`}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"
|
||||
>
|
||||
{rowFields.map((field) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className={cn(
|
||||
getGridSpanClass(field.grid_span),
|
||||
// 모바일에서는 항상 전체 너비
|
||||
'col-span-1 md:col-span-1',
|
||||
// md 이상에서 grid_span 적용
|
||||
field.grid_span && field.grid_span >= 2 && 'md:col-span-2',
|
||||
field.grid_span && field.grid_span >= 3 && 'lg:col-span-3',
|
||||
field.grid_span && field.grid_span >= 4 && 'lg:col-span-4'
|
||||
)}
|
||||
>
|
||||
<DynamicField
|
||||
field={field}
|
||||
value={values[field.field_key]}
|
||||
error={errors[field.field_key]}
|
||||
onChange={(value) => onChange(field.field_key, value)}
|
||||
onBlur={() => onBlur(field.field_key)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default DynamicSection;
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* CheckboxField Component
|
||||
*
|
||||
* 체크박스/스위치 필드 (checkbox, switch)
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import type { DynamicFieldProps } from '../types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function CheckboxField({
|
||||
field,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled,
|
||||
}: DynamicFieldProps) {
|
||||
const isSwitch = field.field_type === 'switch';
|
||||
const checked = value === true || value === 'true' || value === 1;
|
||||
|
||||
const handleChange = (newChecked: boolean) => {
|
||||
onChange(newChecked);
|
||||
onBlur();
|
||||
};
|
||||
|
||||
if (isSwitch) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor={field.field_key}
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500"
|
||||
)}
|
||||
>
|
||||
{field.field_name}
|
||||
</Label>
|
||||
<Switch
|
||||
id={field.field_key}
|
||||
checked={checked}
|
||||
onCheckedChange={handleChange}
|
||||
disabled={disabled || field.is_readonly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{field.help_text && !error && (
|
||||
<p className="text-xs text-muted-foreground">{field.help_text}</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={field.field_key}
|
||||
checked={checked}
|
||||
onCheckedChange={handleChange}
|
||||
disabled={disabled || field.is_readonly}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={field.field_key}
|
||||
className={cn(
|
||||
'text-sm font-medium cursor-pointer',
|
||||
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500",
|
||||
(disabled || field.is_readonly) && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
{field.field_name}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{field.help_text && !error && (
|
||||
<p className="text-xs text-muted-foreground ml-6">{field.help_text}</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500 ml-6">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CheckboxField;
|
||||
479
src/components/items/DynamicItemForm/fields/CustomField.tsx
Normal file
479
src/components/items/DynamicItemForm/fields/CustomField.tsx
Normal file
@@ -0,0 +1,479 @@
|
||||
/**
|
||||
* CustomField Component
|
||||
*
|
||||
* 특수 필드 컴포넌트 래퍼 (전개도, BOM 등)
|
||||
* - custom:drawing-canvas → DrawingCanvas
|
||||
* - custom:bending-detail-table → BendingDetailTable (전개도 상세 테이블)
|
||||
* - custom:bom-table → BOMSection
|
||||
*
|
||||
* 기존 ItemForm의 특수 컴포넌트를 재사용하면서 동적 폼과 통합
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { FileImage, Plus, Trash2 } from 'lucide-react';
|
||||
import type { DynamicFieldProps, FormValue } from '../types';
|
||||
import type { BendingDetail, BOMLine } from '@/types/item';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ===== BOM 테이블 컴포넌트 =====
|
||||
|
||||
interface BOMTableProps {
|
||||
value: BOMLine[];
|
||||
onChange: (lines: BOMLine[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function BOMTable({ value, onChange, disabled }: BOMTableProps) {
|
||||
const bomLines = Array.isArray(value) ? value : [];
|
||||
|
||||
const addLine = () => {
|
||||
const newLine: BOMLine = {
|
||||
id: `bom-${Date.now()}`,
|
||||
childItemCode: '',
|
||||
childItemName: '',
|
||||
quantity: 1,
|
||||
unit: 'EA',
|
||||
};
|
||||
onChange([...bomLines, newLine]);
|
||||
};
|
||||
|
||||
const updateLine = (index: number, field: keyof BOMLine, fieldValue: string | number) => {
|
||||
const updated = [...bomLines];
|
||||
updated[index] = { ...updated[index], [field]: fieldValue };
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const removeLine = (index: number) => {
|
||||
onChange(bomLines.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm">부품 구성 (BOM)</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addLine}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{bomLines.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground border-2 border-dashed rounded-lg">
|
||||
<p>등록된 BOM 항목이 없습니다.</p>
|
||||
<p className="text-sm mt-1">위 버튼을 클릭하여 품목을 추가하세요.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-12 gap-2 text-xs font-medium text-muted-foreground px-2">
|
||||
<div className="col-span-3">품목코드</div>
|
||||
<div className="col-span-4">품목명</div>
|
||||
<div className="col-span-2">수량</div>
|
||||
<div className="col-span-2">단위</div>
|
||||
<div className="col-span-1"></div>
|
||||
</div>
|
||||
{bomLines.map((line, index) => (
|
||||
<div key={line.id} className="grid grid-cols-12 gap-2 items-center">
|
||||
<Input
|
||||
className="col-span-3 h-8 text-sm"
|
||||
placeholder="품목코드"
|
||||
value={line.childItemCode}
|
||||
onChange={(e) => updateLine(index, 'childItemCode', e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
className="col-span-4 h-8 text-sm"
|
||||
placeholder="품목명"
|
||||
value={line.childItemName}
|
||||
onChange={(e) => updateLine(index, 'childItemName', e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
className="col-span-2 h-8 text-sm text-right"
|
||||
type="number"
|
||||
min={1}
|
||||
value={line.quantity}
|
||||
onChange={(e) => updateLine(index, 'quantity', parseInt(e.target.value) || 1)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
className="col-span-2 h-8 text-sm"
|
||||
value={line.unit}
|
||||
onChange={(e) => updateLine(index, 'unit', e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="col-span-1 h-8 w-8 p-0"
|
||||
onClick={() => removeLine(index)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 전개도 상세 테이블 컴포넌트 =====
|
||||
|
||||
interface BendingDetailTableProps {
|
||||
value: BendingDetail[];
|
||||
onChange: (details: BendingDetail[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function BendingDetailTable({ value, onChange, disabled }: BendingDetailTableProps) {
|
||||
const details = Array.isArray(value) ? value : [];
|
||||
|
||||
// 폭 합계 계산
|
||||
const totalWidth = details.reduce((acc, d) => acc + d.input + d.elongation, 0);
|
||||
|
||||
const addRow = () => {
|
||||
const newRow: BendingDetail = {
|
||||
id: `bend-${Date.now()}`,
|
||||
no: details.length + 1,
|
||||
input: 0,
|
||||
elongation: -1,
|
||||
calculated: 0,
|
||||
sum: 0,
|
||||
shaded: false,
|
||||
};
|
||||
onChange([...details, newRow]);
|
||||
};
|
||||
|
||||
const updateRow = (index: number, field: keyof BendingDetail, fieldValue: number | boolean) => {
|
||||
const updated = [...details];
|
||||
updated[index] = { ...updated[index], [field]: fieldValue };
|
||||
|
||||
// calculated와 sum 자동 계산
|
||||
if (field === 'input' || field === 'elongation') {
|
||||
const input = field === 'input' ? (fieldValue as number) : updated[index].input;
|
||||
const elongation = field === 'elongation' ? (fieldValue as number) : updated[index].elongation;
|
||||
updated[index].calculated = input + elongation;
|
||||
|
||||
// 누적 합계 재계산
|
||||
let runningSum = 0;
|
||||
for (let i = 0; i <= index; i++) {
|
||||
runningSum += updated[i].input + updated[i].elongation;
|
||||
updated[i].sum = runningSum;
|
||||
}
|
||||
}
|
||||
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const removeRow = (index: number) => {
|
||||
const updated = details.filter((_, i) => i !== index);
|
||||
// 번호 재정렬
|
||||
updated.forEach((row, i) => {
|
||||
row.no = i + 1;
|
||||
});
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<FileImage className="h-4 w-4" />
|
||||
전개도 상세 입력
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
폭 합계: <strong className="text-foreground">{totalWidth.toFixed(1)} mm</strong>
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addRow}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
행 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{details.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground border-2 border-dashed rounded-lg">
|
||||
<p>전개도 상세 데이터가 없습니다.</p>
|
||||
<p className="text-sm mt-1">위 버튼을 클릭하여 행을 추가하세요.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-12 gap-2 text-xs font-medium text-muted-foreground px-2">
|
||||
<div className="col-span-1">No</div>
|
||||
<div className="col-span-2">입력값</div>
|
||||
<div className="col-span-2">연신율</div>
|
||||
<div className="col-span-2">계산값</div>
|
||||
<div className="col-span-2">합계</div>
|
||||
<div className="col-span-2">음영</div>
|
||||
<div className="col-span-1"></div>
|
||||
</div>
|
||||
{details.map((row, index) => (
|
||||
<div key={row.id} className="grid grid-cols-12 gap-2 items-center">
|
||||
<div className="col-span-1 text-sm text-center text-muted-foreground">
|
||||
{row.no}
|
||||
</div>
|
||||
<Input
|
||||
className="col-span-2 h-8 text-sm text-right"
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={row.input}
|
||||
onChange={(e) => updateRow(index, 'input', parseFloat(e.target.value) || 0)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
className="col-span-2 h-8 text-sm text-right"
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={row.elongation}
|
||||
onChange={(e) => updateRow(index, 'elongation', parseFloat(e.target.value) || 0)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="col-span-2 text-sm text-right pr-3 text-muted-foreground">
|
||||
{(row.input + row.elongation).toFixed(1)}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-right pr-3 font-medium">
|
||||
{row.sum.toFixed(1)}
|
||||
</div>
|
||||
<div className="col-span-2 flex justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={row.shaded}
|
||||
onChange={(e) => updateRow(index, 'shaded', e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="col-span-1 h-8 w-8 p-0"
|
||||
onClick={() => removeRow(index)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 전개도 캔버스 (간단 버전) =====
|
||||
|
||||
interface DrawingCanvasProps {
|
||||
value: string | null;
|
||||
onChange: (dataUrl: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function DrawingCanvasSimple({ value, onChange, disabled }: DrawingCanvasProps) {
|
||||
const [inputMethod, setInputMethod] = useState<'file' | 'drawing'>('file');
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
onChange(event.target?.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onChange(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="py-3">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<FileImage className="h-4 w-4" />
|
||||
전개도 이미지
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 입력 방식 선택 */}
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="drawingMethod"
|
||||
checked={inputMethod === 'file'}
|
||||
onChange={() => setInputMethod('file')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className="text-sm">파일 업로드</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="drawingMethod"
|
||||
checked={inputMethod === 'drawing'}
|
||||
onChange={() => setInputMethod('drawing')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className="text-sm">직접 그리기 (준비 중)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 파일 업로드 */}
|
||||
{inputMethod === 'file' && (
|
||||
<div>
|
||||
{value ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={value}
|
||||
alt="전개도"
|
||||
className="max-w-full h-auto border rounded-lg"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2"
|
||||
onClick={handleClear}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-2 border-dashed rounded-lg p-8 text-center">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
disabled={disabled}
|
||||
className="hidden"
|
||||
id="drawing-file-input"
|
||||
/>
|
||||
<label
|
||||
htmlFor="drawing-file-input"
|
||||
className="cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<FileImage className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>클릭하여 이미지를 업로드하세요</p>
|
||||
<p className="text-xs mt-1">PNG, JPG, GIF 지원</p>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 직접 그리기 (플레이스홀더) */}
|
||||
{inputMethod === 'drawing' && (
|
||||
<div className="border-2 border-dashed rounded-lg p-8 text-center text-muted-foreground">
|
||||
<p>직접 그리기 기능은 준비 중입니다.</p>
|
||||
<p className="text-sm mt-1">기존 DrawingCanvas 컴포넌트와 통합 예정</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 메인 CustomField 컴포넌트 =====
|
||||
|
||||
export function CustomField({
|
||||
field,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled,
|
||||
}: DynamicFieldProps) {
|
||||
const renderCustomComponent = () => {
|
||||
switch (field.field_type) {
|
||||
case 'custom:drawing-canvas':
|
||||
return (
|
||||
<DrawingCanvasSimple
|
||||
value={value as string | null}
|
||||
onChange={(dataUrl) => {
|
||||
onChange(dataUrl);
|
||||
onBlur();
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'custom:bending-detail-table':
|
||||
return (
|
||||
<BendingDetailTable
|
||||
value={value as BendingDetail[] || []}
|
||||
onChange={(details) => {
|
||||
onChange(details as unknown as FormValue);
|
||||
onBlur();
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'custom:bom-table':
|
||||
return (
|
||||
<BOMTable
|
||||
value={value as BOMLine[] || []}
|
||||
onChange={(lines) => {
|
||||
onChange(lines as unknown as FormValue);
|
||||
onBlur();
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="p-4 border border-orange-200 bg-orange-50 rounded-md">
|
||||
<p className="text-sm text-orange-600">
|
||||
알 수 없는 커스텀 필드 타입: {field.field_type}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 커스텀 필드는 자체 레이블이 있으므로 별도 레이블 불필요
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{renderCustomComponent()}
|
||||
|
||||
{field.help_text && !error && (
|
||||
<p className="text-xs text-muted-foreground">{field.help_text}</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomField;
|
||||
100
src/components/items/DynamicItemForm/fields/DateField.tsx
Normal file
100
src/components/items/DynamicItemForm/fields/DateField.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* DateField Component
|
||||
*
|
||||
* 날짜 선택 필드 (date, date-range)
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { ko } from 'date-fns/locale';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import type { DynamicFieldProps } from '../types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function DateField({
|
||||
field,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled,
|
||||
}: DynamicFieldProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// 값을 Date 객체로 변환
|
||||
const dateValue = value ? new Date(value as string) : undefined;
|
||||
const isValidDate = dateValue && !isNaN(dateValue.getTime());
|
||||
|
||||
const handleSelect = (date: Date | undefined) => {
|
||||
if (date) {
|
||||
// ISO 문자열로 변환 (YYYY-MM-DD)
|
||||
onChange(format(date, 'yyyy-MM-dd'));
|
||||
} else {
|
||||
onChange(null);
|
||||
}
|
||||
setOpen(false);
|
||||
onBlur();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor={field.field_key}
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500"
|
||||
)}
|
||||
>
|
||||
{field.field_name}
|
||||
</Label>
|
||||
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={field.field_key}
|
||||
variant="outline"
|
||||
disabled={disabled || field.is_readonly}
|
||||
className={cn(
|
||||
'w-full justify-start text-left font-normal',
|
||||
!isValidDate && 'text-muted-foreground',
|
||||
error && 'border-red-500 focus:ring-red-500',
|
||||
field.is_readonly && 'bg-gray-50 text-gray-500'
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{isValidDate
|
||||
? format(dateValue, 'yyyy년 MM월 dd일', { locale: ko })
|
||||
: field.placeholder || '날짜를 선택하세요'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={isValidDate ? dateValue : undefined}
|
||||
onSelect={handleSelect}
|
||||
locale={ko}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{field.help_text && !error && (
|
||||
<p className="text-xs text-muted-foreground">{field.help_text}</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DateField;
|
||||
118
src/components/items/DynamicItemForm/fields/DropdownField.tsx
Normal file
118
src/components/items/DynamicItemForm/fields/DropdownField.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* DropdownField Component
|
||||
*
|
||||
* 드롭다운/선택 필드 (dropdown, searchable-dropdown)
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import type { DynamicFieldProps, DropdownOption } from '../types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function DropdownField({
|
||||
field,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled,
|
||||
}: DynamicFieldProps) {
|
||||
const [options, setOptions] = useState<DropdownOption[]>(
|
||||
field.dropdown_config?.options || []
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// API에서 옵션 로드 (options_endpoint가 있는 경우)
|
||||
useEffect(() => {
|
||||
if (field.dropdown_config?.options_endpoint) {
|
||||
setIsLoading(true);
|
||||
fetch(field.dropdown_config.options_endpoint)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.success && Array.isArray(data.data)) {
|
||||
setOptions(data.data);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[DropdownField] Failed to load options:', err);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [field.dropdown_config?.options_endpoint]);
|
||||
|
||||
const displayValue = value === null || value === undefined ? '' : String(value);
|
||||
|
||||
const handleValueChange = (newValue: string) => {
|
||||
onChange(newValue);
|
||||
onBlur();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor={field.field_key}
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500"
|
||||
)}
|
||||
>
|
||||
{field.field_name}
|
||||
</Label>
|
||||
|
||||
<Select
|
||||
value={displayValue}
|
||||
onValueChange={handleValueChange}
|
||||
disabled={disabled || field.is_readonly || isLoading}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={field.field_key}
|
||||
className={cn(
|
||||
error && 'border-red-500 focus:ring-red-500',
|
||||
field.is_readonly && 'bg-gray-50 text-gray-500'
|
||||
)}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoading
|
||||
? '로딩 중...'
|
||||
: field.dropdown_config?.placeholder || '선택하세요'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.dropdown_config?.allow_empty && (
|
||||
<SelectItem value="">선택 안함</SelectItem>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{field.help_text && !error && (
|
||||
<p className="text-xs text-muted-foreground">{field.help_text}</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DropdownField;
|
||||
203
src/components/items/DynamicItemForm/fields/FileField.tsx
Normal file
203
src/components/items/DynamicItemForm/fields/FileField.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* FileField Component
|
||||
*
|
||||
* 파일 업로드 필드
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { Upload, X, FileText } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import type { DynamicFieldProps } from '../types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* 파일 크기 포맷팅
|
||||
*/
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
export function FileField({
|
||||
field,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled,
|
||||
}: DynamicFieldProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const fileConfig = field.file_config || {};
|
||||
const accept = fileConfig.accept || '*';
|
||||
const maxSize = fileConfig.max_size || 10 * 1024 * 1024; // 기본 10MB
|
||||
const multiple = fileConfig.multiple || false;
|
||||
|
||||
// 현재 파일(들)
|
||||
const files: File[] = Array.isArray(value)
|
||||
? value
|
||||
: value instanceof File
|
||||
? [value]
|
||||
: [];
|
||||
|
||||
const handleFileSelect = (selectedFiles: FileList | null) => {
|
||||
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||
|
||||
const validFiles: File[] = [];
|
||||
|
||||
for (let i = 0; i < selectedFiles.length; i++) {
|
||||
const file = selectedFiles[i];
|
||||
|
||||
// 파일 크기 검사
|
||||
if (file.size > maxSize) {
|
||||
alert(`"${file.name}" 파일이 너무 큽니다. 최대 ${formatFileSize(maxSize)}까지 업로드 가능합니다.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
validFiles.push(file);
|
||||
}
|
||||
|
||||
if (validFiles.length === 0) return;
|
||||
|
||||
if (multiple) {
|
||||
onChange([...files, ...validFiles]);
|
||||
} else {
|
||||
onChange(validFiles[0]);
|
||||
}
|
||||
onBlur();
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
handleFileSelect(e.target.files);
|
||||
// 같은 파일 재선택 허용을 위해 리셋
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
if (disabled || field.is_readonly) return;
|
||||
handleFileSelect(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (disabled || field.is_readonly) return;
|
||||
setDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDragOver(false);
|
||||
};
|
||||
|
||||
const handleRemoveFile = (index: number) => {
|
||||
if (multiple) {
|
||||
const newFiles = files.filter((_, i) => i !== index);
|
||||
onChange(newFiles.length > 0 ? newFiles : null);
|
||||
} else {
|
||||
onChange(null);
|
||||
}
|
||||
onBlur();
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (disabled || field.is_readonly) return;
|
||||
inputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500"
|
||||
)}
|
||||
>
|
||||
{field.field_name}
|
||||
</Label>
|
||||
|
||||
{/* 드래그 앤 드롭 영역 */}
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
className={cn(
|
||||
'border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors',
|
||||
dragOver && 'border-primary bg-primary/5',
|
||||
error && 'border-red-500',
|
||||
(disabled || field.is_readonly) && 'opacity-50 cursor-not-allowed bg-gray-50',
|
||||
!dragOver && !error && 'border-gray-300 hover:border-gray-400'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
onChange={handleInputChange}
|
||||
disabled={disabled || field.is_readonly}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<Upload className="mx-auto h-8 w-8 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-600">
|
||||
클릭하거나 파일을 드래그하세요
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{accept !== '*' ? `허용 형식: ${accept}` : '모든 형식 허용'} |
|
||||
최대 {formatFileSize(maxSize)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 선택된 파일 목록 */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{files.map((file, index) => (
|
||||
<div
|
||||
key={`${file.name}-${index}`}
|
||||
className="flex items-center justify-between p-2 bg-gray-50 rounded-md"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FileText className="h-4 w-4 text-gray-500 flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{file.name}</p>
|
||||
<p className="text-xs text-gray-500">{formatFileSize(file.size)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{!disabled && !field.is_readonly && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveFile(index);
|
||||
}}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{field.help_text && !error && (
|
||||
<p className="text-xs text-muted-foreground">{field.help_text}</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileField;
|
||||
122
src/components/items/DynamicItemForm/fields/NumberField.tsx
Normal file
122
src/components/items/DynamicItemForm/fields/NumberField.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* NumberField Component
|
||||
*
|
||||
* 숫자 입력 필드 (number, currency)
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import type { DynamicFieldProps } from '../types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* 숫자 포맷팅 (천단위 콤마)
|
||||
*/
|
||||
function formatNumber(value: number | string, isCurrency: boolean): string {
|
||||
const num = typeof value === 'string' ? parseFloat(value) : value;
|
||||
if (isNaN(num)) return '';
|
||||
|
||||
if (isCurrency) {
|
||||
return num.toLocaleString('ko-KR');
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 포맷팅된 문자열을 숫자로 변환
|
||||
*/
|
||||
function parseFormattedNumber(value: string): number {
|
||||
// 콤마 제거 후 숫자로 변환
|
||||
const cleaned = value.replace(/,/g, '');
|
||||
const num = parseFloat(cleaned);
|
||||
return isNaN(num) ? 0 : num;
|
||||
}
|
||||
|
||||
export function NumberField({
|
||||
field,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled,
|
||||
}: DynamicFieldProps) {
|
||||
const isCurrency = field.field_type === 'currency';
|
||||
const numValue = typeof value === 'number' ? value : parseFormattedNumber(String(value || '0'));
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const inputValue = e.target.value;
|
||||
|
||||
// 빈 값 허용
|
||||
if (inputValue === '' || inputValue === '-') {
|
||||
onChange(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// 숫자와 콤마, 소수점, 마이너스만 허용
|
||||
const cleaned = inputValue.replace(/[^\d.,-]/g, '');
|
||||
const num = parseFormattedNumber(cleaned);
|
||||
|
||||
// 유효성 검사
|
||||
const { min, max } = field.validation_rules || {};
|
||||
if (min !== undefined && num < min) return;
|
||||
if (max !== undefined && num > max) return;
|
||||
|
||||
onChange(num);
|
||||
},
|
||||
[onChange, field.validation_rules]
|
||||
);
|
||||
|
||||
// 표시용 값 (통화면 포맷팅)
|
||||
const displayValue = formatNumber(numValue, isCurrency);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor={field.field_key}
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500"
|
||||
)}
|
||||
>
|
||||
{field.field_name}
|
||||
</Label>
|
||||
|
||||
<div className="relative">
|
||||
{isCurrency && (
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">
|
||||
₩
|
||||
</span>
|
||||
)}
|
||||
<Input
|
||||
id={field.field_key}
|
||||
name={field.field_key}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={displayValue}
|
||||
onChange={handleChange}
|
||||
onBlur={onBlur}
|
||||
placeholder={field.placeholder || '0'}
|
||||
disabled={disabled || field.is_readonly}
|
||||
className={cn(
|
||||
isCurrency && 'pl-8',
|
||||
error && 'border-red-500 focus-visible:ring-red-500',
|
||||
field.is_readonly && 'bg-gray-50 text-gray-500',
|
||||
'text-right'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{field.help_text && !error && (
|
||||
<p className="text-xs text-muted-foreground">{field.help_text}</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NumberField;
|
||||
83
src/components/items/DynamicItemForm/fields/TextField.tsx
Normal file
83
src/components/items/DynamicItemForm/fields/TextField.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* TextField Component
|
||||
*
|
||||
* 텍스트 입력 필드 (textbox, textarea)
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import type { DynamicFieldProps } from '../types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function TextField({
|
||||
field,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled,
|
||||
}: DynamicFieldProps) {
|
||||
const isTextarea = field.field_type === 'textarea';
|
||||
const displayValue = value === null || value === undefined ? '' : String(value);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
onChange(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor={field.field_key}
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500"
|
||||
)}
|
||||
>
|
||||
{field.field_name}
|
||||
</Label>
|
||||
|
||||
{isTextarea ? (
|
||||
<Textarea
|
||||
id={field.field_key}
|
||||
name={field.field_key}
|
||||
value={displayValue}
|
||||
onChange={handleChange}
|
||||
onBlur={onBlur}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled || field.is_readonly}
|
||||
className={cn(
|
||||
'min-h-[100px]',
|
||||
error && 'border-red-500 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={field.field_key}
|
||||
name={field.field_key}
|
||||
type="text"
|
||||
value={displayValue}
|
||||
onChange={handleChange}
|
||||
onBlur={onBlur}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled || field.is_readonly}
|
||||
maxLength={field.validation_rules?.maxLength}
|
||||
className={cn(
|
||||
error && 'border-red-500 focus-visible:ring-red-500',
|
||||
field.is_readonly && 'bg-gray-50 text-gray-500'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.help_text && !error && (
|
||||
<p className="text-xs text-muted-foreground">{field.help_text}</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextField;
|
||||
11
src/components/items/DynamicItemForm/fields/index.ts
Normal file
11
src/components/items/DynamicItemForm/fields/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 동적 필드 컴포넌트 인덱스
|
||||
*/
|
||||
|
||||
export { TextField } from './TextField';
|
||||
export { DropdownField } from './DropdownField';
|
||||
export { NumberField } from './NumberField';
|
||||
export { DateField } from './DateField';
|
||||
export { CheckboxField } from './CheckboxField';
|
||||
export { FileField } from './FileField';
|
||||
export { CustomField } from './CustomField';
|
||||
7
src/components/items/DynamicItemForm/hooks/index.ts
Normal file
7
src/components/items/DynamicItemForm/hooks/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 동적 폼 훅 인덱스
|
||||
*/
|
||||
|
||||
export { useFormStructure, clearFormStructureCache, invalidateFormStructureCache } from './useFormStructure';
|
||||
export { useConditionalFields } from './useConditionalFields';
|
||||
export { useDynamicFormState } from './useDynamicFormState';
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* useConditionalFields Hook
|
||||
*
|
||||
* 조건부 섹션/필드 표시 로직을 처리하는 훅
|
||||
* - 필드 값에 따른 섹션 표시/숨김
|
||||
* - 필드 값에 따른 필드 표시/숨김
|
||||
* - 조건 평가 (equals, in, not_equals 등)
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type {
|
||||
ConditionalSection,
|
||||
ConditionalField,
|
||||
DynamicSection,
|
||||
Condition,
|
||||
FormData,
|
||||
UseConditionalFieldsReturn,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* 단일 조건 평가
|
||||
*/
|
||||
function evaluateCondition(condition: Condition, values: FormData): boolean {
|
||||
const fieldValue = values[condition.field_key];
|
||||
|
||||
switch (condition.operator) {
|
||||
case 'equals':
|
||||
return fieldValue === condition.value;
|
||||
|
||||
case 'not_equals':
|
||||
return fieldValue !== condition.value;
|
||||
|
||||
case 'in':
|
||||
if (Array.isArray(condition.value)) {
|
||||
return condition.value.includes(fieldValue as string);
|
||||
}
|
||||
return false;
|
||||
|
||||
case 'not_in':
|
||||
if (Array.isArray(condition.value)) {
|
||||
return !condition.value.includes(fieldValue as string);
|
||||
}
|
||||
return true;
|
||||
|
||||
case 'contains':
|
||||
if (typeof fieldValue === 'string' && typeof condition.value === 'string') {
|
||||
return fieldValue.includes(condition.value);
|
||||
}
|
||||
return false;
|
||||
|
||||
case 'greater_than':
|
||||
if (typeof fieldValue === 'number' && typeof condition.value === 'number') {
|
||||
return fieldValue > condition.value;
|
||||
}
|
||||
return false;
|
||||
|
||||
case 'less_than':
|
||||
if (typeof fieldValue === 'number' && typeof condition.value === 'number') {
|
||||
return fieldValue < condition.value;
|
||||
}
|
||||
return false;
|
||||
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 섹션의 display_condition 평가
|
||||
*/
|
||||
function isSectionConditionMet(section: DynamicSection, values: FormData): boolean {
|
||||
// display_condition이 없으면 항상 표시
|
||||
if (!section.display_condition) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return evaluateCondition(section.display_condition, values);
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 섹션 규칙에 따른 표시 여부 결정
|
||||
*/
|
||||
function evaluateConditionalSections(
|
||||
sections: DynamicSection[],
|
||||
conditionalSections: ConditionalSection[],
|
||||
values: FormData
|
||||
): Set<number> {
|
||||
const visibleSectionIds = new Set<number>();
|
||||
|
||||
// 1. 기본적으로 모든 섹션의 display_condition 평가
|
||||
for (const section of sections) {
|
||||
if (isSectionConditionMet(section, values)) {
|
||||
visibleSectionIds.add(section.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. conditionalSections 규칙 적용
|
||||
for (const rule of conditionalSections) {
|
||||
const conditionMet = evaluateCondition(rule.condition, values);
|
||||
|
||||
if (conditionMet) {
|
||||
// 조건 충족 시 show_sections 표시
|
||||
for (const sectionId of rule.show_sections) {
|
||||
visibleSectionIds.add(sectionId);
|
||||
}
|
||||
// hide_sections가 있으면 숨김
|
||||
if (rule.hide_sections) {
|
||||
for (const sectionId of rule.hide_sections) {
|
||||
visibleSectionIds.delete(sectionId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 조건 미충족 시 show_sections 숨김
|
||||
for (const sectionId of rule.show_sections) {
|
||||
visibleSectionIds.delete(sectionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return visibleSectionIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 필드 규칙에 따른 표시 여부 결정
|
||||
*/
|
||||
function evaluateConditionalFields(
|
||||
sections: DynamicSection[],
|
||||
conditionalFields: ConditionalField[],
|
||||
values: FormData
|
||||
): Map<number, Set<number>> {
|
||||
const visibleFieldsMap = new Map<number, Set<number>>();
|
||||
|
||||
// 1. 기본적으로 모든 필드 표시 (각 섹션별로)
|
||||
for (const section of sections) {
|
||||
const fieldIds = new Set<number>();
|
||||
|
||||
for (const field of section.fields) {
|
||||
// 필드의 display_condition 평가
|
||||
if (field.display_condition) {
|
||||
if (evaluateCondition(field.display_condition, values)) {
|
||||
fieldIds.add(field.id);
|
||||
}
|
||||
} else {
|
||||
fieldIds.add(field.id);
|
||||
}
|
||||
}
|
||||
|
||||
visibleFieldsMap.set(section.id, fieldIds);
|
||||
}
|
||||
|
||||
// 2. conditionalFields 규칙 적용
|
||||
for (const rule of conditionalFields) {
|
||||
const conditionMet = evaluateCondition(rule.condition, values);
|
||||
|
||||
// 모든 섹션에서 해당 필드 ID 찾기
|
||||
for (const [sectionId, fieldIds] of visibleFieldsMap) {
|
||||
if (conditionMet) {
|
||||
// 조건 충족 시 show_fields 표시
|
||||
for (const fieldId of rule.show_fields) {
|
||||
fieldIds.add(fieldId);
|
||||
}
|
||||
// hide_fields가 있으면 숨김
|
||||
if (rule.hide_fields) {
|
||||
for (const fieldId of rule.hide_fields) {
|
||||
fieldIds.delete(fieldId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 조건 미충족 시 show_fields 숨김
|
||||
for (const fieldId of rule.show_fields) {
|
||||
fieldIds.delete(fieldId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return visibleFieldsMap;
|
||||
}
|
||||
|
||||
interface UseConditionalFieldsOptions {
|
||||
sections: DynamicSection[];
|
||||
conditionalSections: ConditionalSection[];
|
||||
conditionalFields: ConditionalField[];
|
||||
values: FormData;
|
||||
}
|
||||
|
||||
/**
|
||||
* useConditionalFields Hook
|
||||
*
|
||||
* @param options - 훅 옵션
|
||||
* @returns 조건부 표시 상태 및 헬퍼 함수
|
||||
*
|
||||
* @example
|
||||
* const { visibleSections, isFieldVisible, isSectionVisible } = useConditionalFields({
|
||||
* sections: formStructure.sections,
|
||||
* conditionalSections: formStructure.conditionalSections,
|
||||
* conditionalFields: formStructure.conditionalFields,
|
||||
* values: formValues,
|
||||
* });
|
||||
*/
|
||||
export function useConditionalFields(
|
||||
options: UseConditionalFieldsOptions
|
||||
): UseConditionalFieldsReturn {
|
||||
const { sections, conditionalSections, conditionalFields, values } = options;
|
||||
|
||||
// 표시할 섹션 ID 목록
|
||||
const visibleSectionIds = useMemo(() => {
|
||||
return evaluateConditionalSections(sections, conditionalSections, values);
|
||||
}, [sections, conditionalSections, values]);
|
||||
|
||||
// 섹션별 표시할 필드 ID 맵
|
||||
const visibleFieldsMap = useMemo(() => {
|
||||
return evaluateConditionalFields(sections, conditionalFields, values);
|
||||
}, [sections, conditionalFields, values]);
|
||||
|
||||
// 배열로 변환
|
||||
const visibleSections = useMemo(() => {
|
||||
return Array.from(visibleSectionIds);
|
||||
}, [visibleSectionIds]);
|
||||
|
||||
// Map을 Record로 변환
|
||||
const visibleFields = useMemo(() => {
|
||||
const result: Record<number, number[]> = {};
|
||||
for (const [sectionId, fieldIds] of visibleFieldsMap) {
|
||||
result[sectionId] = Array.from(fieldIds);
|
||||
}
|
||||
return result;
|
||||
}, [visibleFieldsMap]);
|
||||
|
||||
/**
|
||||
* 특정 섹션이 표시되는지 확인
|
||||
*/
|
||||
const isSectionVisible = (sectionId: number): boolean => {
|
||||
return visibleSectionIds.has(sectionId);
|
||||
};
|
||||
|
||||
/**
|
||||
* 특정 필드가 표시되는지 확인
|
||||
*/
|
||||
const isFieldVisible = (sectionId: number, fieldId: number): boolean => {
|
||||
const fieldIds = visibleFieldsMap.get(sectionId);
|
||||
if (!fieldIds) return false;
|
||||
return fieldIds.has(fieldId);
|
||||
};
|
||||
|
||||
return {
|
||||
visibleSections,
|
||||
visibleFields,
|
||||
isSectionVisible,
|
||||
isFieldVisible,
|
||||
};
|
||||
}
|
||||
|
||||
export default useConditionalFields;
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* useDynamicFormState Hook
|
||||
*
|
||||
* 동적 폼의 상태 관리 훅
|
||||
* - 필드 값 관리
|
||||
* - 유효성 검증
|
||||
* - 에러 상태 관리
|
||||
* - 폼 제출 처리
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import type {
|
||||
FormState,
|
||||
FormData,
|
||||
FormValue,
|
||||
DynamicSection,
|
||||
DynamicField,
|
||||
UseDynamicFormStateReturn,
|
||||
ValidationRules,
|
||||
FIELD_TYPE_DEFAULTS,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* 폼 구조에서 초기 값 생성
|
||||
*/
|
||||
function buildInitialValues(
|
||||
sections: DynamicSection[],
|
||||
existingValues?: FormData
|
||||
): FormData {
|
||||
const values: FormData = {};
|
||||
|
||||
for (const section of sections) {
|
||||
for (const field of section.fields) {
|
||||
// 기존 값이 있으면 사용, 없으면 기본값
|
||||
if (existingValues && existingValues[field.field_key] !== undefined) {
|
||||
values[field.field_key] = existingValues[field.field_key];
|
||||
} else if (field.default_value !== undefined) {
|
||||
values[field.field_key] = field.default_value;
|
||||
} else {
|
||||
// 필드 타입에 따른 기본값
|
||||
values[field.field_key] = getDefaultValueForType(field.field_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* 필드 타입에 따른 기본값
|
||||
*/
|
||||
function getDefaultValueForType(fieldType: string): FormValue {
|
||||
switch (fieldType) {
|
||||
case 'textbox':
|
||||
case 'textarea':
|
||||
case 'dropdown':
|
||||
case 'searchable-dropdown':
|
||||
return '';
|
||||
case 'number':
|
||||
case 'currency':
|
||||
return 0;
|
||||
case 'checkbox':
|
||||
case 'switch':
|
||||
return false;
|
||||
case 'date':
|
||||
case 'date-range':
|
||||
case 'file':
|
||||
case 'custom:drawing-canvas':
|
||||
case 'custom:bending-detail-table':
|
||||
case 'custom:bom-table':
|
||||
return null;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 필드 유효성 검증
|
||||
*/
|
||||
function validateField(
|
||||
field: DynamicField,
|
||||
value: FormValue
|
||||
): string | undefined {
|
||||
// 필수 필드 검사
|
||||
if (field.is_required) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return `${field.field_name}은(는) 필수 입력 항목입니다.`;
|
||||
}
|
||||
}
|
||||
|
||||
// 값이 없으면 추가 검증 스킵
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rules = field.validation_rules;
|
||||
if (!rules) return undefined;
|
||||
|
||||
// 문자열 검증
|
||||
if (typeof value === 'string') {
|
||||
if (rules.minLength && value.length < rules.minLength) {
|
||||
return `최소 ${rules.minLength}자 이상 입력해주세요.`;
|
||||
}
|
||||
if (rules.maxLength && value.length > rules.maxLength) {
|
||||
return `최대 ${rules.maxLength}자까지 입력 가능합니다.`;
|
||||
}
|
||||
if (rules.pattern) {
|
||||
const regex = new RegExp(rules.pattern);
|
||||
if (!regex.test(value)) {
|
||||
return rules.patternMessage || '입력 형식이 올바르지 않습니다.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 숫자 검증
|
||||
if (typeof value === 'number') {
|
||||
if (rules.min !== undefined && value < rules.min) {
|
||||
return `최소 ${rules.min} 이상이어야 합니다.`;
|
||||
}
|
||||
if (rules.max !== undefined && value > rules.max) {
|
||||
return `최대 ${rules.max} 이하여야 합니다.`;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 폼 유효성 검증
|
||||
*/
|
||||
function validateForm(
|
||||
sections: DynamicSection[],
|
||||
values: FormData
|
||||
): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
for (const section of sections) {
|
||||
for (const field of section.fields) {
|
||||
const error = validateField(field, values[field.field_key]);
|
||||
if (error) {
|
||||
errors[field.field_key] = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
interface UseDynamicFormStateOptions {
|
||||
sections: DynamicSection[];
|
||||
initialValues?: FormData;
|
||||
onSubmit?: (data: FormData) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* useDynamicFormState Hook
|
||||
*
|
||||
* @param options - 훅 옵션
|
||||
* @returns 폼 상태 및 조작 함수
|
||||
*
|
||||
* @example
|
||||
* const { state, setValue, handleSubmit, validate } = useDynamicFormState({
|
||||
* sections: formStructure.sections,
|
||||
* initialValues: existingItem,
|
||||
* onSubmit: async (data) => {
|
||||
* await saveItem(data);
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useDynamicFormState(
|
||||
options: UseDynamicFormStateOptions
|
||||
): UseDynamicFormStateReturn {
|
||||
const { sections, initialValues, onSubmit } = options;
|
||||
|
||||
// 초기 상태 생성
|
||||
const [state, setState] = useState<FormState>(() => ({
|
||||
values: buildInitialValues(sections, initialValues),
|
||||
errors: {},
|
||||
touched: {},
|
||||
isSubmitting: false,
|
||||
isValid: true,
|
||||
}));
|
||||
|
||||
/**
|
||||
* 단일 필드 값 설정
|
||||
*/
|
||||
const setValue = useCallback((fieldKey: string, value: FormValue) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
values: {
|
||||
...prev.values,
|
||||
[fieldKey]: value,
|
||||
},
|
||||
// 값 변경 시 해당 필드 에러 클리어
|
||||
errors: {
|
||||
...prev.errors,
|
||||
[fieldKey]: undefined as unknown as string,
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 여러 필드 값 일괄 설정
|
||||
*/
|
||||
const setValues = useCallback((values: FormData) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
values: {
|
||||
...prev.values,
|
||||
...values,
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 필드 에러 설정
|
||||
*/
|
||||
const setError = useCallback((fieldKey: string, message: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
errors: {
|
||||
...prev.errors,
|
||||
[fieldKey]: message,
|
||||
},
|
||||
isValid: false,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 필드 에러 클리어
|
||||
*/
|
||||
const clearError = useCallback((fieldKey: string) => {
|
||||
setState((prev) => {
|
||||
const newErrors = { ...prev.errors };
|
||||
delete newErrors[fieldKey];
|
||||
return {
|
||||
...prev,
|
||||
errors: newErrors,
|
||||
isValid: Object.keys(newErrors).length === 0,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 필드 touched 상태 설정
|
||||
*/
|
||||
const setTouched = useCallback((fieldKey: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
touched: {
|
||||
...prev.touched,
|
||||
[fieldKey]: true,
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 전체 폼 유효성 검증
|
||||
*/
|
||||
const validate = useCallback((): boolean => {
|
||||
const errors = validateForm(sections, state.values);
|
||||
const isValid = Object.keys(errors).length === 0;
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
errors,
|
||||
isValid,
|
||||
}));
|
||||
|
||||
return isValid;
|
||||
}, [sections, state.values]);
|
||||
|
||||
/**
|
||||
* 폼 리셋
|
||||
*/
|
||||
const reset = useCallback(
|
||||
(resetValues?: FormData) => {
|
||||
setState({
|
||||
values: buildInitialValues(sections, resetValues || initialValues),
|
||||
errors: {},
|
||||
touched: {},
|
||||
isSubmitting: false,
|
||||
isValid: true,
|
||||
});
|
||||
},
|
||||
[sections, initialValues]
|
||||
);
|
||||
|
||||
/**
|
||||
* 폼 제출 핸들러 생성
|
||||
*/
|
||||
const handleSubmit = useCallback(
|
||||
(submitFn: (data: FormData) => Promise<void>) => {
|
||||
return async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// 유효성 검증
|
||||
const isValid = validate();
|
||||
if (!isValid) {
|
||||
console.warn('[useDynamicFormState] Form validation failed');
|
||||
return;
|
||||
}
|
||||
|
||||
// 제출 시작
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isSubmitting: true,
|
||||
}));
|
||||
|
||||
try {
|
||||
await submitFn(state.values);
|
||||
} catch (error) {
|
||||
console.error('[useDynamicFormState] Submit error:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isSubmitting: false,
|
||||
}));
|
||||
}
|
||||
};
|
||||
},
|
||||
[validate, state.values]
|
||||
);
|
||||
|
||||
return {
|
||||
state,
|
||||
setValue,
|
||||
setValues,
|
||||
setError,
|
||||
clearError,
|
||||
setTouched,
|
||||
validate,
|
||||
reset,
|
||||
handleSubmit,
|
||||
};
|
||||
}
|
||||
|
||||
export default useDynamicFormState;
|
||||
995
src/components/items/DynamicItemForm/hooks/useFormStructure.ts
Normal file
995
src/components/items/DynamicItemForm/hooks/useFormStructure.ts
Normal file
@@ -0,0 +1,995 @@
|
||||
/**
|
||||
* useFormStructure Hook
|
||||
*
|
||||
* API에서 품목 유형별 폼 구조를 로드하는 훅
|
||||
* - 캐싱 지원 (5분 TTL)
|
||||
* - 에러 처리 및 재시도
|
||||
* - Mock 데이터 폴백 (API 미구현 시)
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import type { ItemType, PartType } from '@/types/item';
|
||||
import type {
|
||||
FormStructure,
|
||||
FormStructureResponse,
|
||||
UseFormStructureReturn,
|
||||
DynamicSection,
|
||||
DynamicField,
|
||||
ConditionalSection,
|
||||
} from '../types';
|
||||
|
||||
// ===== 캐시 설정 =====
|
||||
const CACHE_TTL = 5 * 60 * 1000; // 5분
|
||||
const formStructureCache = new Map<string, { data: FormStructure; timestamp: number }>();
|
||||
|
||||
/**
|
||||
* 캐시 키 생성
|
||||
*/
|
||||
function getCacheKey(itemType: ItemType, partType?: PartType): string {
|
||||
return partType ? `${itemType}_${partType}` : itemType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시에서 데이터 가져오기
|
||||
*/
|
||||
function getFromCache(key: string): FormStructure | null {
|
||||
const cached = formStructureCache.get(key);
|
||||
if (!cached) return null;
|
||||
|
||||
const isExpired = Date.now() - cached.timestamp > CACHE_TTL;
|
||||
if (isExpired) {
|
||||
formStructureCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시에 데이터 저장
|
||||
*/
|
||||
function setToCache(key: string, data: FormStructure): void {
|
||||
formStructureCache.set(key, { data, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
// ===== Mock 데이터 (API 미구현 시 사용) =====
|
||||
|
||||
/**
|
||||
* 제품(FG) Mock 폼 구조
|
||||
*/
|
||||
function getMockFGFormStructure(): FormStructure {
|
||||
return {
|
||||
page: {
|
||||
id: 1,
|
||||
page_name: '제품 등록',
|
||||
item_type: 'FG',
|
||||
is_active: true,
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 101,
|
||||
title: '기본 정보',
|
||||
section_type: 'BASIC',
|
||||
order_no: 1,
|
||||
is_collapsible: false,
|
||||
is_default_open: true,
|
||||
fields: [
|
||||
{
|
||||
id: 1001,
|
||||
field_name: '품목코드',
|
||||
field_key: 'item_code',
|
||||
field_type: 'textbox',
|
||||
order_no: 1,
|
||||
is_required: true,
|
||||
is_readonly: true,
|
||||
placeholder: '자동 생성',
|
||||
grid_row: 1,
|
||||
grid_col: 1,
|
||||
grid_span: 1,
|
||||
},
|
||||
{
|
||||
id: 1002,
|
||||
field_name: '품목명',
|
||||
field_key: 'item_name',
|
||||
field_type: 'textbox',
|
||||
order_no: 2,
|
||||
is_required: true,
|
||||
placeholder: '품목명을 입력하세요',
|
||||
validation_rules: { maxLength: 100 },
|
||||
grid_row: 1,
|
||||
grid_col: 2,
|
||||
grid_span: 2,
|
||||
},
|
||||
{
|
||||
id: 1003,
|
||||
field_name: '제품 카테고리',
|
||||
field_key: 'product_category',
|
||||
field_type: 'dropdown',
|
||||
order_no: 3,
|
||||
is_required: true,
|
||||
dropdown_config: {
|
||||
options: [
|
||||
{ value: 'SCREEN', label: '스크린' },
|
||||
{ value: 'STEEL', label: '철재' },
|
||||
],
|
||||
placeholder: '카테고리 선택',
|
||||
},
|
||||
grid_row: 1,
|
||||
grid_col: 4,
|
||||
grid_span: 1,
|
||||
},
|
||||
{
|
||||
id: 1004,
|
||||
field_name: '단위',
|
||||
field_key: 'unit',
|
||||
field_type: 'dropdown',
|
||||
order_no: 4,
|
||||
is_required: true,
|
||||
dropdown_config: {
|
||||
options: [
|
||||
{ value: 'EA', label: 'EA (개)' },
|
||||
{ value: 'SET', label: 'SET (세트)' },
|
||||
],
|
||||
placeholder: '단위 선택',
|
||||
},
|
||||
grid_row: 2,
|
||||
grid_col: 1,
|
||||
grid_span: 1,
|
||||
},
|
||||
{
|
||||
id: 1005,
|
||||
field_name: '규격',
|
||||
field_key: 'specification',
|
||||
field_type: 'textbox',
|
||||
order_no: 5,
|
||||
is_required: false,
|
||||
placeholder: '규격을 입력하세요',
|
||||
grid_row: 2,
|
||||
grid_col: 2,
|
||||
grid_span: 2,
|
||||
},
|
||||
{
|
||||
id: 1006,
|
||||
field_name: '활성 상태',
|
||||
field_key: 'is_active',
|
||||
field_type: 'switch',
|
||||
order_no: 6,
|
||||
is_required: false,
|
||||
default_value: true,
|
||||
grid_row: 2,
|
||||
grid_col: 4,
|
||||
grid_span: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
title: '가격 정보',
|
||||
section_type: 'DETAIL',
|
||||
order_no: 2,
|
||||
is_collapsible: true,
|
||||
is_default_open: true,
|
||||
fields: [
|
||||
{
|
||||
id: 1010,
|
||||
field_name: '판매 단가',
|
||||
field_key: 'sales_price',
|
||||
field_type: 'currency',
|
||||
order_no: 1,
|
||||
is_required: false,
|
||||
placeholder: '0',
|
||||
grid_row: 1,
|
||||
grid_col: 1,
|
||||
grid_span: 1,
|
||||
},
|
||||
{
|
||||
id: 1011,
|
||||
field_name: '구매 단가',
|
||||
field_key: 'purchase_price',
|
||||
field_type: 'currency',
|
||||
order_no: 2,
|
||||
is_required: false,
|
||||
placeholder: '0',
|
||||
grid_row: 1,
|
||||
grid_col: 2,
|
||||
grid_span: 1,
|
||||
},
|
||||
{
|
||||
id: 1012,
|
||||
field_name: '마진율 (%)',
|
||||
field_key: 'margin_rate',
|
||||
field_type: 'number',
|
||||
order_no: 3,
|
||||
is_required: false,
|
||||
placeholder: '0',
|
||||
validation_rules: { min: 0, max: 100 },
|
||||
grid_row: 1,
|
||||
grid_col: 3,
|
||||
grid_span: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
title: '부품 구성 (BOM)',
|
||||
section_type: 'BOM',
|
||||
order_no: 3,
|
||||
is_collapsible: true,
|
||||
is_default_open: true,
|
||||
fields: [],
|
||||
bom_config: {
|
||||
columns: [
|
||||
{ key: 'child_item_code', label: '품목코드', width: 150 },
|
||||
{ key: 'child_item_name', label: '품목명', width: 200 },
|
||||
{ key: 'specification', label: '규격', width: 150 },
|
||||
{ key: 'quantity', label: '수량', width: 80, type: 'number', editable: true },
|
||||
{ key: 'unit', label: '단위', width: 80 },
|
||||
{ key: 'note', label: '비고', width: 150, type: 'text', editable: true },
|
||||
],
|
||||
allow_search: true,
|
||||
search_endpoint: '/api/proxy/items/search',
|
||||
allow_add: true,
|
||||
allow_delete: true,
|
||||
allow_reorder: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 104,
|
||||
title: '인정 정보',
|
||||
section_type: 'CERTIFICATION',
|
||||
order_no: 4,
|
||||
is_collapsible: true,
|
||||
is_default_open: false,
|
||||
fields: [
|
||||
{
|
||||
id: 1020,
|
||||
field_name: '인정번호',
|
||||
field_key: 'certification_number',
|
||||
field_type: 'textbox',
|
||||
order_no: 1,
|
||||
is_required: false,
|
||||
placeholder: '인정번호를 입력하세요',
|
||||
grid_row: 1,
|
||||
grid_col: 1,
|
||||
grid_span: 2,
|
||||
},
|
||||
{
|
||||
id: 1021,
|
||||
field_name: '인정 시작일',
|
||||
field_key: 'certification_start_date',
|
||||
field_type: 'date',
|
||||
order_no: 2,
|
||||
is_required: false,
|
||||
grid_row: 1,
|
||||
grid_col: 3,
|
||||
grid_span: 1,
|
||||
},
|
||||
{
|
||||
id: 1022,
|
||||
field_name: '인정 종료일',
|
||||
field_key: 'certification_end_date',
|
||||
field_type: 'date',
|
||||
order_no: 3,
|
||||
is_required: false,
|
||||
grid_row: 1,
|
||||
grid_col: 4,
|
||||
grid_span: 1,
|
||||
},
|
||||
{
|
||||
id: 1023,
|
||||
field_name: '시방서',
|
||||
field_key: 'specification_file',
|
||||
field_type: 'file',
|
||||
order_no: 4,
|
||||
is_required: false,
|
||||
file_config: {
|
||||
accept: '.pdf,.doc,.docx',
|
||||
max_size: 10 * 1024 * 1024, // 10MB
|
||||
},
|
||||
grid_row: 2,
|
||||
grid_col: 1,
|
||||
grid_span: 2,
|
||||
},
|
||||
{
|
||||
id: 1024,
|
||||
field_name: '인정서',
|
||||
field_key: 'certification_file',
|
||||
field_type: 'file',
|
||||
order_no: 5,
|
||||
is_required: false,
|
||||
file_config: {
|
||||
accept: '.pdf,.doc,.docx',
|
||||
max_size: 10 * 1024 * 1024,
|
||||
},
|
||||
grid_row: 2,
|
||||
grid_col: 3,
|
||||
grid_span: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
conditionalSections: [],
|
||||
conditionalFields: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 부품(PT) Mock 폼 구조
|
||||
*/
|
||||
function getMockPTFormStructure(partType?: PartType): FormStructure {
|
||||
const baseFields: DynamicField[] = [
|
||||
{
|
||||
id: 2001,
|
||||
field_name: '품목코드',
|
||||
field_key: 'item_code',
|
||||
field_type: 'textbox',
|
||||
order_no: 1,
|
||||
is_required: true,
|
||||
is_readonly: true,
|
||||
placeholder: '자동 생성',
|
||||
grid_row: 1,
|
||||
grid_col: 1,
|
||||
grid_span: 1,
|
||||
},
|
||||
{
|
||||
id: 2002,
|
||||
field_name: '품목명',
|
||||
field_key: 'item_name',
|
||||
field_type: 'textbox',
|
||||
order_no: 2,
|
||||
is_required: true,
|
||||
placeholder: '품목명을 입력하세요',
|
||||
grid_row: 1,
|
||||
grid_col: 2,
|
||||
grid_span: 2,
|
||||
},
|
||||
{
|
||||
id: 2003,
|
||||
field_name: '부품 유형',
|
||||
field_key: 'part_type',
|
||||
field_type: 'dropdown',
|
||||
order_no: 3,
|
||||
is_required: true,
|
||||
dropdown_config: {
|
||||
options: [
|
||||
{ value: 'ASSEMBLY', label: '조립 부품' },
|
||||
{ value: 'BENDING', label: '절곡 부품' },
|
||||
{ value: 'PURCHASED', label: '구매 부품' },
|
||||
],
|
||||
placeholder: '부품 유형 선택',
|
||||
},
|
||||
grid_row: 1,
|
||||
grid_col: 4,
|
||||
grid_span: 1,
|
||||
},
|
||||
{
|
||||
id: 2004,
|
||||
field_name: '단위',
|
||||
field_key: 'unit',
|
||||
field_type: 'dropdown',
|
||||
order_no: 4,
|
||||
is_required: true,
|
||||
dropdown_config: {
|
||||
options: [
|
||||
{ value: 'EA', label: 'EA (개)' },
|
||||
{ value: 'SET', label: 'SET (세트)' },
|
||||
{ value: 'M', label: 'M (미터)' },
|
||||
],
|
||||
},
|
||||
grid_row: 2,
|
||||
grid_col: 1,
|
||||
grid_span: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const sections: DynamicSection[] = [
|
||||
{
|
||||
id: 201,
|
||||
title: '기본 정보',
|
||||
section_type: 'BASIC',
|
||||
order_no: 1,
|
||||
is_collapsible: false,
|
||||
is_default_open: true,
|
||||
fields: baseFields,
|
||||
},
|
||||
];
|
||||
|
||||
// 조립 부품 전용 섹션
|
||||
const assemblySection: DynamicSection = {
|
||||
id: 202,
|
||||
title: '조립 부품 상세',
|
||||
section_type: 'DETAIL',
|
||||
order_no: 2,
|
||||
is_collapsible: true,
|
||||
is_default_open: true,
|
||||
display_condition: {
|
||||
field_key: 'part_type',
|
||||
operator: 'equals',
|
||||
value: 'ASSEMBLY',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
id: 2010,
|
||||
field_name: '설치 유형',
|
||||
field_key: 'installation_type',
|
||||
field_type: 'dropdown',
|
||||
order_no: 1,
|
||||
is_required: false,
|
||||
dropdown_config: {
|
||||
options: [
|
||||
{ value: 'WALL', label: '벽면형' },
|
||||
{ value: 'SIDE', label: '측면형' },
|
||||
],
|
||||
},
|
||||
grid_row: 1,
|
||||
grid_col: 1,
|
||||
grid_span: 1,
|
||||
},
|
||||
{
|
||||
id: 2011,
|
||||
field_name: '조립 종류',
|
||||
field_key: 'assembly_type',
|
||||
field_type: 'dropdown',
|
||||
order_no: 2,
|
||||
is_required: false,
|
||||
dropdown_config: {
|
||||
options: [
|
||||
{ value: 'M', label: 'M형' },
|
||||
{ value: 'T', label: 'T형' },
|
||||
{ value: 'C', label: 'C형' },
|
||||
{ value: 'D', label: 'D형' },
|
||||
{ value: 'S', label: 'S형' },
|
||||
{ value: 'U', label: 'U형' },
|
||||
],
|
||||
},
|
||||
grid_row: 1,
|
||||
grid_col: 2,
|
||||
grid_span: 1,
|
||||
},
|
||||
{
|
||||
id: 2012,
|
||||
field_name: '길이 (mm)',
|
||||
field_key: 'assembly_length',
|
||||
field_type: 'dropdown',
|
||||
order_no: 3,
|
||||
is_required: false,
|
||||
dropdown_config: {
|
||||
options: [
|
||||
{ value: '2438', label: '2438' },
|
||||
{ value: '3000', label: '3000' },
|
||||
{ value: '3500', label: '3500' },
|
||||
{ value: '4000', label: '4000' },
|
||||
{ value: '4300', label: '4300' },
|
||||
],
|
||||
},
|
||||
grid_row: 1,
|
||||
grid_col: 3,
|
||||
grid_span: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 절곡 부품 전용 섹션
|
||||
const bendingSection: DynamicSection = {
|
||||
id: 203,
|
||||
title: '절곡 정보',
|
||||
section_type: 'BENDING',
|
||||
order_no: 2,
|
||||
is_collapsible: true,
|
||||
is_default_open: true,
|
||||
display_condition: {
|
||||
field_key: 'part_type',
|
||||
operator: 'equals',
|
||||
value: 'BENDING',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
id: 2020,
|
||||
field_name: '재질',
|
||||
field_key: 'material',
|
||||
field_type: 'dropdown',
|
||||
order_no: 1,
|
||||
is_required: true,
|
||||
dropdown_config: {
|
||||
options: [
|
||||
{ value: 'EGI_1.55T', label: 'EGI 1.55T' },
|
||||
{ value: 'SUS_1.2T', label: 'SUS 1.2T' },
|
||||
{ value: 'SUS_1.5T', label: 'SUS 1.5T' },
|
||||
],
|
||||
},
|
||||
grid_row: 1,
|
||||
grid_col: 1,
|
||||
grid_span: 1,
|
||||
},
|
||||
{
|
||||
id: 2021,
|
||||
field_name: '길이/목함 (mm)',
|
||||
field_key: 'bending_length',
|
||||
field_type: 'number',
|
||||
order_no: 2,
|
||||
is_required: false,
|
||||
placeholder: '길이 입력',
|
||||
grid_row: 1,
|
||||
grid_col: 2,
|
||||
grid_span: 1,
|
||||
},
|
||||
{
|
||||
id: 2022,
|
||||
field_name: '전개도',
|
||||
field_key: 'bending_diagram',
|
||||
field_type: 'custom:drawing-canvas',
|
||||
order_no: 3,
|
||||
is_required: false,
|
||||
grid_row: 2,
|
||||
grid_col: 1,
|
||||
grid_span: 4,
|
||||
},
|
||||
{
|
||||
id: 2023,
|
||||
field_name: '전개도 상세',
|
||||
field_key: 'bending_details',
|
||||
field_type: 'custom:bending-detail-table',
|
||||
order_no: 4,
|
||||
is_required: false,
|
||||
grid_row: 3,
|
||||
grid_col: 1,
|
||||
grid_span: 4,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 구매 부품 전용 섹션
|
||||
const purchasedSection: DynamicSection = {
|
||||
id: 204,
|
||||
title: '구매 부품 상세',
|
||||
section_type: 'DETAIL',
|
||||
order_no: 2,
|
||||
is_collapsible: true,
|
||||
is_default_open: true,
|
||||
display_condition: {
|
||||
field_key: 'part_type',
|
||||
operator: 'equals',
|
||||
value: 'PURCHASED',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
id: 2030,
|
||||
field_name: '구매처',
|
||||
field_key: 'supplier',
|
||||
field_type: 'textbox',
|
||||
order_no: 1,
|
||||
is_required: false,
|
||||
placeholder: '구매처를 입력하세요',
|
||||
grid_row: 1,
|
||||
grid_col: 1,
|
||||
grid_span: 2,
|
||||
},
|
||||
{
|
||||
id: 2031,
|
||||
field_name: '구매 단가',
|
||||
field_key: 'purchase_price',
|
||||
field_type: 'currency',
|
||||
order_no: 2,
|
||||
is_required: false,
|
||||
grid_row: 1,
|
||||
grid_col: 3,
|
||||
grid_span: 1,
|
||||
},
|
||||
{
|
||||
id: 2032,
|
||||
field_name: '리드타임 (일)',
|
||||
field_key: 'lead_time',
|
||||
field_type: 'number',
|
||||
order_no: 3,
|
||||
is_required: false,
|
||||
placeholder: '0',
|
||||
grid_row: 1,
|
||||
grid_col: 4,
|
||||
grid_span: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sections.push(assemblySection, bendingSection, purchasedSection);
|
||||
|
||||
// BOM 섹션 (조립/절곡 부품만)
|
||||
const bomSection: DynamicSection = {
|
||||
id: 205,
|
||||
title: '부품 구성 (BOM)',
|
||||
section_type: 'BOM',
|
||||
order_no: 3,
|
||||
is_collapsible: true,
|
||||
is_default_open: true,
|
||||
display_condition: {
|
||||
field_key: 'part_type',
|
||||
operator: 'in',
|
||||
value: ['ASSEMBLY', 'BENDING'],
|
||||
},
|
||||
fields: [],
|
||||
bom_config: {
|
||||
columns: [
|
||||
{ key: 'child_item_code', label: '품목코드', width: 150 },
|
||||
{ key: 'child_item_name', label: '품목명', width: 200 },
|
||||
{ key: 'quantity', label: '수량', width: 80, type: 'number', editable: true },
|
||||
{ key: 'unit', label: '단위', width: 80 },
|
||||
],
|
||||
allow_search: true,
|
||||
search_endpoint: '/api/proxy/items/search',
|
||||
allow_add: true,
|
||||
allow_delete: true,
|
||||
},
|
||||
};
|
||||
|
||||
sections.push(bomSection);
|
||||
|
||||
return {
|
||||
page: {
|
||||
id: 2,
|
||||
page_name: '부품 등록',
|
||||
item_type: 'PT',
|
||||
part_type: partType,
|
||||
is_active: true,
|
||||
},
|
||||
sections,
|
||||
conditionalSections: [],
|
||||
conditionalFields: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 자재(RM/SM/CS) Mock 폼 구조
|
||||
*/
|
||||
function getMockMaterialFormStructure(itemType: ItemType): FormStructure {
|
||||
const typeLabels: Record<string, string> = {
|
||||
RM: '원자재',
|
||||
SM: '부자재',
|
||||
CS: '소모품',
|
||||
};
|
||||
|
||||
return {
|
||||
page: {
|
||||
id: itemType === 'RM' ? 3 : itemType === 'SM' ? 4 : 5,
|
||||
page_name: `${typeLabels[itemType]} 등록`,
|
||||
item_type: itemType,
|
||||
is_active: true,
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 301,
|
||||
title: '기본 정보',
|
||||
section_type: 'BASIC',
|
||||
order_no: 1,
|
||||
is_collapsible: false,
|
||||
is_default_open: true,
|
||||
fields: [
|
||||
{
|
||||
id: 3001,
|
||||
field_name: '품목코드',
|
||||
field_key: 'item_code',
|
||||
field_type: 'textbox',
|
||||
order_no: 1,
|
||||
is_required: true,
|
||||
is_readonly: true,
|
||||
placeholder: '자동 생성',
|
||||
grid_row: 1,
|
||||
grid_col: 1,
|
||||
grid_span: 1,
|
||||
},
|
||||
{
|
||||
id: 3002,
|
||||
field_name: '품목명',
|
||||
field_key: 'item_name',
|
||||
field_type: 'textbox',
|
||||
order_no: 2,
|
||||
is_required: true,
|
||||
placeholder: '품목명을 입력하세요',
|
||||
grid_row: 1,
|
||||
grid_col: 2,
|
||||
grid_span: 2,
|
||||
},
|
||||
{
|
||||
id: 3003,
|
||||
field_name: '단위',
|
||||
field_key: 'unit',
|
||||
field_type: 'dropdown',
|
||||
order_no: 3,
|
||||
is_required: true,
|
||||
dropdown_config: {
|
||||
options: [
|
||||
{ value: 'EA', label: 'EA (개)' },
|
||||
{ value: 'KG', label: 'KG (킬로그램)' },
|
||||
{ value: 'M', label: 'M (미터)' },
|
||||
{ value: 'L', label: 'L (리터)' },
|
||||
{ value: 'BOX', label: 'BOX (박스)' },
|
||||
],
|
||||
},
|
||||
grid_row: 1,
|
||||
grid_col: 4,
|
||||
grid_span: 1,
|
||||
},
|
||||
{
|
||||
id: 3004,
|
||||
field_name: '규격',
|
||||
field_key: 'specification',
|
||||
field_type: 'textbox',
|
||||
order_no: 4,
|
||||
is_required: false,
|
||||
placeholder: '규격을 입력하세요',
|
||||
grid_row: 2,
|
||||
grid_col: 1,
|
||||
grid_span: 2,
|
||||
},
|
||||
{
|
||||
id: 3005,
|
||||
field_name: '구매 단가',
|
||||
field_key: 'purchase_price',
|
||||
field_type: 'currency',
|
||||
order_no: 5,
|
||||
is_required: false,
|
||||
grid_row: 2,
|
||||
grid_col: 3,
|
||||
grid_span: 1,
|
||||
},
|
||||
{
|
||||
id: 3006,
|
||||
field_name: '안전재고',
|
||||
field_key: 'safety_stock',
|
||||
field_type: 'number',
|
||||
order_no: 6,
|
||||
is_required: false,
|
||||
placeholder: '0',
|
||||
grid_row: 2,
|
||||
grid_col: 4,
|
||||
grid_span: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 302,
|
||||
title: '구매 정보',
|
||||
section_type: 'DETAIL',
|
||||
order_no: 2,
|
||||
is_collapsible: true,
|
||||
is_default_open: false,
|
||||
fields: [
|
||||
{
|
||||
id: 3010,
|
||||
field_name: '구매처',
|
||||
field_key: 'supplier',
|
||||
field_type: 'textbox',
|
||||
order_no: 1,
|
||||
is_required: false,
|
||||
placeholder: '구매처를 입력하세요',
|
||||
grid_row: 1,
|
||||
grid_col: 1,
|
||||
grid_span: 2,
|
||||
},
|
||||
{
|
||||
id: 3011,
|
||||
field_name: '리드타임 (일)',
|
||||
field_key: 'lead_time',
|
||||
field_type: 'number',
|
||||
order_no: 2,
|
||||
is_required: false,
|
||||
placeholder: '0',
|
||||
grid_row: 1,
|
||||
grid_col: 3,
|
||||
grid_span: 1,
|
||||
},
|
||||
{
|
||||
id: 3012,
|
||||
field_name: '비고',
|
||||
field_key: 'note',
|
||||
field_type: 'textarea',
|
||||
order_no: 3,
|
||||
is_required: false,
|
||||
placeholder: '비고를 입력하세요',
|
||||
grid_row: 2,
|
||||
grid_col: 1,
|
||||
grid_span: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
conditionalSections: [],
|
||||
conditionalFields: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock 데이터 가져오기
|
||||
*/
|
||||
function getMockFormStructure(itemType: ItemType, partType?: PartType): FormStructure {
|
||||
switch (itemType) {
|
||||
case 'FG':
|
||||
return getMockFGFormStructure();
|
||||
case 'PT':
|
||||
return getMockPTFormStructure(partType);
|
||||
case 'RM':
|
||||
case 'SM':
|
||||
case 'CS':
|
||||
return getMockMaterialFormStructure(itemType);
|
||||
default:
|
||||
return getMockFGFormStructure();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== API 호출 =====
|
||||
|
||||
/**
|
||||
* 폼 구조 API 호출
|
||||
*/
|
||||
async function fetchFormStructure(
|
||||
itemType: ItemType,
|
||||
partType?: PartType
|
||||
): Promise<FormStructure> {
|
||||
const endpoint = partType
|
||||
? `/api/proxy/item-master/form-structure/${itemType}?part_type=${partType}`
|
||||
: `/api/proxy/item-master/form-structure/${itemType}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint);
|
||||
|
||||
if (!response.ok) {
|
||||
// API가 404면 Mock 데이터 사용
|
||||
if (response.status === 404) {
|
||||
console.warn(`[useFormStructure] API not found, using mock data for ${itemType}`);
|
||||
return getMockFormStructure(itemType, partType);
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result: FormStructureResponse = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || 'API returned unsuccessful response');
|
||||
}
|
||||
|
||||
// API 응답을 FormStructure 형식으로 변환
|
||||
return {
|
||||
page: result.data.page,
|
||||
sections: result.data.sections,
|
||||
conditionalSections: result.data.conditional_sections || [],
|
||||
conditionalFields: result.data.conditional_fields || [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`[useFormStructure] API call failed, using mock data:`, error);
|
||||
// API 실패 시 Mock 데이터 폴백
|
||||
return getMockFormStructure(itemType, partType);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 훅 구현 =====
|
||||
|
||||
interface UseFormStructureOptions {
|
||||
itemType: ItemType;
|
||||
partType?: PartType;
|
||||
enabled?: boolean;
|
||||
useMock?: boolean; // 강제로 Mock 데이터 사용
|
||||
}
|
||||
|
||||
/**
|
||||
* useFormStructure Hook
|
||||
*
|
||||
* @param options - 훅 옵션
|
||||
* @returns 폼 구조 데이터 및 상태
|
||||
*
|
||||
* @example
|
||||
* const { formStructure, isLoading, error, refetch } = useFormStructure({
|
||||
* itemType: 'FG',
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* const { formStructure } = useFormStructure({
|
||||
* itemType: 'PT',
|
||||
* partType: 'BENDING',
|
||||
* });
|
||||
*/
|
||||
export function useFormStructure(options: UseFormStructureOptions): UseFormStructureReturn {
|
||||
const { itemType, partType, enabled = true, useMock = false } = options;
|
||||
|
||||
const [formStructure, setFormStructure] = useState<FormStructure | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// 이전 요청 취소용
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const cacheKey = getCacheKey(itemType, partType);
|
||||
|
||||
/**
|
||||
* 폼 구조 로드
|
||||
*/
|
||||
const loadFormStructure = useCallback(async () => {
|
||||
// 이전 요청 취소
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
// 캐시 확인
|
||||
const cached = getFromCache(cacheKey);
|
||||
if (cached) {
|
||||
setFormStructure(cached);
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let data: FormStructure;
|
||||
|
||||
if (useMock) {
|
||||
// 강제 Mock 모드
|
||||
data = getMockFormStructure(itemType, partType);
|
||||
} else {
|
||||
// API 호출 (실패 시 자동으로 Mock 폴백)
|
||||
data = await fetchFormStructure(itemType, partType);
|
||||
}
|
||||
|
||||
// 캐시에 저장
|
||||
setToCache(cacheKey, data);
|
||||
|
||||
setFormStructure(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Unknown error'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [itemType, partType, cacheKey, useMock]);
|
||||
|
||||
/**
|
||||
* 강제 새로고침
|
||||
*/
|
||||
const refetch = useCallback(async () => {
|
||||
// 캐시 무효화
|
||||
formStructureCache.delete(cacheKey);
|
||||
await loadFormStructure();
|
||||
}, [cacheKey, loadFormStructure]);
|
||||
|
||||
// 마운트 시 및 의존성 변경 시 로드
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
loadFormStructure();
|
||||
}
|
||||
|
||||
return () => {
|
||||
// 언마운트 시 요청 취소
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [enabled, loadFormStructure]);
|
||||
|
||||
return {
|
||||
formStructure,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 캐시 유틸리티 =====
|
||||
|
||||
/**
|
||||
* 폼 구조 캐시 초기화
|
||||
*/
|
||||
export function clearFormStructureCache(): void {
|
||||
formStructureCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 품목 유형의 캐시 무효화
|
||||
*/
|
||||
export function invalidateFormStructureCache(itemType: ItemType, partType?: PartType): void {
|
||||
const key = getCacheKey(itemType, partType);
|
||||
formStructureCache.delete(key);
|
||||
}
|
||||
|
||||
export default useFormStructure;
|
||||
208
src/components/items/DynamicItemForm/index.tsx
Normal file
208
src/components/items/DynamicItemForm/index.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* DynamicItemForm Component
|
||||
*
|
||||
* 품목기준관리 설정에 따라 동적으로 폼을 렌더링하는 메인 컴포넌트
|
||||
*
|
||||
* 특징:
|
||||
* - API에서 폼 구조 로드 (품목 유형별)
|
||||
* - 조건부 섹션/필드 표시
|
||||
* - 동적 유효성 검증
|
||||
* - 기존 특수 컴포넌트 (BOM, 전개도) 통합 가능
|
||||
*
|
||||
* @example
|
||||
* // 신규 등록
|
||||
* <DynamicItemForm
|
||||
* itemType="FG"
|
||||
* onSubmit={handleCreate}
|
||||
* onCancel={handleCancel}
|
||||
* />
|
||||
*
|
||||
* @example
|
||||
* // 수정
|
||||
* <DynamicItemForm
|
||||
* itemType="PT"
|
||||
* partType="BENDING"
|
||||
* initialValues={existingItem}
|
||||
* onSubmit={handleUpdate}
|
||||
* onCancel={handleCancel}
|
||||
* />
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { DynamicFormRenderer } from './DynamicFormRenderer';
|
||||
import { useFormStructure, useDynamicFormState } from './hooks';
|
||||
import type { ItemType, PartType } from '@/types/item';
|
||||
import type { FormData } from './types';
|
||||
|
||||
interface DynamicItemFormProps {
|
||||
/** 품목 유형 */
|
||||
itemType: ItemType;
|
||||
/** 부품 유형 (PT인 경우) */
|
||||
partType?: PartType;
|
||||
/** 초기 값 (수정 모드) */
|
||||
initialValues?: FormData;
|
||||
/** 제출 핸들러 */
|
||||
onSubmit: (data: FormData) => Promise<void>;
|
||||
/** 취소 핸들러 */
|
||||
onCancel?: () => void;
|
||||
/** 폼 비활성화 */
|
||||
disabled?: boolean;
|
||||
/** Mock 데이터 사용 (API 미구현 시) */
|
||||
useMock?: boolean;
|
||||
}
|
||||
|
||||
export function DynamicItemForm({
|
||||
itemType,
|
||||
partType,
|
||||
initialValues,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
disabled = false,
|
||||
useMock = true, // 기본적으로 Mock 사용 (API 구현 후 false로 변경)
|
||||
}: DynamicItemFormProps) {
|
||||
// 폼 구조 로드
|
||||
const {
|
||||
formStructure,
|
||||
isLoading: isLoadingStructure,
|
||||
error: structureError,
|
||||
refetch: refetchStructure,
|
||||
} = useFormStructure({
|
||||
itemType,
|
||||
partType,
|
||||
useMock,
|
||||
});
|
||||
|
||||
// 폼 상태 관리
|
||||
const {
|
||||
state,
|
||||
setValue,
|
||||
setTouched,
|
||||
validate,
|
||||
reset,
|
||||
handleSubmit,
|
||||
} = useDynamicFormState({
|
||||
sections: formStructure?.sections || [],
|
||||
initialValues,
|
||||
});
|
||||
|
||||
// 폼 구조가 변경되면 값 초기화
|
||||
useEffect(() => {
|
||||
if (formStructure) {
|
||||
reset(initialValues);
|
||||
}
|
||||
}, [formStructure, initialValues, reset]);
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoadingStructure) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="ml-3 text-muted-foreground">폼 구조 로딩 중...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (structureError) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="flex items-center justify-between">
|
||||
<span>폼 구조를 불러오는데 실패했습니다: {structureError.message}</span>
|
||||
<Button variant="outline" size="sm" onClick={refetchStructure}>
|
||||
다시 시도
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// 폼 구조가 없는 경우
|
||||
if (!formStructure) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
해당 품목 유형({itemType})의 폼 구조가 정의되지 않았습니다.
|
||||
품목기준관리에서 페이지를 먼저 설정해주세요.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// 폼 제출
|
||||
const onFormSubmit = handleSubmit(onSubmit);
|
||||
|
||||
return (
|
||||
<form onSubmit={onFormSubmit} className="space-y-6">
|
||||
{/* 페이지 정보 (디버그용, 필요시 제거) */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div className="text-xs text-muted-foreground bg-gray-50 p-2 rounded">
|
||||
📄 {formStructure.page.page_name} | 품목유형: {formStructure.page.item_type}
|
||||
{formStructure.page.part_type && ` | 부품유형: ${formStructure.page.part_type}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 동적 폼 렌더러 */}
|
||||
<DynamicFormRenderer
|
||||
sections={formStructure.sections}
|
||||
conditionalSections={formStructure.conditionalSections}
|
||||
conditionalFields={formStructure.conditionalFields}
|
||||
values={state.values}
|
||||
errors={state.errors}
|
||||
onChange={(fieldKey, value) => {
|
||||
setValue(fieldKey, value);
|
||||
}}
|
||||
onBlur={(fieldKey) => {
|
||||
setTouched(fieldKey);
|
||||
}}
|
||||
disabled={disabled || state.isSubmitting}
|
||||
/>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t">
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={state.isSubmitting}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={disabled || state.isSubmitting}
|
||||
>
|
||||
{state.isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
저장 중...
|
||||
</>
|
||||
) : (
|
||||
'저장'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// 하위 컴포넌트 및 훅 re-export
|
||||
export { DynamicFormRenderer } from './DynamicFormRenderer';
|
||||
export { DynamicSection } from './DynamicSection';
|
||||
export { DynamicField } from './DynamicField';
|
||||
export {
|
||||
useFormStructure,
|
||||
useConditionalFields,
|
||||
useDynamicFormState,
|
||||
clearFormStructureCache,
|
||||
invalidateFormStructureCache,
|
||||
} from './hooks';
|
||||
export * from './types';
|
||||
|
||||
export default DynamicItemForm;
|
||||
402
src/components/items/DynamicItemForm/types.ts
Normal file
402
src/components/items/DynamicItemForm/types.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* 동적 폼 렌더링 타입 정의
|
||||
*
|
||||
* API 응답 구조와 동적 폼 컴포넌트에서 사용하는 타입들
|
||||
* 참조: [PLAN-2025-11-28] dynamic-item-form-implementation.md
|
||||
*/
|
||||
|
||||
import type { ItemType, PartType } from '@/types/item';
|
||||
|
||||
// ===== 필드 타입 =====
|
||||
|
||||
/**
|
||||
* 필드 입력 타입
|
||||
*/
|
||||
export type FieldType =
|
||||
| 'textbox' // 단일 텍스트 입력
|
||||
| 'textarea' // 여러 줄 텍스트
|
||||
| 'dropdown' // 선택 목록
|
||||
| 'searchable-dropdown' // 검색 가능 선택
|
||||
| 'number' // 숫자 입력
|
||||
| 'currency' // 통화 입력
|
||||
| 'date' // 날짜 선택
|
||||
| 'date-range' // 기간 선택
|
||||
| 'checkbox' // 체크박스
|
||||
| 'switch' // 토글 스위치
|
||||
| 'file' // 파일 업로드
|
||||
| 'custom:drawing-canvas' // 전개도 그리기
|
||||
| 'custom:bending-detail-table' // 전개도 상세 입력
|
||||
| 'custom:bom-table'; // BOM 관리 테이블
|
||||
|
||||
/**
|
||||
* 섹션 타입
|
||||
*/
|
||||
export type SectionType =
|
||||
| 'BASIC' // 기본 정보
|
||||
| 'DETAIL' // 상세 정보
|
||||
| 'BOM' // 부품 구성
|
||||
| 'BENDING' // 절곡 정보
|
||||
| 'CERTIFICATION'// 인정 정보
|
||||
| 'CUSTOM'; // 커스텀
|
||||
|
||||
// ===== 유효성 검증 =====
|
||||
|
||||
/**
|
||||
* 필드 유효성 검증 규칙
|
||||
*/
|
||||
export interface ValidationRules {
|
||||
maxLength?: number;
|
||||
minLength?: number;
|
||||
max?: number;
|
||||
min?: number;
|
||||
pattern?: string;
|
||||
patternMessage?: string;
|
||||
custom?: string; // 커스텀 검증 함수 이름
|
||||
}
|
||||
|
||||
// ===== 조건부 렌더링 =====
|
||||
|
||||
/**
|
||||
* 조건 연산자
|
||||
*/
|
||||
export type ConditionOperator =
|
||||
| 'equals'
|
||||
| 'not_equals'
|
||||
| 'in'
|
||||
| 'not_in'
|
||||
| 'contains'
|
||||
| 'greater_than'
|
||||
| 'less_than';
|
||||
|
||||
/**
|
||||
* 조건 정의
|
||||
*/
|
||||
export interface Condition {
|
||||
field_key: string;
|
||||
operator: ConditionOperator;
|
||||
value: string | number | boolean | string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 섹션 표시 규칙
|
||||
*/
|
||||
export interface ConditionalSection {
|
||||
condition: Condition;
|
||||
show_sections: number[];
|
||||
hide_sections?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 필드 표시 규칙
|
||||
*/
|
||||
export interface ConditionalField {
|
||||
condition: Condition;
|
||||
show_fields: number[];
|
||||
hide_fields?: number[];
|
||||
}
|
||||
|
||||
// ===== 필드 옵션 =====
|
||||
|
||||
/**
|
||||
* 드롭다운 옵션
|
||||
*/
|
||||
export interface DropdownOption {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 드롭다운 설정
|
||||
*/
|
||||
export interface DropdownConfig {
|
||||
options?: DropdownOption[];
|
||||
options_endpoint?: string; // API에서 옵션 로드
|
||||
placeholder?: string;
|
||||
allow_empty?: boolean;
|
||||
searchable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 업로드 설정
|
||||
*/
|
||||
export interface FileConfig {
|
||||
accept?: string; // 허용 파일 타입 (예: ".pdf,.doc")
|
||||
max_size?: number; // 최대 파일 크기 (bytes)
|
||||
multiple?: boolean;
|
||||
upload_endpoint?: string;
|
||||
}
|
||||
|
||||
// ===== BOM 섹션 설정 =====
|
||||
|
||||
/**
|
||||
* BOM 컬럼 정의
|
||||
*/
|
||||
export interface BOMColumn {
|
||||
key: string;
|
||||
label: string;
|
||||
width?: number;
|
||||
type?: 'text' | 'number' | 'dropdown';
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* BOM 섹션 설정
|
||||
*/
|
||||
export interface BOMConfig {
|
||||
columns: BOMColumn[];
|
||||
allow_search: boolean;
|
||||
search_endpoint?: string;
|
||||
allow_add: boolean;
|
||||
allow_delete: boolean;
|
||||
allow_reorder?: boolean;
|
||||
}
|
||||
|
||||
// ===== 동적 필드 =====
|
||||
|
||||
/**
|
||||
* 동적 필드 정의
|
||||
* API 응답: sections[].fields[]
|
||||
*/
|
||||
export interface DynamicField {
|
||||
id: number;
|
||||
field_name: string; // 표시 이름 (예: "품목명")
|
||||
field_key: string; // 데이터 키 (예: "item_name")
|
||||
field_type: FieldType;
|
||||
order_no: number;
|
||||
is_required: boolean;
|
||||
is_readonly?: boolean;
|
||||
placeholder?: string;
|
||||
default_value?: string | number | boolean;
|
||||
validation_rules?: ValidationRules;
|
||||
help_text?: string;
|
||||
|
||||
// 그리드 레이아웃
|
||||
grid_row?: number;
|
||||
grid_col?: number;
|
||||
grid_span?: number; // 1-4 (4 = 전체 너비)
|
||||
|
||||
// 타입별 설정
|
||||
dropdown_config?: DropdownConfig;
|
||||
file_config?: FileConfig;
|
||||
|
||||
// 조건부 표시
|
||||
display_condition?: Condition;
|
||||
}
|
||||
|
||||
// ===== 동적 섹션 =====
|
||||
|
||||
/**
|
||||
* 동적 섹션 정의
|
||||
* API 응답: sections[]
|
||||
*/
|
||||
export interface DynamicSection {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
section_type: SectionType;
|
||||
order_no: number;
|
||||
is_collapsible: boolean;
|
||||
is_default_open: boolean;
|
||||
|
||||
// 섹션 내 필드들
|
||||
fields: DynamicField[];
|
||||
|
||||
// BOM 섹션인 경우
|
||||
bom_config?: BOMConfig;
|
||||
|
||||
// 조건부 표시 (섹션 레벨)
|
||||
display_condition?: Condition;
|
||||
}
|
||||
|
||||
// ===== 페이지 정의 =====
|
||||
|
||||
/**
|
||||
* 페이지 정보
|
||||
* API 응답: page
|
||||
*/
|
||||
export interface PageInfo {
|
||||
id: number;
|
||||
page_name: string;
|
||||
item_type: ItemType;
|
||||
part_type?: PartType; // PT인 경우에만
|
||||
description?: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
// ===== API 응답 구조 =====
|
||||
|
||||
/**
|
||||
* 폼 구조 API 응답
|
||||
* GET /api/v1/item-master/form-structure/{item_type}
|
||||
*/
|
||||
export interface FormStructureResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
page: PageInfo;
|
||||
sections: DynamicSection[];
|
||||
conditional_sections?: ConditionalSection[];
|
||||
conditional_fields?: ConditionalField[];
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 구조 데이터 (클라이언트용)
|
||||
*/
|
||||
export interface FormStructure {
|
||||
page: PageInfo;
|
||||
sections: DynamicSection[];
|
||||
conditionalSections: ConditionalSection[];
|
||||
conditionalFields: ConditionalField[];
|
||||
}
|
||||
|
||||
// ===== 폼 상태 =====
|
||||
|
||||
/**
|
||||
* 폼 값 타입
|
||||
*/
|
||||
export type FormValue = string | number | boolean | null | undefined | File | File[];
|
||||
|
||||
/**
|
||||
* 폼 데이터 (동적 키-값 쌍)
|
||||
*/
|
||||
export type FormData = Record<string, FormValue>;
|
||||
|
||||
/**
|
||||
* 필드 에러 상태
|
||||
*/
|
||||
export interface FieldError {
|
||||
field_key: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 상태
|
||||
*/
|
||||
export interface FormState {
|
||||
values: FormData;
|
||||
errors: Record<string, string>;
|
||||
touched: Record<string, boolean>;
|
||||
isSubmitting: boolean;
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
// ===== 컴포넌트 Props =====
|
||||
|
||||
/**
|
||||
* DynamicField 컴포넌트 Props
|
||||
*/
|
||||
export interface DynamicFieldProps {
|
||||
field: DynamicField;
|
||||
value: FormValue;
|
||||
error?: string;
|
||||
onChange: (value: FormValue) => void;
|
||||
onBlur: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DynamicSection 컴포넌트 Props
|
||||
*/
|
||||
export interface DynamicSectionProps {
|
||||
section: DynamicSection;
|
||||
values: FormData;
|
||||
errors: Record<string, string>;
|
||||
onChange: (fieldKey: string, value: FormValue) => void;
|
||||
onBlur: (fieldKey: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DynamicFormRenderer 컴포넌트 Props
|
||||
*/
|
||||
export interface DynamicFormRendererProps {
|
||||
sections: DynamicSection[];
|
||||
conditionalSections: ConditionalSection[];
|
||||
conditionalFields: ConditionalField[];
|
||||
values: FormData;
|
||||
errors: Record<string, string>;
|
||||
onChange: (fieldKey: string, value: FormValue) => void;
|
||||
onBlur: (fieldKey: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// ===== 훅 반환 타입 =====
|
||||
|
||||
/**
|
||||
* useFormStructure 훅 반환 타입
|
||||
*/
|
||||
export interface UseFormStructureReturn {
|
||||
formStructure: FormStructure | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* useDynamicFormState 훅 반환 타입
|
||||
*/
|
||||
export interface UseDynamicFormStateReturn {
|
||||
state: FormState;
|
||||
setValue: (fieldKey: string, value: FormValue) => void;
|
||||
setValues: (values: FormData) => void;
|
||||
setError: (fieldKey: string, message: string) => void;
|
||||
clearError: (fieldKey: string) => void;
|
||||
setTouched: (fieldKey: string) => void;
|
||||
validate: () => boolean;
|
||||
reset: (initialValues?: FormData) => void;
|
||||
handleSubmit: (onSubmit: (data: FormData) => Promise<void>) => (e: React.FormEvent) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* useConditionalFields 훅 반환 타입
|
||||
*/
|
||||
export interface UseConditionalFieldsReturn {
|
||||
visibleSections: number[];
|
||||
visibleFields: Record<number, number[]>; // sectionId -> fieldIds
|
||||
isFieldVisible: (sectionId: number, fieldId: number) => boolean;
|
||||
isSectionVisible: (sectionId: number) => boolean;
|
||||
}
|
||||
|
||||
// ===== 유틸리티 타입 =====
|
||||
|
||||
/**
|
||||
* 필드 타입 → 기본값 매핑
|
||||
*/
|
||||
export const FIELD_TYPE_DEFAULTS: Record<FieldType, FormValue> = {
|
||||
textbox: '',
|
||||
textarea: '',
|
||||
dropdown: '',
|
||||
'searchable-dropdown': '',
|
||||
number: 0,
|
||||
currency: 0,
|
||||
date: null,
|
||||
'date-range': null,
|
||||
checkbox: false,
|
||||
switch: false,
|
||||
file: null,
|
||||
'custom:drawing-canvas': null,
|
||||
'custom:bending-detail-table': null,
|
||||
'custom:bom-table': null,
|
||||
};
|
||||
|
||||
/**
|
||||
* 필드 타입 → 컴포넌트 이름 매핑
|
||||
*/
|
||||
export const FIELD_TYPE_COMPONENTS: Record<FieldType, string> = {
|
||||
textbox: 'TextField',
|
||||
textarea: 'TextField',
|
||||
dropdown: 'DropdownField',
|
||||
'searchable-dropdown': 'DropdownField',
|
||||
number: 'NumberField',
|
||||
currency: 'NumberField',
|
||||
date: 'DateField',
|
||||
'date-range': 'DateField',
|
||||
checkbox: 'CheckboxField',
|
||||
switch: 'CheckboxField',
|
||||
file: 'FileField',
|
||||
'custom:drawing-canvas': 'CustomField',
|
||||
'custom:bending-detail-table': 'CustomField',
|
||||
'custom:bom-table': 'CustomField',
|
||||
};
|
||||
200
src/components/items/ItemFormWrapper.tsx
Normal file
200
src/components/items/ItemFormWrapper.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* ItemFormWrapper - Feature Flag 기반 폼 전환 래퍼
|
||||
*
|
||||
* 기존 ItemForm과 새 DynamicItemForm 간 전환을 제어
|
||||
* - 환경변수 또는 localStorage로 제어 가능
|
||||
* - 품목 유형별로 다르게 적용 가능
|
||||
*
|
||||
* @example
|
||||
* // 기존과 동일하게 사용
|
||||
* <ItemFormWrapper mode="create" onSubmit={handleSubmit} />
|
||||
*
|
||||
* // 동적 폼 강제 사용
|
||||
* <ItemFormWrapper mode="create" onSubmit={handleSubmit} useDynamicForm />
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { ItemType } from '@/types/item';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
import ItemForm from './ItemForm';
|
||||
import DynamicItemForm from './DynamicItemForm';
|
||||
import type { FormData } from './DynamicItemForm/types';
|
||||
|
||||
// Feature Flag 설정
|
||||
const FEATURE_FLAGS = {
|
||||
// 환경변수로 전체 활성화 (기본값: false)
|
||||
ENABLE_DYNAMIC_FORM: process.env.NEXT_PUBLIC_ENABLE_DYNAMIC_FORM === 'true',
|
||||
|
||||
// 품목 유형별 활성화 (점진적 마이그레이션용)
|
||||
ENABLED_ITEM_TYPES: ['FG'] as ItemType[], // 초기에는 FG만 활성화
|
||||
};
|
||||
|
||||
interface ItemFormWrapperProps {
|
||||
mode: 'create' | 'edit';
|
||||
initialData?: CreateItemFormData;
|
||||
onSubmit: (data: CreateItemFormData) => Promise<void>;
|
||||
/** 동적 폼 강제 사용 (테스트용) */
|
||||
useDynamicForm?: boolean;
|
||||
/** 특정 품목 유형으로 시작 (동적 폼에서 사용) */
|
||||
defaultItemType?: ItemType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 동적 폼 사용 여부 결정
|
||||
*/
|
||||
function shouldUseDynamicForm(
|
||||
forceDynamic: boolean | undefined,
|
||||
itemType: ItemType | undefined
|
||||
): boolean {
|
||||
// 강제 동적 폼 사용
|
||||
if (forceDynamic) return true;
|
||||
|
||||
// 전체 비활성화
|
||||
if (!FEATURE_FLAGS.ENABLE_DYNAMIC_FORM) return false;
|
||||
|
||||
// 품목 유형별 활성화 체크
|
||||
if (itemType && FEATURE_FLAGS.ENABLED_ITEM_TYPES.includes(itemType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* DynamicItemForm 데이터 → CreateItemFormData 변환
|
||||
*/
|
||||
function transformDynamicToLegacy(
|
||||
dynamicData: FormData,
|
||||
itemType: ItemType
|
||||
): CreateItemFormData {
|
||||
return {
|
||||
itemType,
|
||||
itemCode: String(dynamicData.item_code || ''),
|
||||
itemName: String(dynamicData.item_name || ''),
|
||||
unit: String(dynamicData.unit || 'EA'),
|
||||
specification: dynamicData.specification ? String(dynamicData.specification) : undefined,
|
||||
isActive: dynamicData.is_active !== false,
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
// 추가 필드들은 동적으로 매핑
|
||||
productCategory: dynamicData.product_category ? String(dynamicData.product_category) : undefined,
|
||||
partType: dynamicData.part_type ? String(dynamicData.part_type) : undefined,
|
||||
material: dynamicData.material ? String(dynamicData.material) : undefined,
|
||||
salesPrice: dynamicData.sales_price ? Number(dynamicData.sales_price) : undefined,
|
||||
purchasePrice: dynamicData.purchase_price ? Number(dynamicData.purchase_price) : undefined,
|
||||
marginRate: dynamicData.margin_rate ? Number(dynamicData.margin_rate) : undefined,
|
||||
certificationNumber: dynamicData.certification_number ? String(dynamicData.certification_number) : undefined,
|
||||
certificationStartDate: dynamicData.certification_start_date ? String(dynamicData.certification_start_date) : undefined,
|
||||
certificationEndDate: dynamicData.certification_end_date ? String(dynamicData.certification_end_date) : undefined,
|
||||
} as CreateItemFormData;
|
||||
}
|
||||
|
||||
export default function ItemFormWrapper({
|
||||
mode,
|
||||
initialData,
|
||||
onSubmit,
|
||||
useDynamicForm: forceDynamic,
|
||||
defaultItemType,
|
||||
}: ItemFormWrapperProps) {
|
||||
// 현재 선택된 품목 유형 (동적 폼에서 변경 가능)
|
||||
const [currentItemType, setCurrentItemType] = useState<ItemType>(
|
||||
defaultItemType || initialData?.itemType || 'FG'
|
||||
);
|
||||
|
||||
// 개발 모드에서 localStorage로 오버라이드 가능
|
||||
const [localOverride, setLocalOverride] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
|
||||
const override = localStorage.getItem('FORCE_DYNAMIC_FORM');
|
||||
if (override !== null) {
|
||||
setLocalOverride(override === 'true');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 동적 폼 사용 여부 결정
|
||||
const useDynamic = localOverride !== null
|
||||
? localOverride
|
||||
: shouldUseDynamicForm(forceDynamic, currentItemType);
|
||||
|
||||
// 동적 폼 제출 핸들러
|
||||
const handleDynamicSubmit = async (dynamicData: FormData) => {
|
||||
const legacyData = transformDynamicToLegacy(dynamicData, currentItemType);
|
||||
await onSubmit(legacyData);
|
||||
};
|
||||
|
||||
// 동적 폼 초기 데이터 변환
|
||||
const dynamicInitialValues: FormData | undefined = initialData
|
||||
? {
|
||||
item_code: initialData.itemCode,
|
||||
item_name: initialData.itemName,
|
||||
unit: initialData.unit,
|
||||
specification: initialData.specification,
|
||||
is_active: initialData.isActive,
|
||||
product_category: initialData.productCategory,
|
||||
part_type: initialData.partType,
|
||||
material: initialData.material,
|
||||
sales_price: initialData.salesPrice,
|
||||
purchase_price: initialData.purchasePrice,
|
||||
margin_rate: initialData.marginRate,
|
||||
certification_number: initialData.certificationNumber,
|
||||
certification_start_date: initialData.certificationStartDate,
|
||||
certification_end_date: initialData.certificationEndDate,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// 개발 모드 토글 UI
|
||||
const DevModeToggle = () => {
|
||||
if (process.env.NODE_ENV !== 'development') return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 bg-gray-900 text-white p-3 rounded-lg shadow-lg text-xs">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span>🧪 폼 모드:</span>
|
||||
<span className={useDynamic ? 'text-green-400' : 'text-yellow-400'}>
|
||||
{useDynamic ? 'Dynamic' : 'Legacy'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newValue = !useDynamic;
|
||||
localStorage.setItem('FORCE_DYNAMIC_FORM', String(newValue));
|
||||
setLocalOverride(newValue);
|
||||
}}
|
||||
className="bg-blue-600 hover:bg-blue-700 px-2 py-1 rounded text-xs"
|
||||
>
|
||||
Toggle Form Mode
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (useDynamic) {
|
||||
return (
|
||||
<>
|
||||
<DynamicItemForm
|
||||
itemType={currentItemType}
|
||||
initialValues={dynamicInitialValues}
|
||||
onSubmit={handleDynamicSubmit}
|
||||
onCancel={() => window.history.back()}
|
||||
useMock={true} // API 구현 전까지 Mock 사용
|
||||
/>
|
||||
<DevModeToggle />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ItemForm mode={mode} initialData={initialData} onSubmit={onSubmit} />
|
||||
<DevModeToggle />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 개별 export
|
||||
export { ItemForm, DynamicItemForm };
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user