Merge pull request 'feature/screen-management' (#306) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/306
This commit is contained in:
commit
932eb288c6
|
|
@ -0,0 +1,680 @@
|
|||
# Screen Designer 2.0 리뉴얼 계획: 컴포넌트 통합 및 속성 기반 고도화
|
||||
|
||||
## 1. 개요
|
||||
|
||||
현재 **68개 이상**으로 파편화된 화면 관리 컴포넌트들을 **9개의 핵심 통합 컴포넌트(Unified Components)**로 재편합니다.
|
||||
각 컴포넌트는 **속성(Config)** 설정을 통해 다양한 형태(View Mode)와 기능(Behavior)을 수행하도록 설계되어, 유지보수성과 확장성을 극대화합니다.
|
||||
|
||||
### 현재 컴포넌트 현황 (AS-IS)
|
||||
|
||||
| 카테고리 | 파일 수 | 주요 파일들 |
|
||||
| :------------- | :-----: | :------------------------------------------------------------------ |
|
||||
| Widget 타입별 | 14개 | TextWidget, NumberWidget, SelectWidget, DateWidget, EntityWidget 등 |
|
||||
| Config Panel | 28개 | TextConfigPanel, SelectConfigPanel, DateConfigPanel 등 |
|
||||
| WebType Config | 11개 | TextTypeConfigPanel, SelectTypeConfigPanel 등 |
|
||||
| 기타 패널 | 15개+ | PropertiesPanel, DetailSettingsPanel 등 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 통합 전략: 9 Core Widgets
|
||||
|
||||
### A. 입력 위젯 (Input Widgets) - 5종
|
||||
|
||||
단순 데이터 입력 필드를 통합합니다.
|
||||
|
||||
| 통합 컴포넌트 (TO-BE) | 포함되는 기존 컴포넌트 (AS-IS) | 핵심 속성 (Configuration) |
|
||||
| :-------------------- | :------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **1. Unified Select** | Select, Radio, Checkbox, Boolean, Code, Entity, Combobox, Toggle | **`mode`**: "dropdown" / "radio" / "check" / "tag"<br>**`source`**: "static" / "code" / "db" / "api"<br>**`dependency`**: { parentField: "..." } |
|
||||
| **2. Unified Input** | Text, Number, Email, Tel, Password, Color, Search, Integer, Decimal | **`type`**: "text" / "number" / "password"<br>**`format`**: "email", "currency", "biz_no"<br>**`mask`**: "000-0000-0000" |
|
||||
| **3. Unified Date** | Date, Time, DateTime, DateRange, Month, Year | **`type`**: "date" / "time" / "datetime"<br>**`range`**: true/false |
|
||||
| **4. Unified Text** | Textarea, RichEditor, Markdown, HTML | **`mode`**: "simple" / "rich" / "code"<br>**`rows`**: number |
|
||||
| **5. Unified Media** | File, Image, Video, Audio, Attachment | **`type`**: "file" / "image"<br>**`multiple`**: true/false<br>**`preview`**: true/false |
|
||||
|
||||
### B. 구조/데이터 위젯 (Structure & Data Widgets) - 4종
|
||||
|
||||
레이아웃 배치와 데이터 시각화를 담당합니다.
|
||||
|
||||
| 통합 컴포넌트 (TO-BE) | 포함되는 기존 컴포넌트 (AS-IS) | 핵심 속성 (Configuration) | 활용 예시 |
|
||||
| :-------------------- | :-------------------------------------------------- | :------------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------- |
|
||||
| **6. Unified List** | **Table, Card List, Repeater, DataGrid, List View** | **`viewMode`**: "table" / "card" / "kanban"<br>**`editable`**: true/false | - `viewMode='table'`: 엑셀형 리스트<br>- `viewMode='card'`: **카드 디스플레이**<br>- `editable=true`: **반복 필드 그룹** |
|
||||
| **7. Unified Layout** | **Row, Col, Split Panel, Grid, Spacer** | **`type`**: "grid" / "split" / "flex"<br>**`columns`**: number | - `type='split'`: **화면 분할 패널**<br>- `type='grid'`: 격자 레이아웃 |
|
||||
| **8. Unified Group** | Tab, Accordion, FieldSet, Modal, Section | **`type`**: "tab" / "accordion" / "modal" | - 탭이나 아코디언으로 내용 그룹화 |
|
||||
| **9. Unified Biz** | **Rack Structure**, Calendar, Gantt | **`type`**: "rack" / "calendar" / "gantt" | - `type='rack'`: **랙 구조 설정**<br>- 특수 비즈니스 로직 플러그인 탑재 |
|
||||
|
||||
### C. Config Panel 통합 전략 (핵심)
|
||||
|
||||
현재 28개의 ConfigPanel을 **1개의 DynamicConfigPanel**로 통합합니다.
|
||||
|
||||
| AS-IS | TO-BE | 방식 |
|
||||
| :-------------------- | :--------------------- | :------------------------------- |
|
||||
| TextConfigPanel.tsx | | |
|
||||
| SelectConfigPanel.tsx | **DynamicConfigPanel** | DB의 `sys_input_type` 테이블에서 |
|
||||
| DateConfigPanel.tsx | (단일 컴포넌트) | JSON Schema를 읽어 |
|
||||
| NumberConfigPanel.tsx | | 속성 UI를 동적 생성 |
|
||||
| ... 24개 더 | | |
|
||||
|
||||
---
|
||||
|
||||
## 3. 구현 시나리오 (속성 기반 변신)
|
||||
|
||||
### Case 1: "테이블을 카드 리스트로 변경"
|
||||
|
||||
- **AS-IS**: `DataTable` 컴포넌트를 삭제하고 `CardList` 컴포넌트를 새로 추가해야 함.
|
||||
- **TO-BE**: `UnifiedList`의 속성창에서 **[View Mode]**를 `Table` → `Card`로 변경하면 즉시 반영.
|
||||
|
||||
### Case 2: "단일 선택을 라디오 버튼으로 변경"
|
||||
|
||||
- **AS-IS**: `SelectWidget`을 삭제하고 `RadioWidget` 추가.
|
||||
- **TO-BE**: `UnifiedSelect` 속성창에서 **[Display Mode]**를 `Dropdown` → `Radio`로 변경.
|
||||
|
||||
### Case 3: "입력 폼에 반복 필드(Repeater) 추가"
|
||||
|
||||
- **TO-BE**: `UnifiedList` 컴포넌트 배치 후 `editable: true`, `viewMode: "table"` 설정.
|
||||
|
||||
---
|
||||
|
||||
## 4. 실행 로드맵 (Action Plan)
|
||||
|
||||
### Phase 0: 준비 단계 (1주)
|
||||
|
||||
통합 작업 전 필수 분석 및 설계를 진행합니다.
|
||||
|
||||
- [ ] 기존 컴포넌트 사용 현황 분석 (화면별 위젯 사용 빈도 조사)
|
||||
- [ ] 데이터 마이그레이션 전략 설계 (`widgetType` → `UnifiedWidget.type` 매핑 정의)
|
||||
- [ ] `sys_input_type` 테이블 JSON Schema 설계
|
||||
- [ ] DynamicConfigPanel 프로토타입 설계
|
||||
|
||||
### Phase 1: 입력 위젯 통합 (2주)
|
||||
|
||||
가장 중복이 많고 효과가 즉각적인 입력 필드부터 통합합니다.
|
||||
|
||||
- [ ] **UnifiedInput 구현**: Text, Number, Email, Tel, Password 통합
|
||||
- [ ] **UnifiedSelect 구현**: Select, Radio, Checkbox, Boolean 통합
|
||||
- [ ] **UnifiedDate 구현**: Date, DateTime, Time 통합
|
||||
- [ ] 기존 위젯과 **병행 운영** (deprecated 마킹, 삭제하지 않음)
|
||||
|
||||
### Phase 2: Config Panel 통합 (2주)
|
||||
|
||||
28개의 ConfigPanel을 단일 동적 패널로 통합합니다.
|
||||
|
||||
- [ ] **DynamicConfigPanel 구현**: DB 스키마 기반 속성 UI 자동 생성
|
||||
- [ ] `sys_input_type` 테이블에 위젯별 JSON Schema 정의 저장
|
||||
- [ ] 기존 ConfigPanel과 **병행 운영** (삭제하지 않음)
|
||||
|
||||
### Phase 3: 데이터/레이아웃 위젯 통합 (2주)
|
||||
|
||||
프로젝트의 데이터를 보여주는 핵심 뷰를 통합합니다.
|
||||
|
||||
- [ ] **UnifiedList 구현**: Table, Card, Repeater 통합 렌더러 개발
|
||||
- [ ] **UnifiedLayout 구현**: Split Panel, Grid, Flex 통합
|
||||
- [ ] **UnifiedGroup 구현**: Tab, Accordion, Modal 통합
|
||||
|
||||
### Phase 4: 안정화 및 마이그레이션 (2주)
|
||||
|
||||
신규 컴포넌트 안정화 후 점진적 전환을 진행합니다.
|
||||
|
||||
- [ ] 신규 화면은 Unified 컴포넌트만 사용하도록 가이드
|
||||
- [ ] 기존 화면 데이터 마이그레이션 스크립트 개발
|
||||
- [ ] 마이그레이션 테스트 (스테이징 환경)
|
||||
- [ ] 문서화 및 개발 가이드 작성
|
||||
|
||||
### Phase 5: 레거시 정리 (추후 결정)
|
||||
|
||||
충분한 안정화 기간 후 레거시 컴포넌트 정리를 검토합니다.
|
||||
|
||||
- [ ] 사용 현황 재분석 (Unified 전환율 확인)
|
||||
- [ ] 미전환 화면 목록 정리
|
||||
- [ ] 레거시 컴포넌트 삭제 여부 결정 (별도 회의)
|
||||
|
||||
---
|
||||
|
||||
## 5. 데이터 마이그레이션 전략
|
||||
|
||||
### 5.1 위젯 타입 매핑 테이블
|
||||
|
||||
기존 `widgetType`을 신규 Unified 컴포넌트로 매핑합니다.
|
||||
|
||||
| 기존 widgetType | 신규 컴포넌트 | 속성 설정 |
|
||||
| :-------------- | :------------ | :------------------------------ |
|
||||
| `text` | UnifiedInput | `type: "text"` |
|
||||
| `number` | UnifiedInput | `type: "number"` |
|
||||
| `email` | UnifiedInput | `type: "text", format: "email"` |
|
||||
| `tel` | UnifiedInput | `type: "text", format: "tel"` |
|
||||
| `select` | UnifiedSelect | `mode: "dropdown"` |
|
||||
| `radio` | UnifiedSelect | `mode: "radio"` |
|
||||
| `checkbox` | UnifiedSelect | `mode: "check"` |
|
||||
| `date` | UnifiedDate | `type: "date"` |
|
||||
| `datetime` | UnifiedDate | `type: "datetime"` |
|
||||
| `textarea` | UnifiedText | `mode: "simple"` |
|
||||
| `file` | UnifiedMedia | `type: "file"` |
|
||||
| `image` | UnifiedMedia | `type: "image"` |
|
||||
|
||||
### 5.2 마이그레이션 원칙
|
||||
|
||||
1. **비파괴적 전환**: 기존 데이터 구조 유지, 신규 필드 추가 방식
|
||||
2. **하위 호환성**: 기존 `widgetType` 필드는 유지, `unifiedType` 필드 추가
|
||||
3. **점진적 전환**: 화면 수정 시점에 자동 또는 수동 전환
|
||||
|
||||
---
|
||||
|
||||
## 6. 기대 효과
|
||||
|
||||
1. **컴포넌트 수 감소**: 68개 → **9개** (관리 포인트 87% 감소)
|
||||
2. **Config Panel 통합**: 28개 → **1개** (DynamicConfigPanel)
|
||||
3. **유연한 UI 변경**: 컴포넌트 교체 없이 속성 변경만으로 UI 모드 전환 가능
|
||||
4. **Low-Code 확장성**: 새로운 유형의 입력 방식이 필요할 때 코딩 없이 DB 설정만으로 추가 가능
|
||||
|
||||
---
|
||||
|
||||
## 7. 리스크 및 대응 방안
|
||||
|
||||
| 리스크 | 영향도 | 대응 방안 |
|
||||
| :----------------------- | :----: | :-------------------------------- |
|
||||
| 기존 화면 호환성 깨짐 | 높음 | 병행 운영 + 하위 호환성 유지 |
|
||||
| 마이그레이션 데이터 손실 | 높음 | 백업 필수 + 롤백 스크립트 준비 |
|
||||
| 개발자 학습 곡선 | 중간 | 상세 가이드 문서 + 예제 코드 제공 |
|
||||
| 성능 저하 (동적 렌더링) | 중간 | 메모이제이션 + 지연 로딩 적용 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 현재 컴포넌트 매핑 분석
|
||||
|
||||
### 8.1 Registry 등록 컴포넌트 전수 조사 (44개)
|
||||
|
||||
현재 `frontend/lib/registry/components/`에 등록된 모든 컴포넌트의 통합 가능 여부를 분석했습니다.
|
||||
|
||||
#### UnifiedInput으로 통합 (4개)
|
||||
|
||||
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||
| :------------- | :--------------- | :------------- |
|
||||
| text-input | `type: "text"` | |
|
||||
| number-input | `type: "number"` | |
|
||||
| slider-basic | `type: "slider"` | 속성 추가 필요 |
|
||||
| button-primary | `type: "button"` | 별도 검토 |
|
||||
|
||||
#### UnifiedSelect로 통합 (8개)
|
||||
|
||||
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||
| :------------------------ | :----------------------------------- | :------------- |
|
||||
| select-basic | `mode: "dropdown"` | |
|
||||
| checkbox-basic | `mode: "check"` | |
|
||||
| radio-basic | `mode: "radio"` | |
|
||||
| toggle-switch | `mode: "toggle"` | 속성 추가 필요 |
|
||||
| autocomplete-search-input | `mode: "dropdown", searchable: true` | |
|
||||
| entity-search-input | `source: "entity"` | |
|
||||
| mail-recipient-selector | `mode: "multi", type: "email"` | 복합 컴포넌트 |
|
||||
| location-swap-selector | `mode: "swap"` | 특수 UI |
|
||||
|
||||
#### UnifiedDate로 통합 (1개)
|
||||
|
||||
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||
| :------------ | :------------- | :--- |
|
||||
| date-input | `type: "date"` | |
|
||||
|
||||
#### UnifiedText로 통합 (1개)
|
||||
|
||||
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||
| :------------- | :--------------- | :--- |
|
||||
| textarea-basic | `mode: "simple"` | |
|
||||
|
||||
#### UnifiedMedia로 통합 (3개)
|
||||
|
||||
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||
| :------------ | :------------------------------ | :--- |
|
||||
| file-upload | `type: "file"` | |
|
||||
| image-widget | `type: "image"` | |
|
||||
| image-display | `type: "image", readonly: true` | |
|
||||
|
||||
#### UnifiedList로 통합 (8개)
|
||||
|
||||
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||
| :-------------------- | :------------------------------------ | :------------ |
|
||||
| table-list | `viewMode: "table"` | |
|
||||
| card-display | `viewMode: "card"` | |
|
||||
| repeater-field-group | `editable: true` | |
|
||||
| modal-repeater-table | `viewMode: "table", modal: true` | |
|
||||
| simple-repeater-table | `viewMode: "table", simple: true` | |
|
||||
| repeat-screen-modal | `viewMode: "card", modal: true` | |
|
||||
| table-search-widget | `viewMode: "table", searchable: true` | |
|
||||
| tax-invoice-list | `viewMode: "table", bizType: "tax"` | 특수 비즈니스 |
|
||||
|
||||
#### UnifiedLayout으로 통합 (4개)
|
||||
|
||||
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||
| :------------------ | :-------------------------- | :------------- |
|
||||
| split-panel-layout | `type: "split"` | |
|
||||
| split-panel-layout2 | `type: "split", version: 2` | |
|
||||
| divider-line | `type: "divider"` | 속성 추가 필요 |
|
||||
| screen-split-panel | `type: "screen-embed"` | 화면 임베딩 |
|
||||
|
||||
#### UnifiedGroup으로 통합 (5개)
|
||||
|
||||
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||
| :------------------- | :--------------------- | :------------ |
|
||||
| accordion-basic | `type: "accordion"` | |
|
||||
| tabs | `type: "tabs"` | |
|
||||
| section-paper | `type: "section"` | |
|
||||
| section-card | `type: "card-section"` | |
|
||||
| universal-form-modal | `type: "form-modal"` | 복합 컴포넌트 |
|
||||
|
||||
#### UnifiedBiz로 통합 (7개)
|
||||
|
||||
| 현재 컴포넌트 | 매핑 속성 | 비고 |
|
||||
| :-------------------- | :------------------------ | :--------------- |
|
||||
| flow-widget | `type: "flow"` | 플로우 관리 |
|
||||
| rack-structure | `type: "rack"` | 창고 렉 구조 |
|
||||
| map | `type: "map"` | 지도 |
|
||||
| numbering-rule | `type: "numbering"` | 채번 규칙 |
|
||||
| category-manager | `type: "category"` | 카테고리 관리 |
|
||||
| customer-item-mapping | `type: "mapping"` | 거래처-품목 매핑 |
|
||||
| related-data-buttons | `type: "related-buttons"` | 연관 데이터 |
|
||||
|
||||
#### 별도 검토 필요 (3개)
|
||||
|
||||
| 현재 컴포넌트 | 문제점 | 제안 |
|
||||
| :-------------------------- | :------------------- | :------------------------------ |
|
||||
| conditional-container | 조건부 렌더링 로직 | 공통 속성으로 분리 |
|
||||
| selected-items-detail-input | 복합 (선택+상세입력) | UnifiedList + UnifiedGroup 조합 |
|
||||
| text-display | 읽기 전용 텍스트 | UnifiedInput (readonly: true) |
|
||||
|
||||
### 8.2 매핑 분석 결과
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 전체 44개 컴포넌트 분석 결과 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ ✅ 즉시 통합 가능 : 36개 (82%) │
|
||||
│ ⚠️ 속성 추가 후 통합 : 5개 (11%) │
|
||||
│ 🔄 별도 검토 필요 : 3개 (7%) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 8.3 속성 확장 필요 사항
|
||||
|
||||
#### UnifiedInput 속성 확장
|
||||
|
||||
```typescript
|
||||
// 기존
|
||||
type: "text" | "number" | "password";
|
||||
|
||||
// 확장
|
||||
type: "text" | "number" | "password" | "slider" | "color" | "button";
|
||||
```
|
||||
|
||||
#### UnifiedSelect 속성 확장
|
||||
|
||||
```typescript
|
||||
// 기존
|
||||
mode: "dropdown" | "radio" | "check" | "tag";
|
||||
|
||||
// 확장
|
||||
mode: "dropdown" | "radio" | "check" | "tag" | "toggle" | "swap";
|
||||
```
|
||||
|
||||
#### UnifiedLayout 속성 확장
|
||||
|
||||
```typescript
|
||||
// 기존
|
||||
type: "grid" | "split" | "flex";
|
||||
|
||||
// 확장
|
||||
type: "grid" | "split" | "flex" | "divider" | "screen-embed";
|
||||
```
|
||||
|
||||
### 8.4 조건부 렌더링 공통화
|
||||
|
||||
`conditional-container`의 기능을 모든 컴포넌트에서 사용 가능한 공통 속성으로 분리합니다.
|
||||
|
||||
```typescript
|
||||
// 모든 Unified 컴포넌트에 적용 가능한 공통 속성
|
||||
interface BaseUnifiedProps {
|
||||
// ... 기존 속성
|
||||
|
||||
/** 조건부 렌더링 설정 */
|
||||
conditional?: {
|
||||
enabled: boolean;
|
||||
field: string; // 참조할 필드명
|
||||
operator: "=" | "!=" | ">" | "<" | "in" | "notIn";
|
||||
value: any; // 비교 값
|
||||
hideOnFalse?: boolean; // false일 때 숨김 (기본: true)
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 계층 구조(Hierarchy) 컴포넌트 전략
|
||||
|
||||
### 9.1 현재 계층 구조 지원 현황
|
||||
|
||||
DB 테이블 `cascading_hierarchy_group`에서 4가지 계층 타입을 지원합니다:
|
||||
|
||||
| 타입 | 설명 | 예시 |
|
||||
| :----------------- | :---------------------- | :--------------- |
|
||||
| **MULTI_TABLE** | 다중 테이블 계층 | 국가 > 도시 > 구 |
|
||||
| **SELF_REFERENCE** | 자기 참조 (단일 테이블) | 조직도, 메뉴 |
|
||||
| **BOM** | 자재명세서 구조 | 부품 > 하위부품 |
|
||||
| **TREE** | 일반 트리 | 카테고리 |
|
||||
|
||||
### 9.2 통합 방안: UnifiedHierarchy 신설 (10번째 컴포넌트)
|
||||
|
||||
계층 구조는 일반 입력/표시 위젯과 성격이 다르므로 **별도 컴포넌트로 분리**합니다.
|
||||
|
||||
```typescript
|
||||
interface UnifiedHierarchyProps {
|
||||
/** 계층 유형 */
|
||||
type: "tree" | "org" | "bom" | "cascading";
|
||||
|
||||
/** 표시 방식 */
|
||||
viewMode: "tree" | "table" | "indent" | "dropdown";
|
||||
|
||||
/** 계층 그룹 코드 (cascading_hierarchy_group 연동) */
|
||||
source: string;
|
||||
|
||||
/** 편집 가능 여부 */
|
||||
editable?: boolean;
|
||||
|
||||
/** 드래그 정렬 가능 */
|
||||
draggable?: boolean;
|
||||
|
||||
/** BOM 수량 표시 (BOM 타입 전용) */
|
||||
showQty?: boolean;
|
||||
|
||||
/** 최대 레벨 제한 */
|
||||
maxLevel?: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 활용 예시
|
||||
|
||||
| 설정 | 결과 |
|
||||
| :---------------------------------------- | :------------------------- |
|
||||
| `type: "tree", viewMode: "tree"` | 카테고리 트리뷰 |
|
||||
| `type: "org", viewMode: "tree"` | 조직도 |
|
||||
| `type: "bom", viewMode: "indent"` | BOM 들여쓰기 테이블 |
|
||||
| `type: "cascading", viewMode: "dropdown"` | 연쇄 셀렉트 (국가>도시>구) |
|
||||
|
||||
---
|
||||
|
||||
## 10. 최종 통합 컴포넌트 목록 (10개)
|
||||
|
||||
| # | 컴포넌트 | 역할 | 커버 범위 |
|
||||
| :-: | :------------------- | :------------- | :----------------------------------- |
|
||||
| 1 | **UnifiedInput** | 단일 값 입력 | text, number, slider, button 등 |
|
||||
| 2 | **UnifiedSelect** | 선택 입력 | dropdown, radio, checkbox, toggle 등 |
|
||||
| 3 | **UnifiedDate** | 날짜/시간 입력 | date, datetime, time, range |
|
||||
| 4 | **UnifiedText** | 다중 행 텍스트 | textarea, rich editor, markdown |
|
||||
| 5 | **UnifiedMedia** | 파일/미디어 | file, image, video, audio |
|
||||
| 6 | **UnifiedList** | 데이터 목록 | table, card, repeater, kanban |
|
||||
| 7 | **UnifiedLayout** | 레이아웃 배치 | grid, split, flex, divider |
|
||||
| 8 | **UnifiedGroup** | 콘텐츠 그룹화 | tabs, accordion, section, modal |
|
||||
| 9 | **UnifiedBiz** | 비즈니스 특화 | flow, rack, map, numbering 등 |
|
||||
| 10 | **UnifiedHierarchy** | 계층 구조 | tree, org, bom, cascading |
|
||||
|
||||
---
|
||||
|
||||
## 11. 연쇄관계 관리 메뉴 통합 전략
|
||||
|
||||
### 11.1 현재 연쇄관계 관리 현황
|
||||
|
||||
**관리 메뉴**: `연쇄 드롭다운 통합 관리` (6개 탭)
|
||||
|
||||
| 탭 | DB 테이블 | 실제 데이터 | 복잡도 |
|
||||
| :--------------- | :--------------------------------------- | :---------: | :----: |
|
||||
| 2단계 연쇄관계 | `cascading_relation` | 2건 | 낮음 |
|
||||
| 다단계 계층 | `cascading_hierarchy_group/level` | 1건 | 높음 |
|
||||
| 조건부 필터 | `cascading_condition` | 0건 | 중간 |
|
||||
| 자동 입력 | `cascading_auto_fill_group/mapping` | 0건 | 낮음 |
|
||||
| 상호 배제 | `cascading_mutual_exclusion` | 0건 | 낮음 |
|
||||
| 카테고리 값 연쇄 | `category_value_cascading_group/mapping` | 2건 | 중간 |
|
||||
|
||||
### 11.2 통합 방향: 속성 기반 vs 공통 정의
|
||||
|
||||
#### 판단 기준
|
||||
|
||||
| 기능 | 재사용 빈도 | 설정 복잡도 | 권장 방식 |
|
||||
| :--------------- | :---------: | :---------: | :----------------------- |
|
||||
| 2단계 연쇄 | 낮음 | 간단 | **속성에 inline 정의** |
|
||||
| 다단계 계층 | 높음 | 복잡 | **공통 정의 유지** |
|
||||
| 조건부 필터 | 낮음 | 간단 | **속성에 inline 정의** |
|
||||
| 자동 입력 | 낮음 | 간단 | **속성에 inline 정의** |
|
||||
| 상호 배제 | 낮음 | 간단 | **속성에 inline 정의** |
|
||||
| 카테고리 값 연쇄 | 중간 | 중간 | **카테고리 관리와 통합** |
|
||||
|
||||
### 11.3 속성 통합 설계
|
||||
|
||||
#### 2단계 연쇄 → UnifiedSelect 속성
|
||||
|
||||
```typescript
|
||||
// AS-IS: 별도 관리 메뉴에서 정의 후 참조
|
||||
<SelectWidget cascadingRelation="WAREHOUSE_LOCATION" />
|
||||
|
||||
// TO-BE: 컴포넌트 속성에서 직접 정의
|
||||
<UnifiedSelect
|
||||
source="db"
|
||||
table="warehouse_location"
|
||||
valueColumn="location_code"
|
||||
labelColumn="location_name"
|
||||
cascading={{
|
||||
parentField: "warehouse_code", // 같은 화면 내 부모 필드
|
||||
filterColumn: "warehouse_code", // 필터링할 컬럼
|
||||
clearOnChange: true // 부모 변경 시 초기화
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
#### 조건부 필터 → 공통 conditional 속성
|
||||
|
||||
```typescript
|
||||
// AS-IS: 별도 관리 메뉴에서 조건 정의
|
||||
// cascading_condition 테이블에 저장
|
||||
|
||||
// TO-BE: 모든 컴포넌트에 공통 속성으로 적용
|
||||
<UnifiedInput
|
||||
conditional={{
|
||||
enabled: true,
|
||||
field: "order_type", // 참조할 필드
|
||||
operator: "=", // 비교 연산자
|
||||
value: "EXPORT", // 비교 값
|
||||
action: "show", // show | hide | disable | enable
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
#### 자동 입력 → autoFill 속성
|
||||
|
||||
```typescript
|
||||
// AS-IS: cascading_auto_fill_group 테이블에 정의
|
||||
|
||||
// TO-BE: 컴포넌트 속성에서 직접 정의
|
||||
<UnifiedInput
|
||||
autoFill={{
|
||||
enabled: true,
|
||||
sourceTable: "company_mng", // 조회할 테이블
|
||||
filterColumn: "company_code", // 필터링 컬럼
|
||||
userField: "companyCode", // 사용자 정보 필드
|
||||
displayColumn: "company_name", // 표시할 컬럼
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
#### 상호 배제 → mutualExclusion 속성
|
||||
|
||||
```typescript
|
||||
// AS-IS: cascading_mutual_exclusion 테이블에 정의
|
||||
|
||||
// TO-BE: 컴포넌트 속성에서 직접 정의
|
||||
<UnifiedSelect
|
||||
mutualExclusion={{
|
||||
enabled: true,
|
||||
targetField: "sub_category", // 상호 배제 대상 필드
|
||||
type: "exclusive", // exclusive | inclusive
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### 11.4 관리 메뉴 정리 계획
|
||||
|
||||
| 현재 메뉴 | TO-BE | 비고 |
|
||||
| :-------------------------- | :----------------------- | :-------------------- |
|
||||
| **연쇄 드롭다운 통합 관리** | **삭제** | 6개 탭 전체 제거 |
|
||||
| ├─ 2단계 연쇄관계 | UnifiedSelect 속성 | inline 정의 |
|
||||
| ├─ 다단계 계층 | **테이블관리로 이동** | 복잡한 구조 유지 필요 |
|
||||
| ├─ 조건부 필터 | 공통 conditional 속성 | 모든 컴포넌트에 적용 |
|
||||
| ├─ 자동 입력 | autoFill 속성 | 컴포넌트별 정의 |
|
||||
| ├─ 상호 배제 | mutualExclusion 속성 | 컴포넌트별 정의 |
|
||||
| └─ 카테고리 값 연쇄 | **카테고리 관리로 이동** | 기존 메뉴 통합 |
|
||||
|
||||
### 11.5 DB 테이블 정리 (Phase 5)
|
||||
|
||||
| 테이블 | 조치 | 시점 |
|
||||
| :--------------------------- | :----------------------- | :------ |
|
||||
| `cascading_relation` | 마이그레이션 후 삭제 | Phase 5 |
|
||||
| `cascading_condition` | 삭제 (데이터 없음) | Phase 5 |
|
||||
| `cascading_auto_fill_*` | 삭제 (데이터 없음) | Phase 5 |
|
||||
| `cascading_mutual_exclusion` | 삭제 (데이터 없음) | Phase 5 |
|
||||
| `cascading_hierarchy_*` | **유지** | - |
|
||||
| `category_value_cascading_*` | **유지** (카테고리 관리) | - |
|
||||
|
||||
### 11.6 마이그레이션 스크립트 필요 항목
|
||||
|
||||
```sql
|
||||
-- cascading_relation → 화면 레이아웃 데이터로 마이그레이션
|
||||
-- 기존 2건의 연쇄관계를 사용하는 화면을 찾아서
|
||||
-- 해당 컴포넌트의 cascading 속성으로 변환
|
||||
|
||||
-- 예시: WAREHOUSE_LOCATION 연쇄관계
|
||||
-- 이 관계를 사용하는 화면의 컴포넌트에
|
||||
-- cascading: { parentField: "warehouse_code", filterColumn: "warehouse_code" }
|
||||
-- 속성 추가
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 최종 아키텍처 요약
|
||||
|
||||
### 12.1 통합 컴포넌트 (10개)
|
||||
|
||||
| # | 컴포넌트 | 역할 |
|
||||
| :-: | :------------------- | :--------------------------------------- |
|
||||
| 1 | **UnifiedInput** | 단일 값 입력 (text, number, slider 등) |
|
||||
| 2 | **UnifiedSelect** | 선택 입력 (dropdown, radio, checkbox 등) |
|
||||
| 3 | **UnifiedDate** | 날짜/시간 입력 |
|
||||
| 4 | **UnifiedText** | 다중 행 텍스트 (textarea, rich editor) |
|
||||
| 5 | **UnifiedMedia** | 파일/미디어 (file, image) |
|
||||
| 6 | **UnifiedList** | 데이터 목록 (table, card, repeater) |
|
||||
| 7 | **UnifiedLayout** | 레이아웃 배치 (grid, split, flex) |
|
||||
| 8 | **UnifiedGroup** | 콘텐츠 그룹화 (tabs, accordion, section) |
|
||||
| 9 | **UnifiedBiz** | 비즈니스 특화 (flow, rack, map 등) |
|
||||
| 10 | **UnifiedHierarchy** | 계층 구조 (tree, org, bom, cascading) |
|
||||
|
||||
### 12.2 공통 속성 (모든 컴포넌트에 적용)
|
||||
|
||||
```typescript
|
||||
interface BaseUnifiedProps {
|
||||
// 기본 속성
|
||||
id: string;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
|
||||
// 스타일
|
||||
style?: ComponentStyle;
|
||||
className?: string;
|
||||
|
||||
// 조건부 렌더링 (conditional-container 대체)
|
||||
conditional?: {
|
||||
enabled: boolean;
|
||||
field: string;
|
||||
operator:
|
||||
| "="
|
||||
| "!="
|
||||
| ">"
|
||||
| "<"
|
||||
| "in"
|
||||
| "notIn"
|
||||
| "isEmpty"
|
||||
| "isNotEmpty";
|
||||
value: any;
|
||||
action: "show" | "hide" | "disable" | "enable";
|
||||
};
|
||||
|
||||
// 자동 입력 (autoFill 대체)
|
||||
autoFill?: {
|
||||
enabled: boolean;
|
||||
sourceTable: string;
|
||||
filterColumn: string;
|
||||
userField: "companyCode" | "userId" | "deptCode";
|
||||
displayColumn: string;
|
||||
};
|
||||
|
||||
// 유효성 검사
|
||||
validation?: ValidationRule[];
|
||||
}
|
||||
```
|
||||
|
||||
### 12.3 UnifiedSelect 전용 속성
|
||||
|
||||
```typescript
|
||||
interface UnifiedSelectProps extends BaseUnifiedProps {
|
||||
// 표시 모드
|
||||
mode: "dropdown" | "radio" | "check" | "tag" | "toggle" | "swap";
|
||||
|
||||
// 데이터 소스
|
||||
source: "static" | "code" | "db" | "api" | "entity";
|
||||
|
||||
// static 소스
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
|
||||
// db 소스
|
||||
table?: string;
|
||||
valueColumn?: string;
|
||||
labelColumn?: string;
|
||||
|
||||
// code 소스
|
||||
codeGroup?: string;
|
||||
|
||||
// 연쇄 관계 (cascading_relation 대체)
|
||||
cascading?: {
|
||||
parentField: string; // 부모 필드명
|
||||
filterColumn: string; // 필터링할 컬럼
|
||||
clearOnChange?: boolean; // 부모 변경 시 초기화
|
||||
};
|
||||
|
||||
// 상호 배제 (mutual_exclusion 대체)
|
||||
mutualExclusion?: {
|
||||
enabled: boolean;
|
||||
targetField: string; // 상호 배제 대상
|
||||
type: "exclusive" | "inclusive";
|
||||
};
|
||||
|
||||
// 다중 선택
|
||||
multiple?: boolean;
|
||||
maxSelect?: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 12.4 관리 메뉴 정리 결과
|
||||
|
||||
| AS-IS | TO-BE |
|
||||
| :---------------------------- | :----------------------------------- |
|
||||
| 연쇄 드롭다운 통합 관리 (6탭) | **삭제** |
|
||||
| - 2단계 연쇄관계 | → UnifiedSelect.cascading 속성 |
|
||||
| - 다단계 계층 | → 테이블관리 > 계층 구조 설정 |
|
||||
| - 조건부 필터 | → 공통 conditional 속성 |
|
||||
| - 자동 입력 | → 공통 autoFill 속성 |
|
||||
| - 상호 배제 | → UnifiedSelect.mutualExclusion 속성 |
|
||||
| - 카테고리 값 연쇄 | → 카테고리 관리와 통합 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 주의사항
|
||||
|
||||
> **기존 컴포넌트 삭제 금지**
|
||||
> 모든 Phase에서 기존 컴포넌트는 삭제하지 않고 **병행 운영**합니다.
|
||||
> 레거시 정리는 Phase 5에서 충분한 안정화 후 별도 검토합니다.
|
||||
|
||||
> **연쇄관계 마이그레이션 필수**
|
||||
> 관리 메뉴 삭제 전 기존 `cascading_relation` 데이터(2건)를
|
||||
> 해당 화면의 컴포넌트 속성으로 마이그레이션해야 합니다.
|
||||
|
|
@ -76,7 +76,9 @@ export const getCategoryValueCascadingGroups = async (
|
|||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("카테고리 값 연쇄관계 그룹 목록 조회 실패", { error: error.message });
|
||||
logger.error("카테고리 값 연쇄관계 그룹 목록 조회 실패", {
|
||||
error: error.message,
|
||||
});
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 값 연쇄관계 그룹 목록 조회에 실패했습니다.",
|
||||
|
|
@ -175,7 +177,9 @@ export const getCategoryValueCascadingGroupById = async (
|
|||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("카테고리 값 연쇄관계 그룹 상세 조회 실패", { error: error.message });
|
||||
logger.error("카테고리 값 연쇄관계 그룹 상세 조회 실패", {
|
||||
error: error.message,
|
||||
});
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 값 연쇄관계 그룹 조회에 실패했습니다.",
|
||||
|
|
@ -240,7 +244,9 @@ export const getCategoryValueCascadingByCode = async (
|
|||
data: result.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("카테고리 값 연쇄관계 코드 조회 실패", { error: error.message });
|
||||
logger.error("카테고리 값 연쇄관계 코드 조회 실패", {
|
||||
error: error.message,
|
||||
});
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 값 연쇄관계 조회에 실패했습니다.",
|
||||
|
|
@ -277,7 +283,14 @@ export const createCategoryValueCascadingGroup = async (
|
|||
} = req.body;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!relationCode || !relationName || !parentTableName || !parentColumnName || !childTableName || !childColumnName) {
|
||||
if (
|
||||
!relationCode ||
|
||||
!relationName ||
|
||||
!parentTableName ||
|
||||
!parentColumnName ||
|
||||
!childTableName ||
|
||||
!childColumnName
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다.",
|
||||
|
|
@ -352,7 +365,9 @@ export const createCategoryValueCascadingGroup = async (
|
|||
message: "카테고리 값 연쇄관계 그룹이 생성되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("카테고리 값 연쇄관계 그룹 생성 실패", { error: error.message });
|
||||
logger.error("카테고리 값 연쇄관계 그룹 생성 실패", {
|
||||
error: error.message,
|
||||
});
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 값 연쇄관계 그룹 생성에 실패했습니다.",
|
||||
|
|
@ -403,7 +418,11 @@ export const updateCategoryValueCascadingGroup = async (
|
|||
}
|
||||
|
||||
const existingCompanyCode = existingCheck.rows[0].company_code;
|
||||
if (companyCode !== "*" && existingCompanyCode !== companyCode && existingCompanyCode !== "*") {
|
||||
if (
|
||||
companyCode !== "*" &&
|
||||
existingCompanyCode !== companyCode &&
|
||||
existingCompanyCode !== "*"
|
||||
) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: "수정 권한이 없습니다.",
|
||||
|
|
@ -440,7 +459,11 @@ export const updateCategoryValueCascadingGroup = async (
|
|||
childTableName,
|
||||
childColumnName,
|
||||
childMenuObjid,
|
||||
clearOnParentChange !== undefined ? (clearOnParentChange ? "Y" : "N") : null,
|
||||
clearOnParentChange !== undefined
|
||||
? clearOnParentChange
|
||||
? "Y"
|
||||
: "N"
|
||||
: null,
|
||||
showGroupLabel !== undefined ? (showGroupLabel ? "Y" : "N") : null,
|
||||
emptyParentMessage,
|
||||
noOptionsMessage,
|
||||
|
|
@ -461,7 +484,9 @@ export const updateCategoryValueCascadingGroup = async (
|
|||
message: "카테고리 값 연쇄관계 그룹이 수정되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("카테고리 값 연쇄관계 그룹 수정 실패", { error: error.message });
|
||||
logger.error("카테고리 값 연쇄관계 그룹 수정 실패", {
|
||||
error: error.message,
|
||||
});
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 값 연쇄관계 그룹 수정에 실패했습니다.",
|
||||
|
|
@ -496,7 +521,11 @@ export const deleteCategoryValueCascadingGroup = async (
|
|||
}
|
||||
|
||||
const existingCompanyCode = existingCheck.rows[0].company_code;
|
||||
if (companyCode !== "*" && existingCompanyCode !== companyCode && existingCompanyCode !== "*") {
|
||||
if (
|
||||
companyCode !== "*" &&
|
||||
existingCompanyCode !== companyCode &&
|
||||
existingCompanyCode !== "*"
|
||||
) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: "삭제 권한이 없습니다.",
|
||||
|
|
@ -522,7 +551,9 @@ export const deleteCategoryValueCascadingGroup = async (
|
|||
message: "카테고리 값 연쇄관계 그룹이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("카테고리 값 연쇄관계 그룹 삭제 실패", { error: error.message });
|
||||
logger.error("카테고리 값 연쇄관계 그룹 삭제 실패", {
|
||||
error: error.message,
|
||||
});
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 값 연쇄관계 그룹 삭제에 실패했습니다.",
|
||||
|
|
@ -620,7 +651,9 @@ export const saveCategoryValueCascadingMappings = async (
|
|||
client.release();
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("카테고리 값 연쇄관계 매핑 저장 실패", { error: error.message });
|
||||
logger.error("카테고리 값 연쇄관계 매핑 저장 실패", {
|
||||
error: error.message,
|
||||
});
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 값 연쇄관계 매핑 저장에 실패했습니다.",
|
||||
|
|
@ -649,12 +682,15 @@ export const getCategoryValueCascadingOptions = async (
|
|||
|
||||
// 다중 부모값 파싱
|
||||
let parentValueArray: string[] = [];
|
||||
|
||||
|
||||
if (parentValues) {
|
||||
if (Array.isArray(parentValues)) {
|
||||
parentValueArray = parentValues.map(v => String(v));
|
||||
parentValueArray = parentValues.map((v) => String(v));
|
||||
} else {
|
||||
parentValueArray = String(parentValues).split(',').map(v => v.trim()).filter(v => v);
|
||||
parentValueArray = String(parentValues)
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v);
|
||||
}
|
||||
} else if (parentValue) {
|
||||
parentValueArray = [String(parentValue)];
|
||||
|
|
@ -696,8 +732,10 @@ export const getCategoryValueCascadingOptions = async (
|
|||
const group = groupResult.rows[0];
|
||||
|
||||
// 매핑된 자식 값 조회 (다중 부모값에 대해 IN 절 사용)
|
||||
const placeholders = parentValueArray.map((_, idx) => `$${idx + 2}`).join(', ');
|
||||
|
||||
const placeholders = parentValueArray
|
||||
.map((_, idx) => `$${idx + 2}`)
|
||||
.join(", ");
|
||||
|
||||
const optionsQuery = `
|
||||
SELECT DISTINCT
|
||||
child_value_code as value,
|
||||
|
|
@ -712,7 +750,10 @@ export const getCategoryValueCascadingOptions = async (
|
|||
ORDER BY parent_value_code, display_order, child_value_label
|
||||
`;
|
||||
|
||||
const optionsResult = await pool.query(optionsQuery, [group.group_id, ...parentValueArray]);
|
||||
const optionsResult = await pool.query(optionsQuery, [
|
||||
group.group_id,
|
||||
...parentValueArray,
|
||||
]);
|
||||
|
||||
logger.info("카테고리 값 연쇄 옵션 조회", {
|
||||
relationCode: code,
|
||||
|
|
@ -723,7 +764,7 @@ export const getCategoryValueCascadingOptions = async (
|
|||
return res.json({
|
||||
success: true,
|
||||
data: optionsResult.rows,
|
||||
showGroupLabel: group.show_group_label === 'Y',
|
||||
showGroupLabel: group.show_group_label === "Y",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("카테고리 값 연쇄 옵션 조회 실패", { error: error.message });
|
||||
|
|
@ -789,7 +830,10 @@ export const getCategoryValueCascadingParentOptions = async (
|
|||
AND is_active = true
|
||||
`;
|
||||
|
||||
const optionsParams: any[] = [group.parent_table_name, group.parent_column_name];
|
||||
const optionsParams: any[] = [
|
||||
group.parent_table_name,
|
||||
group.parent_column_name,
|
||||
];
|
||||
let paramIndex = 3;
|
||||
|
||||
// 메뉴 스코프 적용
|
||||
|
|
@ -884,7 +928,10 @@ export const getCategoryValueCascadingChildOptions = async (
|
|||
AND is_active = true
|
||||
`;
|
||||
|
||||
const optionsParams: any[] = [group.child_table_name, group.child_column_name];
|
||||
const optionsParams: any[] = [
|
||||
group.child_table_name,
|
||||
group.child_column_name,
|
||||
];
|
||||
let paramIndex = 3;
|
||||
|
||||
// 메뉴 스코프 적용
|
||||
|
|
@ -925,3 +972,91 @@ export const getCategoryValueCascadingChildOptions = async (
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 테이블명으로 해당 테이블의 모든 연쇄관계 매핑 조회
|
||||
* (테이블 목록에서 코드→라벨 변환에 사용)
|
||||
*/
|
||||
export const getCategoryValueCascadingMappingsByTable = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
if (!tableName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "테이블명이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 해당 테이블이 자식 테이블인 연쇄관계 그룹 찾기
|
||||
let groupQuery = `
|
||||
SELECT
|
||||
group_id,
|
||||
relation_code,
|
||||
child_column_name
|
||||
FROM category_value_cascading_group
|
||||
WHERE child_table_name = $1
|
||||
AND is_active = 'Y'
|
||||
`;
|
||||
const groupParams: any[] = [tableName];
|
||||
let paramIndex = 2;
|
||||
|
||||
// 멀티테넌시 적용
|
||||
if (companyCode !== "*") {
|
||||
groupQuery += ` AND (company_code = $${paramIndex} OR company_code = '*')`;
|
||||
groupParams.push(companyCode);
|
||||
}
|
||||
|
||||
const groupResult = await pool.query(groupQuery, groupParams);
|
||||
|
||||
if (groupResult.rowCount === 0) {
|
||||
// 연쇄관계가 없으면 빈 객체 반환
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {},
|
||||
});
|
||||
}
|
||||
|
||||
// 각 그룹의 매핑 조회
|
||||
const mappings: Record<string, Array<{ code: string; label: string }>> = {};
|
||||
|
||||
for (const group of groupResult.rows) {
|
||||
const mappingQuery = `
|
||||
SELECT DISTINCT
|
||||
child_value_code as code,
|
||||
child_value_label as label
|
||||
FROM category_value_cascading_mapping
|
||||
WHERE group_id = $1
|
||||
AND is_active = 'Y'
|
||||
ORDER BY child_value_label
|
||||
`;
|
||||
|
||||
const mappingResult = await pool.query(mappingQuery, [group.group_id]);
|
||||
|
||||
if (mappingResult.rowCount && mappingResult.rowCount > 0) {
|
||||
mappings[group.child_column_name] = mappingResult.rows;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("테이블별 연쇄관계 매핑 조회", {
|
||||
tableName,
|
||||
groupCount: groupResult.rowCount,
|
||||
columnMappings: Object.keys(mappings),
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: mappings,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("테이블별 연쇄관계 매핑 조회 실패", { error: error.message });
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "연쇄관계 매핑 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -53,3 +53,4 @@ export default router;
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -49,3 +49,4 @@ export default router;
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -65,3 +65,4 @@ export default router;
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -53,3 +53,4 @@ export default router;
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
getCategoryValueCascadingOptions,
|
||||
getCategoryValueCascadingParentOptions,
|
||||
getCategoryValueCascadingChildOptions,
|
||||
getCategoryValueCascadingMappingsByTable,
|
||||
} from "../controllers/categoryValueCascadingController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
|
|
@ -60,5 +61,14 @@ router.get("/child-options/:code", getCategoryValueCascadingChildOptions);
|
|||
// 연쇄 옵션 조회 (부모 값 기반 자식 옵션)
|
||||
router.get("/options/:code", getCategoryValueCascadingOptions);
|
||||
|
||||
export default router;
|
||||
// ============================================
|
||||
// 테이블별 매핑 조회 (테이블 목록 표시용)
|
||||
// ============================================
|
||||
|
||||
// 테이블명으로 해당 테이블의 모든 연쇄관계 매핑 조회
|
||||
router.get(
|
||||
"/table/:tableName/mappings",
|
||||
getCategoryValueCascadingMappingsByTable
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -585,3 +585,4 @@ const result = await executeNodeFlow(flowId, {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -358,3 +358,4 @@
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -344,3 +344,4 @@ const getComponentValue = (componentId: string) => {
|
|||
4. **연쇄 저장**: 한 번의 클릭으로 여러 테이블에 저장
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ export function MenuCopyDialog({
|
|||
const [copyNumberingRules, setCopyNumberingRules] = useState(false);
|
||||
const [copyCategoryMapping, setCopyCategoryMapping] = useState(false);
|
||||
const [copyTableTypeColumns, setCopyTableTypeColumns] = useState(false);
|
||||
const [copyCascadingRelation, setCopyCascadingRelation] = useState(false);
|
||||
|
||||
// 회사 목록 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -76,6 +77,7 @@ export function MenuCopyDialog({
|
|||
setCopyNumberingRules(false);
|
||||
setCopyCategoryMapping(false);
|
||||
setCopyTableTypeColumns(false);
|
||||
setCopyCascadingRelation(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
|
|
@ -128,6 +130,7 @@ export function MenuCopyDialog({
|
|||
copyNumberingRules,
|
||||
copyCategoryMapping,
|
||||
copyTableTypeColumns,
|
||||
copyCascadingRelation,
|
||||
};
|
||||
|
||||
const response = await menuApi.copyMenu(
|
||||
|
|
@ -344,6 +347,20 @@ export function MenuCopyDialog({
|
|||
테이블 타입관리 입력타입 설정 복사
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="copyCascadingRelation"
|
||||
checked={copyCascadingRelation}
|
||||
onCheckedChange={(checked) => setCopyCascadingRelation(checked as boolean)}
|
||||
disabled={copying}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="copyCascadingRelation"
|
||||
className="text-xs cursor-pointer"
|
||||
>
|
||||
연쇄관계 설정 복사
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -410,6 +427,12 @@ export function MenuCopyDialog({
|
|||
<span className="font-medium">{result.copiedTableTypeColumns}개</span>
|
||||
</div>
|
||||
)}
|
||||
{(result.copiedCascadingRelations ?? 0) > 0 && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">연쇄관계:</span>{" "}
|
||||
<span className="font-medium">{result.copiedCascadingRelations}개</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -138,3 +138,4 @@ export const useActiveTabOptional = () => {
|
|||
};
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -195,3 +195,4 @@ export function applyAutoFillToFormData(
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ export const menuApi = {
|
|||
}
|
||||
},
|
||||
|
||||
// 메뉴 복사
|
||||
// 메뉴 복사 (타임아웃 5분 - 대량 데이터 처리)
|
||||
copyMenu: async (
|
||||
menuObjid: number,
|
||||
targetCompanyCode: string,
|
||||
|
|
@ -176,6 +176,7 @@ export const menuApi = {
|
|||
copyNumberingRules?: boolean;
|
||||
copyCategoryMapping?: boolean;
|
||||
copyTableTypeColumns?: boolean;
|
||||
copyCascadingRelation?: boolean;
|
||||
}
|
||||
): Promise<ApiResponse<MenuCopyResult>> => {
|
||||
try {
|
||||
|
|
@ -185,11 +186,24 @@ export const menuApi = {
|
|||
targetCompanyCode,
|
||||
screenNameConfig,
|
||||
additionalCopyOptions
|
||||
},
|
||||
{
|
||||
timeout: 300000, // 5분 (메뉴 복사는 많은 데이터를 처리하므로 긴 타임아웃 필요)
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("❌ 메뉴 복사 실패:", error);
|
||||
|
||||
// 타임아웃 에러 구분 처리
|
||||
if (error.code === "ECONNABORTED" || error.message?.includes("timeout")) {
|
||||
return {
|
||||
success: false,
|
||||
message: "메뉴 복사 요청 시간이 초과되었습니다. 백엔드에서 작업이 완료되었을 수 있으니 잠시 후 확인해주세요.",
|
||||
errorCode: "MENU_COPY_TIMEOUT",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || "메뉴 복사 중 오류가 발생했습니다",
|
||||
|
|
@ -211,6 +225,7 @@ export interface MenuCopyResult {
|
|||
copiedNumberingRules?: number;
|
||||
copiedCategoryMappings?: number;
|
||||
copiedTableTypeColumns?: number;
|
||||
copiedCascadingRelations?: number;
|
||||
menuIdMap: Record<number, number>;
|
||||
screenIdMap: Record<number, number>;
|
||||
flowIdMap: Record<number, number>;
|
||||
|
|
|
|||
|
|
@ -172,19 +172,38 @@ export function EntitySearchInputComponent({
|
|||
// value가 변경되면 표시값 업데이트 (외래키 값으로 데이터 조회)
|
||||
useEffect(() => {
|
||||
const loadDisplayValue = async () => {
|
||||
if (value && selectedData) {
|
||||
// 이미 selectedData가 있으면 표시값만 업데이트
|
||||
// value가 없으면 초기화
|
||||
if (!value) {
|
||||
setDisplayValue("");
|
||||
setSelectedData(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 selectedData가 있고 value와 일치하면 표시값만 업데이트
|
||||
if (selectedData && String(selectedData[valueField]) === String(value)) {
|
||||
setDisplayValue(selectedData[displayField] || "");
|
||||
} else if (value && mode === "select" && effectiveOptions.length > 0) {
|
||||
// select 모드에서 value가 있고 options가 로드된 경우
|
||||
const found = effectiveOptions.find((opt) => opt[valueField] === value);
|
||||
return;
|
||||
}
|
||||
|
||||
// select 모드에서 options가 로드된 경우 먼저 옵션에서 찾기
|
||||
if (mode === "select" && effectiveOptions.length > 0) {
|
||||
// 타입 변환하여 비교 (숫자 vs 문자열 문제 해결)
|
||||
const found = effectiveOptions.find((opt) => String(opt[valueField]) === String(value));
|
||||
if (found) {
|
||||
setSelectedData(found);
|
||||
setDisplayValue(found[displayField] || "");
|
||||
console.log("✅ [EntitySearchInput] 옵션에서 초기값 찾음:", { value, found });
|
||||
return;
|
||||
}
|
||||
} else if (value && !selectedData && tableName) {
|
||||
// value는 있지만 selectedData가 없는 경우 (초기 로드 시)
|
||||
// API로 해당 데이터 조회
|
||||
// 옵션에서 찾지 못한 경우 API로 조회 진행
|
||||
console.log("⚠️ [EntitySearchInput] 옵션에서 찾지 못함, API로 조회:", {
|
||||
value,
|
||||
optionsCount: effectiveOptions.length,
|
||||
});
|
||||
}
|
||||
|
||||
// API로 해당 데이터 조회
|
||||
if (tableName) {
|
||||
try {
|
||||
console.log("🔍 [EntitySearchInput] 초기값 조회:", { value, tableName, valueField });
|
||||
const response = await dynamicFormApi.getTableData(tableName, {
|
||||
|
|
@ -222,9 +241,6 @@ export function EntitySearchInputComponent({
|
|||
// 에러 시 value 자체를 표시
|
||||
setDisplayValue(String(value));
|
||||
}
|
||||
} else if (!value) {
|
||||
setDisplayValue("");
|
||||
setSelectedData(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ export interface TableListComponentProps {
|
|||
onConfigChange?: (config: any) => void;
|
||||
refreshKey?: number;
|
||||
// 탭 관련 정보 (탭 내부의 테이블에서 사용)
|
||||
parentTabId?: string; // 부모 탭 ID
|
||||
parentTabId?: string; // 부모 탭 ID
|
||||
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
|
||||
}
|
||||
|
||||
|
|
@ -689,7 +689,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table");
|
||||
// 체크박스 컬럼은 항상 기본 틀고정
|
||||
const [frozenColumns, setFrozenColumns] = useState<string[]>(
|
||||
(tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : []
|
||||
(tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : [],
|
||||
);
|
||||
const [frozenColumnCount, setFrozenColumnCount] = useState<number>(0);
|
||||
|
||||
|
|
@ -1311,17 +1311,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const parts = columnName.split(".");
|
||||
targetTable = parts[0]; // 조인된 테이블명 (예: item_info)
|
||||
targetColumn = parts[1]; // 실제 컬럼명 (예: material)
|
||||
console.log(`🔗 [TableList] 엔티티 조인 컬럼 감지:`, {
|
||||
console.log("🔗 [TableList] 엔티티 조인 컬럼 감지:", {
|
||||
originalColumn: columnName,
|
||||
targetTable,
|
||||
targetColumn,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`);
|
||||
|
||||
|
||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||
const mapping: Record<string, { label: string; color?: string }> = {};
|
||||
|
||||
|
|
@ -1376,7 +1374,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
col.columnName,
|
||||
})) || [];
|
||||
|
||||
|
||||
// 조인 테이블별로 그룹화
|
||||
const joinedTableColumns: Record<string, { columnName: string; actualColumn: string }[]> = {};
|
||||
|
||||
|
|
@ -1408,7 +1405,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
});
|
||||
}
|
||||
|
||||
|
||||
// 조인된 테이블별로 inputType 정보 가져오기
|
||||
const newJoinedColumnMeta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
|
||||
|
||||
|
|
@ -1471,6 +1467,41 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
console.log("✅ [TableList] 조인 컬럼 메타데이터 설정:", newJoinedColumnMeta);
|
||||
}
|
||||
|
||||
// 🆕 카테고리 연쇄관계 매핑 로드 (category_value_cascading_mapping)
|
||||
try {
|
||||
const cascadingResponse = await apiClient.get(
|
||||
`/category-value-cascading/table/${tableConfig.selectedTable}/mappings`,
|
||||
);
|
||||
if (cascadingResponse.data.success && cascadingResponse.data.data) {
|
||||
const cascadingMappings = cascadingResponse.data.data;
|
||||
|
||||
// 각 자식 컬럼에 대한 매핑 추가
|
||||
for (const [columnName, columnMappings] of Object.entries(
|
||||
cascadingMappings as Record<string, Array<{ code: string; label: string }>>,
|
||||
)) {
|
||||
if (!mappings[columnName]) {
|
||||
mappings[columnName] = {};
|
||||
}
|
||||
// 연쇄관계 매핑 추가
|
||||
for (const item of columnMappings) {
|
||||
mappings[columnName][item.code] = {
|
||||
label: item.label,
|
||||
color: undefined, // 연쇄관계는 색상 없음
|
||||
};
|
||||
}
|
||||
}
|
||||
console.log("✅ [TableList] 카테고리 연쇄관계 매핑 로드 완료:", {
|
||||
tableName: tableConfig.selectedTable,
|
||||
cascadingColumns: Object.keys(cascadingMappings),
|
||||
});
|
||||
}
|
||||
} catch (cascadingError: any) {
|
||||
// 연쇄관계 매핑이 없는 경우 무시 (404 등)
|
||||
if (cascadingError?.response?.status !== 404) {
|
||||
console.warn("⚠️ [TableList] 카테고리 연쇄관계 매핑 로드 실패:", cascadingError?.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(mappings).length > 0) {
|
||||
setCategoryMappings(mappings);
|
||||
setCategoryMappingsKey((prev) => prev + 1);
|
||||
|
|
@ -1495,7 +1526,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
// ========================================
|
||||
|
||||
const fetchTableDataInternal = useCallback(async () => {
|
||||
|
||||
if (!tableConfig.selectedTable || isDesignMode) {
|
||||
setData([]);
|
||||
setTotalPages(0);
|
||||
|
|
@ -1514,11 +1544,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const search = searchTerm || undefined;
|
||||
|
||||
// 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때)
|
||||
let linkedFilterValues: Record<string, any> = {};
|
||||
const linkedFilterValues: Record<string, any> = {};
|
||||
let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부
|
||||
let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부
|
||||
|
||||
|
||||
if (splitPanelContext) {
|
||||
// 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지)
|
||||
const linkedFiltersConfig = splitPanelContext.linkedFilters || [];
|
||||
|
|
@ -1609,7 +1638,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}
|
||||
|
||||
// 🆕 RelatedDataButtons 필터 값 준비
|
||||
let relatedButtonFilterValues: Record<string, any> = {};
|
||||
const relatedButtonFilterValues: Record<string, any> = {};
|
||||
if (relatedButtonFilter) {
|
||||
relatedButtonFilterValues[relatedButtonFilter.filterColumn] = {
|
||||
value: relatedButtonFilter.filterValue,
|
||||
|
|
@ -1685,7 +1714,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
};
|
||||
});
|
||||
|
||||
|
||||
// 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||
let excludeFilterParam: any = undefined;
|
||||
if (tableConfig.excludeFilter?.enabled) {
|
||||
|
|
@ -2427,7 +2455,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
|
||||
await apiClient.put(`/dynamic-form/update-field`, {
|
||||
await apiClient.put("/dynamic-form/update-field", {
|
||||
tableName: tableConfig.selectedTable,
|
||||
keyField: primaryKeyField,
|
||||
keyValue: primaryKeyValue,
|
||||
|
|
@ -2468,7 +2496,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
// 모든 변경사항 저장
|
||||
const savePromises = Array.from(pendingChanges.values()).map((change) =>
|
||||
apiClient.put(`/dynamic-form/update-field`, {
|
||||
apiClient.put("/dynamic-form/update-field", {
|
||||
tableName: tableConfig.selectedTable,
|
||||
keyField: primaryKeyField,
|
||||
keyValue: change.primaryKeyValue,
|
||||
|
|
@ -2942,9 +2970,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
if (state.frozenColumns) {
|
||||
// 체크박스 컬럼이 항상 포함되도록 보장
|
||||
const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? "__checkbox__" : null;
|
||||
const restoredFrozenColumns = checkboxColumn && !state.frozenColumns.includes(checkboxColumn)
|
||||
? [checkboxColumn, ...state.frozenColumns]
|
||||
: state.frozenColumns;
|
||||
const restoredFrozenColumns =
|
||||
checkboxColumn && !state.frozenColumns.includes(checkboxColumn)
|
||||
? [checkboxColumn, ...state.frozenColumns]
|
||||
: state.frozenColumns;
|
||||
setFrozenColumns(restoredFrozenColumns);
|
||||
}
|
||||
if (state.frozenColumnCount !== undefined) setFrozenColumnCount(state.frozenColumnCount); // 틀고정 컬럼 수 복원
|
||||
|
|
@ -2956,7 +2985,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
});
|
||||
setHeaderFilters(filters);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 상태 복원 실패:", error);
|
||||
}
|
||||
|
|
@ -3576,7 +3604,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}));
|
||||
|
||||
// 배치 업데이트
|
||||
await Promise.all(updates.map((update) => apiClient.put(`/dynamic-form/update-field`, update)));
|
||||
await Promise.all(updates.map((update) => apiClient.put("/dynamic-form/update-field", update)));
|
||||
|
||||
toast.success("순서가 변경되었습니다.");
|
||||
setRefreshTrigger((prev) => prev + 1);
|
||||
|
|
@ -4894,7 +4922,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
useEffect(() => {
|
||||
const handleRelatedButtonSelect = (event: CustomEvent) => {
|
||||
const { targetTable, filterColumn, filterValue } = event.detail || {};
|
||||
|
||||
|
||||
// 이 테이블이 대상 테이블인지 확인
|
||||
if (targetTable === tableConfig.selectedTable) {
|
||||
// filterValue가 null이면 선택 해제 (빈 상태)
|
||||
|
|
@ -4925,9 +4953,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
useEffect(() => {
|
||||
if (!isDesignMode) {
|
||||
// relatedButtonFilter가 있으면 데이터 로드, null이면 빈 상태 (setRefreshTrigger로 트리거)
|
||||
console.log("🔄 [TableList] RelatedDataButtons 상태 변경:", {
|
||||
relatedButtonFilter,
|
||||
isRelatedButtonTarget
|
||||
console.log("🔄 [TableList] RelatedDataButtons 상태 변경:", {
|
||||
relatedButtonFilter,
|
||||
isRelatedButtonTarget,
|
||||
});
|
||||
setRefreshTrigger((prev) => prev + 1);
|
||||
}
|
||||
|
|
@ -5618,7 +5646,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
for (let i = 0; i < frozenIndex; i++) {
|
||||
const frozenCol = frozenColumns[i];
|
||||
// 체크박스 컬럼은 48px 고정
|
||||
const frozenColWidth = frozenCol === "__checkbox__" ? 48 : (columnWidths[frozenCol] || 150);
|
||||
const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
|
||||
leftPosition += frozenColWidth;
|
||||
}
|
||||
}
|
||||
|
|
@ -5930,7 +5958,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
for (let i = 0; i < frozenIndex; i++) {
|
||||
const frozenCol = frozenColumns[i];
|
||||
// 체크박스 컬럼은 48px 고정
|
||||
const frozenColWidth = frozenCol === "__checkbox__" ? 48 : (columnWidths[frozenCol] || 150);
|
||||
const frozenColWidth =
|
||||
frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
|
||||
leftPosition += frozenColWidth;
|
||||
}
|
||||
}
|
||||
|
|
@ -5958,7 +5987,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
: `${100 / visibleColumns.length}%`,
|
||||
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
||||
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
||||
...(isFrozen && {
|
||||
...(isFrozen && {
|
||||
left: `${leftPosition}px`,
|
||||
backgroundColor: "hsl(var(--background))",
|
||||
}),
|
||||
|
|
@ -6094,7 +6123,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
for (let i = 0; i < frozenIndex; i++) {
|
||||
const frozenCol = frozenColumns[i];
|
||||
// 체크박스 컬럼은 48px 고정
|
||||
const frozenColWidth = frozenCol === "__checkbox__" ? 48 : (columnWidths[frozenCol] || 150);
|
||||
const frozenColWidth =
|
||||
frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
|
||||
leftPosition += frozenColWidth;
|
||||
}
|
||||
}
|
||||
|
|
@ -6134,7 +6164,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
column.columnName === "__checkbox__" ? "48px" : `${100 / visibleColumns.length}%`,
|
||||
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
||||
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
||||
...(isFrozen && {
|
||||
...(isFrozen && {
|
||||
left: `${leftPosition}px`,
|
||||
backgroundColor: "hsl(var(--background))",
|
||||
}),
|
||||
|
|
@ -6259,7 +6289,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
for (let i = 0; i < frozenIndex; i++) {
|
||||
const frozenCol = frozenColumns[i];
|
||||
// 체크박스 컬럼은 48px 고정
|
||||
const frozenColWidth = frozenCol === "__checkbox__" ? 48 : (columnWidths[frozenCol] || 150);
|
||||
const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
|
||||
leftPosition += frozenColWidth;
|
||||
}
|
||||
}
|
||||
|
|
@ -6284,7 +6314,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
: columnWidth
|
||||
? `${columnWidth}px`
|
||||
: undefined,
|
||||
...(isFrozen && {
|
||||
...(isFrozen && {
|
||||
left: `${leftPosition}px`,
|
||||
backgroundColor: "hsl(var(--muted) / 0.8)",
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -269,7 +269,7 @@ export function UniversalFormModalComponent({
|
|||
// 설정에 정의된 필드 columnName 목록 수집
|
||||
const configuredFields = new Set<string>();
|
||||
config.sections.forEach((section) => {
|
||||
section.fields.forEach((field) => {
|
||||
(section.fields || []).forEach((field) => {
|
||||
if (field.columnName) {
|
||||
configuredFields.add(field.columnName);
|
||||
}
|
||||
|
|
@ -319,7 +319,7 @@ export function UniversalFormModalComponent({
|
|||
|
||||
// 모든 섹션의 필드에서 linkedFieldGroup.sourceTable 수집
|
||||
config.sections.forEach((section) => {
|
||||
section.fields.forEach((field) => {
|
||||
(section.fields || []).forEach((field) => {
|
||||
if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable) {
|
||||
tablesToLoad.add(field.linkedFieldGroup.sourceTable);
|
||||
}
|
||||
|
|
@ -374,7 +374,7 @@ export function UniversalFormModalComponent({
|
|||
newRepeatSections[section.id] = items;
|
||||
} else {
|
||||
// 일반 섹션 필드 초기화
|
||||
for (const field of section.fields) {
|
||||
for (const field of section.fields || []) {
|
||||
// 기본값 설정
|
||||
let value = field.defaultValue ?? "";
|
||||
|
||||
|
|
@ -405,7 +405,7 @@ export function UniversalFormModalComponent({
|
|||
console.log(`[initializeForm] 옵셔널 그룹 자동 활성화: ${key}, triggerField=${group.triggerField}, value=${triggerValue}`);
|
||||
|
||||
// 활성화된 그룹의 필드값도 초기화
|
||||
for (const field of group.fields) {
|
||||
for (const field of group.fields || []) {
|
||||
let value = field.defaultValue ?? "";
|
||||
const parentField = field.parentFieldName || field.columnName;
|
||||
if (effectiveInitialData[parentField] !== undefined) {
|
||||
|
|
@ -448,7 +448,7 @@ export function UniversalFormModalComponent({
|
|||
_index: index,
|
||||
};
|
||||
|
||||
for (const field of section.fields) {
|
||||
for (const field of section.fields || []) {
|
||||
item[field.columnName] = field.defaultValue ?? "";
|
||||
}
|
||||
|
||||
|
|
@ -481,7 +481,7 @@ export function UniversalFormModalComponent({
|
|||
for (const section of config.sections) {
|
||||
if (section.repeatable) continue;
|
||||
|
||||
for (const field of section.fields) {
|
||||
for (const field of section.fields || []) {
|
||||
if (
|
||||
field.numberingRule?.enabled &&
|
||||
field.numberingRule?.generateOnOpen &&
|
||||
|
|
@ -653,7 +653,7 @@ export function UniversalFormModalComponent({
|
|||
}
|
||||
|
||||
// 옵셔널 필드 그룹 필드 값 초기화
|
||||
group.fields.forEach((field) => {
|
||||
(group.fields || []).forEach((field) => {
|
||||
handleFieldChange(field.columnName, field.defaultValue || "");
|
||||
});
|
||||
}, [config, handleFieldChange]);
|
||||
|
|
@ -783,7 +783,7 @@ export function UniversalFormModalComponent({
|
|||
for (const section of config.sections) {
|
||||
if (section.repeatable) continue; // 반복 섹션은 별도 검증
|
||||
|
||||
for (const field of section.fields) {
|
||||
for (const field of section.fields || []) {
|
||||
if (field.required && !field.hidden && !field.numberingRule?.hidden) {
|
||||
const value = formData[field.columnName];
|
||||
if (value === undefined || value === null || value === "") {
|
||||
|
|
@ -809,7 +809,7 @@ export function UniversalFormModalComponent({
|
|||
|
||||
// 저장 시점 채번규칙 처리 (generateOnSave만 처리)
|
||||
for (const section of config.sections) {
|
||||
for (const field of section.fields) {
|
||||
for (const field of section.fields || []) {
|
||||
if (field.numberingRule?.enabled && field.numberingRule?.generateOnSave && field.numberingRule?.ruleId) {
|
||||
const response = await allocateNumberingCode(field.numberingRule.ruleId);
|
||||
if (response.success && response.data?.generatedCode) {
|
||||
|
|
@ -840,7 +840,7 @@ export function UniversalFormModalComponent({
|
|||
// 공통 필드가 설정되지 않은 경우, 기본정보 섹션의 모든 필드를 공통 필드로 사용
|
||||
if (commonFields.length === 0) {
|
||||
const nonRepeatableSections = config.sections.filter((s) => !s.repeatable);
|
||||
commonFields = nonRepeatableSections.flatMap((s) => s.fields.map((f) => f.columnName));
|
||||
commonFields = nonRepeatableSections.flatMap((s) => (s.fields || []).map((f) => f.columnName));
|
||||
}
|
||||
|
||||
// 반복 섹션 ID가 설정되지 않은 경우, 첫 번째 반복 섹션 사용
|
||||
|
|
@ -886,7 +886,7 @@ export function UniversalFormModalComponent({
|
|||
|
||||
// 반복 섹션의 필드 값 추가
|
||||
const repeatSection = config.sections.find((s) => s.id === repeatSectionId);
|
||||
repeatSection?.fields.forEach((field) => {
|
||||
(repeatSection?.fields || []).forEach((field) => {
|
||||
if (item[field.columnName] !== undefined) {
|
||||
subRow[field.columnName] = item[field.columnName];
|
||||
}
|
||||
|
|
@ -903,7 +903,7 @@ export function UniversalFormModalComponent({
|
|||
for (const section of config.sections) {
|
||||
if (section.repeatable) continue;
|
||||
|
||||
for (const field of section.fields) {
|
||||
for (const field of section.fields || []) {
|
||||
if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
|
||||
// generateOnSave 또는 generateOnOpen 모두 저장 시 실제 순번 할당
|
||||
const shouldAllocate = field.numberingRule.generateOnSave || field.numberingRule.generateOnOpen;
|
||||
|
|
@ -952,7 +952,7 @@ export function UniversalFormModalComponent({
|
|||
const mainData: Record<string, any> = {};
|
||||
config.sections.forEach((section) => {
|
||||
if (section.repeatable) return; // 반복 섹션은 제외
|
||||
section.fields.forEach((field) => {
|
||||
(section.fields || []).forEach((field) => {
|
||||
const value = formData[field.columnName];
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
mainData[field.columnName] = value;
|
||||
|
|
@ -964,7 +964,7 @@ export function UniversalFormModalComponent({
|
|||
for (const section of config.sections) {
|
||||
if (section.repeatable) continue;
|
||||
|
||||
for (const field of section.fields) {
|
||||
for (const field of section.fields || []) {
|
||||
// 채번규칙이 활성화된 필드 처리
|
||||
if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
|
||||
// 신규 생성이거나 값이 없는 경우에만 채번
|
||||
|
|
@ -1055,7 +1055,7 @@ export function UniversalFormModalComponent({
|
|||
else {
|
||||
config.sections.forEach((section) => {
|
||||
if (section.repeatable) return;
|
||||
const matchingField = section.fields.find((f) => f.columnName === mapping.targetColumn);
|
||||
const matchingField = (section.fields || []).find((f) => f.columnName === mapping.targetColumn);
|
||||
if (matchingField && mainData[matchingField.columnName] !== undefined) {
|
||||
mainFieldMappings!.push({
|
||||
formField: matchingField.columnName,
|
||||
|
|
@ -1560,7 +1560,7 @@ export function UniversalFormModalComponent({
|
|||
<CardContent>
|
||||
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}>
|
||||
{/* 일반 필드 렌더링 */}
|
||||
{section.fields.map((field) =>
|
||||
{(section.fields || []).map((field) =>
|
||||
renderFieldWithColumns(
|
||||
field,
|
||||
formData[field.columnName],
|
||||
|
|
@ -1582,7 +1582,7 @@ export function UniversalFormModalComponent({
|
|||
<CardContent>
|
||||
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}>
|
||||
{/* 일반 필드 렌더링 */}
|
||||
{section.fields.map((field) =>
|
||||
{(section.fields || []).map((field) =>
|
||||
renderFieldWithColumns(
|
||||
field,
|
||||
formData[field.columnName],
|
||||
|
|
@ -1719,7 +1719,7 @@ export function UniversalFormModalComponent({
|
|||
</div>
|
||||
<CollapsibleContent>
|
||||
<div className="grid gap-3 px-3 pb-3" style={{ gridTemplateColumns: "repeat(12, 1fr)" }}>
|
||||
{group.fields.map((field) =>
|
||||
{(group.fields || []).map((field) =>
|
||||
renderFieldWithColumns(
|
||||
field,
|
||||
formData[field.columnName],
|
||||
|
|
@ -1763,7 +1763,7 @@ export function UniversalFormModalComponent({
|
|||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-3" style={{ gridTemplateColumns: "repeat(12, 1fr)" }}>
|
||||
{group.fields.map((field) =>
|
||||
{(group.fields || []).map((field) =>
|
||||
renderFieldWithColumns(
|
||||
field,
|
||||
formData[field.columnName],
|
||||
|
|
@ -1819,7 +1819,7 @@ export function UniversalFormModalComponent({
|
|||
</div>
|
||||
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}>
|
||||
{/* 일반 필드 렌더링 */}
|
||||
{section.fields.map((field) =>
|
||||
{(section.fields || []).map((field) =>
|
||||
renderFieldWithColumns(
|
||||
field,
|
||||
item[field.columnName],
|
||||
|
|
@ -1898,7 +1898,7 @@ export function UniversalFormModalComponent({
|
|||
<div className="text-muted-foreground text-center">
|
||||
<p className="font-medium">{config.modal.title || "범용 폼 모달"}</p>
|
||||
<p className="mt-1 text-xs">
|
||||
{config.sections.length}개 섹션 |{config.sections.reduce((acc, s) => acc + s.fields.length, 0)}개 필드
|
||||
{config.sections.length}개 섹션 |{config.sections.reduce((acc, s) => acc + (s.fields?.length || 0), 0)}개 필드
|
||||
</p>
|
||||
<p className="mt-1 text-xs">저장 테이블: {config.saveConfig.tableName || "(미설정)"}</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1687,3 +1687,4 @@ const 출고등록_설정: ScreenSplitPanel = {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -534,3 +534,4 @@ const { data: config } = await getScreenSplitPanel(screenId);
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -521,3 +521,4 @@ function ScreenViewPage() {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue