Merge origin/main into ksh - resolve conflicts
This commit is contained in:
commit
f38447be8e
|
|
@ -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건)를
|
||||
> 해당 화면의 컴포넌트 속성으로 마이그레이션해야 합니다.
|
||||
|
|
@ -14,10 +14,12 @@
|
|||
"bcryptjs": "^2.4.3",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"docx": "^9.5.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"html-to-docx": "^1.8.0",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"imap": "^0.8.19",
|
||||
"joi": "^17.11.0",
|
||||
|
|
@ -2256,6 +2258,93 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/dom": {
|
||||
"version": "1.15.6",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.6.tgz",
|
||||
"integrity": "sha512-k4uEIa6DI3FCrFJMGq/05U/59WnS9DjME0kaPqBRCJAqBTkmopbYV1Xs4qFKbDJ/9wOg8W97p+1E0heng/LH7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oozcitak/infra": "1.0.5",
|
||||
"@oozcitak/url": "1.0.0",
|
||||
"@oozcitak/util": "8.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/infra": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.5.tgz",
|
||||
"integrity": "sha512-o+zZH7M6l5e3FaAWy3ojaPIVN5eusaYPrKm6MZQt0DKNdgXa2wDYExjpP0t/zx+GoQgQKzLu7cfD8rHCLt8JrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oozcitak/util": "8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/infra/node_modules/@oozcitak/util": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.0.0.tgz",
|
||||
"integrity": "sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/url": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-1.0.0.tgz",
|
||||
"integrity": "sha512-LGrMeSxeLzsdaitxq3ZmBRVOrlRRQIgNNci6L0VRnOKlJFuRIkNm4B+BObXPCJA6JT5bEJtrrwjn30jueHJYZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oozcitak/infra": "1.0.3",
|
||||
"@oozcitak/util": "1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/url/node_modules/@oozcitak/infra": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.3.tgz",
|
||||
"integrity": "sha512-9O2wxXGnRzy76O1XUxESxDGsXT5kzETJPvYbreO4mv6bqe1+YSuux2cZTagjJ/T4UfEwFJz5ixanOqB0QgYAag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oozcitak/util": "1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/url/node_modules/@oozcitak/infra/node_modules/@oozcitak/util": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-1.0.1.tgz",
|
||||
"integrity": "sha512-dFwFqcKrQnJ2SapOmRD1nQWEZUtbtIy9Y6TyJquzsalWNJsKIPxmTI0KG6Ypyl8j7v89L2wixH9fQDNrF78hKg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/url/node_modules/@oozcitak/util": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-1.0.2.tgz",
|
||||
"integrity": "sha512-4n8B1cWlJleSOSba5gxsMcN4tO8KkkcvXhNWW+ADqvq9Xj+Lrl9uCa90GRpjekqQJyt84aUX015DG81LFpZYXA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/util": {
|
||||
"version": "8.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.4.tgz",
|
||||
"integrity": "sha512-6gH/bLQJSJEg7OEpkH4wGQdA8KXHRbzL1YkGyUO12YNAgV3jxKy4K9kvfXj4+9T0OLug5k58cnPCKSSIKzp7pg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@paralleldrive/cuid2": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz",
|
||||
|
|
@ -4326,6 +4415,12 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/browser-split": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/browser-split/-/browser-split-0.0.1.tgz",
|
||||
"integrity": "sha512-JhvgRb2ihQhsljNda3BI8/UcRHVzrVwo3Q+P8vDtSiyobXuFpuZ9mq+MbRGMnC22CjW3RrfXdg6j6ITX8M+7Ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.26.2",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz",
|
||||
|
|
@ -4521,6 +4616,15 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelize": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
|
||||
"integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001745",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz",
|
||||
|
|
@ -5202,6 +5306,56 @@
|
|||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/docx": {
|
||||
"version": "9.5.1",
|
||||
"resolved": "https://registry.npmjs.org/docx/-/docx-9.5.1.tgz",
|
||||
"integrity": "sha512-ABDI7JEirFD2+bHhOBlsGZxaG1UgZb2M/QMKhLSDGgVNhxDesTCDcP+qoDnDGjZ4EOXTRfUjUgwHVuZ6VSTfWQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^24.0.1",
|
||||
"hash.js": "^1.1.7",
|
||||
"jszip": "^3.10.1",
|
||||
"nanoid": "^5.1.3",
|
||||
"xml": "^1.0.1",
|
||||
"xml-js": "^1.6.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/docx/node_modules/@types/node": {
|
||||
"version": "24.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz",
|
||||
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/docx/node_modules/nanoid": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
|
||||
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18 || >=20"
|
||||
}
|
||||
},
|
||||
"node_modules/docx/node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
|
|
@ -5216,6 +5370,11 @@
|
|||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-walk": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
|
||||
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
|
|
@ -5349,6 +5508,27 @@
|
|||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ent": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz",
|
||||
"integrity": "sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.3",
|
||||
"es-errors": "^1.3.0",
|
||||
"punycode": "^1.4.1",
|
||||
"safe-regex-test": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ent/node_modules/punycode": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
|
||||
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
|
|
@ -5361,6 +5541,16 @@
|
|||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/error": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/error/-/error-4.4.0.tgz",
|
||||
"integrity": "sha512-SNDKualLUtT4StGFP7xNfuFybL2f6iJujFtrWuvJqGbVQGaN+adE23veqzPz1hjUjTunLi2EnJ+0SJxtbJreKw==",
|
||||
"dependencies": {
|
||||
"camelize": "^1.0.0",
|
||||
"string-template": "~0.2.0",
|
||||
"xtend": "~4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/error-ex": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
||||
|
|
@ -5643,6 +5833,14 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ev-store": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ev-store/-/ev-store-7.0.0.tgz",
|
||||
"integrity": "sha512-otazchNRnGzp2YarBJ+GXKVGvhxVATB1zmaStxJBYet0Dyq7A9VhH8IUEB/gRcL6Ch52lfpgPTRJ2m49epyMsQ==",
|
||||
"dependencies": {
|
||||
"individual": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
|
|
@ -6279,6 +6477,16 @@
|
|||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/global": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
|
||||
"integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"min-document": "^2.19.0",
|
||||
"process": "^0.11.10"
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "13.24.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
|
||||
|
|
@ -6413,6 +6621,16 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hash.js": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
|
||||
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"minimalistic-assert": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
|
|
@ -6443,6 +6661,22 @@
|
|||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-entities": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
|
||||
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/mdevils"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://patreon.com/mdevils"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
|
|
@ -6450,6 +6684,27 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-to-docx": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/html-to-docx/-/html-to-docx-1.8.0.tgz",
|
||||
"integrity": "sha512-IiMBWIqXM4+cEsW//RKoonWV7DlXAJBmmKI73XJSVWTIXjGUaxSr2ck1jqzVRZknpvO8xsFnVicldKVAWrBYBA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oozcitak/dom": "1.15.6",
|
||||
"@oozcitak/util": "8.3.4",
|
||||
"color-name": "^1.1.4",
|
||||
"html-entities": "^2.3.3",
|
||||
"html-to-vdom": "^0.7.0",
|
||||
"image-size": "^1.0.0",
|
||||
"image-to-base64": "^2.2.0",
|
||||
"jszip": "^3.7.1",
|
||||
"lodash": "^4.17.21",
|
||||
"mime-types": "^2.1.35",
|
||||
"nanoid": "^3.1.25",
|
||||
"virtual-dom": "^2.1.1",
|
||||
"xmlbuilder2": "2.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-text": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
||||
|
|
@ -6466,6 +6721,106 @@
|
|||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-vdom": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/html-to-vdom/-/html-to-vdom-0.7.0.tgz",
|
||||
"integrity": "sha512-k+d2qNkbx0JO00KezQsNcn6k2I/xSBP4yXYFLvXbcasTTDh+RDLUJS3puxqyNnpdyXWRHFGoKU7cRmby8/APcQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"ent": "^2.0.0",
|
||||
"htmlparser2": "^3.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-vdom/node_modules/dom-serializer": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz",
|
||||
"integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.0.1",
|
||||
"entities": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-vdom/node_modules/dom-serializer/node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/html-to-vdom/node_modules/dom-serializer/node_modules/entities": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
|
||||
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
|
||||
"license": "BSD-2-Clause",
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-vdom/node_modules/domelementtype": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz",
|
||||
"integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/html-to-vdom/node_modules/domhandler": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz",
|
||||
"integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-vdom/node_modules/domutils": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz",
|
||||
"integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "0",
|
||||
"domelementtype": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-vdom/node_modules/entities": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz",
|
||||
"integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/html-to-vdom/node_modules/htmlparser2": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz",
|
||||
"integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^1.3.1",
|
||||
"domhandler": "^2.3.0",
|
||||
"domutils": "^1.5.1",
|
||||
"entities": "^1.1.1",
|
||||
"inherits": "^2.0.1",
|
||||
"readable-stream": "^3.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-vdom/node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||
|
|
@ -6590,6 +6945,30 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/image-size": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
|
||||
"integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"queue": "6.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"image-size": "bin/image-size.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.x"
|
||||
}
|
||||
},
|
||||
"node_modules/image-to-base64": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/image-to-base64/-/image-to-base64-2.2.0.tgz",
|
||||
"integrity": "sha512-Z+aMwm/91UOQqHhrz7Upre2ytKhWejZlWV/JxUTD1sT7GWWKFDJUEV5scVQKnkzSgPHFuQBUEWcanO+ma0PSVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/imap": {
|
||||
"version": "0.8.19",
|
||||
"resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz",
|
||||
|
|
@ -6626,6 +7005,12 @@
|
|||
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
|
|
@ -6673,6 +7058,11 @@
|
|||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/individual": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/individual/-/individual-3.0.0.tgz",
|
||||
"integrity": "sha512-rUY5vtT748NMRbEMrTNiFfy29BgGZwGXUi2NFUVMWQrogSLzlJvQV9eeMWi+g1aVaQ53tpyLAQtd5x/JH0Nh1g=="
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
|
|
@ -6854,6 +7244,15 @@
|
|||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-object": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz",
|
||||
"integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-path-inside": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
|
||||
|
|
@ -7696,6 +8095,18 @@
|
|||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||
|
|
@ -7812,6 +8223,15 @@
|
|||
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
|
|
@ -8177,6 +8597,21 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/min-document": {
|
||||
"version": "2.19.2",
|
||||
"resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.2.tgz",
|
||||
"integrity": "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dom-walk": "^0.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
|
||||
|
|
@ -8300,6 +8735,24 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/native-duplexpair": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz",
|
||||
|
|
@ -8329,6 +8782,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next-tick": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-0.2.2.tgz",
|
||||
"integrity": "sha512-f7h4svPtl+QidoBv4taKXUjJ70G2asaZ8G28nS0OkqaalX8dwwrtWtyxEDPK62AC00ur/+/E0pUwBwY5EPn15Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
||||
|
|
@ -8670,6 +9129,12 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parchment": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
|
||||
|
|
@ -9179,6 +9644,15 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/queue": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
|
||||
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "~2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
|
|
@ -9595,6 +10069,23 @@
|
|||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-regex-test": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
|
||||
"integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"is-regex": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-stable-stringify": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||
|
|
@ -9610,6 +10101,12 @@
|
|||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz",
|
||||
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
|
|
@ -9744,6 +10241,12 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
|
|
@ -10020,6 +10523,11 @@
|
|||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/string-template": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/string-template/-/string-template-0.2.1.tgz",
|
||||
"integrity": "sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw=="
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
|
|
@ -10685,6 +11193,22 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/virtual-dom": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/virtual-dom/-/virtual-dom-2.1.1.tgz",
|
||||
"integrity": "sha512-wb6Qc9Lbqug0kRqo/iuApfBpJJAq14Sk1faAnSmtqXiwahg7PVTvWMs9L02Z8nNIMqbwsxzBAA90bbtRLbw0zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browser-split": "0.0.1",
|
||||
"error": "^4.3.0",
|
||||
"ev-store": "^7.0.0",
|
||||
"global": "^4.3.0",
|
||||
"is-object": "^1.0.1",
|
||||
"next-tick": "^0.2.2",
|
||||
"x-is-array": "0.1.0",
|
||||
"x-is-string": "0.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/walker": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
|
||||
|
|
@ -10862,6 +11386,80 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/x-is-array": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/x-is-array/-/x-is-array-0.1.0.tgz",
|
||||
"integrity": "sha512-goHPif61oNrr0jJgsXRfc8oqtYzvfiMJpTqwE7Z4y9uH+T3UozkGqQ4d2nX9mB9khvA8U2o/UbPOFjgC7hLWIA=="
|
||||
},
|
||||
"node_modules/x-is-string": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/x-is-string/-/x-is-string-0.1.0.tgz",
|
||||
"integrity": "sha512-GojqklwG8gpzOVEVki5KudKNoq7MbbjYZCbyWzEz7tyPA7eleiE0+ePwOWQQRb5fm86rD3S8Tc0tSFf3AOv50w=="
|
||||
},
|
||||
"node_modules/xml": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
|
||||
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xml-js": {
|
||||
"version": "1.6.11",
|
||||
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
|
||||
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sax": "^1.2.4"
|
||||
},
|
||||
"bin": {
|
||||
"xml-js": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder2": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-2.1.2.tgz",
|
||||
"integrity": "sha512-PI710tmtVlQ5VmwzbRTuhmVhKnj9pM8Si+iOZCV2g2SNo3gCrpzR2Ka9wNzZtqfD+mnP+xkrqoNy0sjKZqP4Dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oozcitak/dom": "1.15.5",
|
||||
"@oozcitak/infra": "1.0.5",
|
||||
"@oozcitak/util": "8.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder2/node_modules/@oozcitak/dom": {
|
||||
"version": "1.15.5",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.5.tgz",
|
||||
"integrity": "sha512-L6v3Mwb0TaYBYgeYlIeBaHnc+2ZEaDSbFiRm5KmqZQSoBlbPlf+l6aIH/sD5GUf2MYwULw00LT7+dOnEuAEC0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oozcitak/infra": "1.0.5",
|
||||
"@oozcitak/url": "1.0.0",
|
||||
"@oozcitak/util": "8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder2/node_modules/@oozcitak/dom/node_modules/@oozcitak/util": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.0.0.tgz",
|
||||
"integrity": "sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder2/node_modules/@oozcitak/util": {
|
||||
"version": "8.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.3.tgz",
|
||||
"integrity": "sha512-Ufpab7G5PfnEhQyy5kDg9C8ltWJjsVT1P/IYqacjstaqydG4Q21HAT2HUZQYBrC/a1ZLKCz87pfydlDvv8y97w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -28,10 +28,12 @@
|
|||
"bcryptjs": "^2.4.3",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"docx": "^9.5.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"html-to-docx": "^1.8.0",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"imap": "^0.8.19",
|
||||
"joi": "^17.11.0",
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자
|
|||
import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리
|
||||
import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리
|
||||
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
|
||||
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -255,6 +256,7 @@ app.use("/api/cascading-auto-fill", cascadingAutoFillRoutes); // 자동 입력
|
|||
app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리
|
||||
app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리
|
||||
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
|
||||
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
|
|
|
|||
|
|
@ -3394,13 +3394,23 @@ export async function copyMenu(
|
|||
}
|
||||
: undefined;
|
||||
|
||||
// 추가 복사 옵션 (카테고리, 코드, 채번규칙 등)
|
||||
const additionalCopyOptions = req.body.additionalCopyOptions
|
||||
? {
|
||||
copyCodeCategory: req.body.additionalCopyOptions.copyCodeCategory === true,
|
||||
copyNumberingRules: req.body.additionalCopyOptions.copyNumberingRules === true,
|
||||
copyCategoryMapping: req.body.additionalCopyOptions.copyCategoryMapping === true,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// 메뉴 복사 실행
|
||||
const menuCopyService = new MenuCopyService();
|
||||
const result = await menuCopyService.copyMenu(
|
||||
parseInt(menuObjid, 10),
|
||||
targetCompanyCode,
|
||||
userId,
|
||||
screenNameConfig
|
||||
screenNameConfig,
|
||||
additionalCopyOptions
|
||||
);
|
||||
|
||||
logger.info("✅ 메뉴 복사 API 성공");
|
||||
|
|
|
|||
|
|
@ -662,6 +662,10 @@ export const getParentOptions = async (
|
|||
/**
|
||||
* 연쇄 관계로 자식 옵션 조회
|
||||
* 실제 연쇄 드롭다운에서 사용하는 API
|
||||
*
|
||||
* 다중 부모값 지원:
|
||||
* - parentValue: 단일 값 (예: "공정검사")
|
||||
* - parentValues: 다중 값 (예: "공정검사,출하검사" 또는 배열)
|
||||
*/
|
||||
export const getCascadingOptions = async (
|
||||
req: AuthenticatedRequest,
|
||||
|
|
@ -669,10 +673,26 @@ export const getCascadingOptions = async (
|
|||
) => {
|
||||
try {
|
||||
const { code } = req.params;
|
||||
const { parentValue } = req.query;
|
||||
const { parentValue, parentValues } = req.query;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
if (!parentValue) {
|
||||
// 다중 부모값 파싱
|
||||
let parentValueArray: string[] = [];
|
||||
|
||||
if (parentValues) {
|
||||
// parentValues가 있으면 우선 사용 (다중 선택)
|
||||
if (Array.isArray(parentValues)) {
|
||||
parentValueArray = parentValues.map(v => String(v));
|
||||
} else {
|
||||
// 콤마로 구분된 문자열
|
||||
parentValueArray = String(parentValues).split(',').map(v => v.trim()).filter(v => v);
|
||||
}
|
||||
} else if (parentValue) {
|
||||
// 기존 단일 값 호환
|
||||
parentValueArray = [String(parentValue)];
|
||||
}
|
||||
|
||||
if (parentValueArray.length === 0) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: [],
|
||||
|
|
@ -714,13 +734,17 @@ export const getCascadingOptions = async (
|
|||
|
||||
const relation = relationResult.rows[0];
|
||||
|
||||
// 자식 옵션 조회
|
||||
// 자식 옵션 조회 - 다중 부모값에 대해 IN 절 사용
|
||||
// SQL Injection 방지를 위해 파라미터화된 쿼리 사용
|
||||
const placeholders = parentValueArray.map((_, idx) => `$${idx + 1}`).join(', ');
|
||||
|
||||
let optionsQuery = `
|
||||
SELECT
|
||||
SELECT DISTINCT
|
||||
${relation.child_value_column} as value,
|
||||
${relation.child_label_column} as label
|
||||
${relation.child_label_column} as label,
|
||||
${relation.child_filter_column} as parent_value
|
||||
FROM ${relation.child_table}
|
||||
WHERE ${relation.child_filter_column} = $1
|
||||
WHERE ${relation.child_filter_column} IN (${placeholders})
|
||||
`;
|
||||
|
||||
// 멀티테넌시 적용 (테이블에 company_code가 있는 경우)
|
||||
|
|
@ -730,7 +754,8 @@ export const getCascadingOptions = async (
|
|||
[relation.child_table]
|
||||
);
|
||||
|
||||
const optionsParams: any[] = [parentValue];
|
||||
const optionsParams: any[] = [...parentValueArray];
|
||||
let paramIndex = parentValueArray.length + 1;
|
||||
|
||||
// company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만
|
||||
if (
|
||||
|
|
@ -738,8 +763,9 @@ export const getCascadingOptions = async (
|
|||
tableInfoResult.rowCount > 0 &&
|
||||
companyCode !== "*"
|
||||
) {
|
||||
optionsQuery += ` AND company_code = $2`;
|
||||
optionsQuery += ` AND company_code = $${paramIndex}`;
|
||||
optionsParams.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 정렬
|
||||
|
|
@ -751,9 +777,9 @@ export const getCascadingOptions = async (
|
|||
|
||||
const optionsResult = await pool.query(optionsQuery, optionsParams);
|
||||
|
||||
logger.info("연쇄 옵션 조회", {
|
||||
logger.info("연쇄 옵션 조회 (다중 부모값 지원)", {
|
||||
relationCode: code,
|
||||
parentValue,
|
||||
parentValues: parentValueArray,
|
||||
optionsCount: optionsResult.rowCount,
|
||||
});
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -52,3 +52,5 @@ export default router;
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -48,3 +48,5 @@ export default router;
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -64,3 +64,5 @@ export default router;
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -52,3 +52,5 @@ export default router;
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
import { Router } from "express";
|
||||
import {
|
||||
getCategoryValueCascadingGroups,
|
||||
getCategoryValueCascadingGroupById,
|
||||
getCategoryValueCascadingByCode,
|
||||
createCategoryValueCascadingGroup,
|
||||
updateCategoryValueCascadingGroup,
|
||||
deleteCategoryValueCascadingGroup,
|
||||
saveCategoryValueCascadingMappings,
|
||||
getCategoryValueCascadingOptions,
|
||||
getCategoryValueCascadingParentOptions,
|
||||
getCategoryValueCascadingChildOptions,
|
||||
getCategoryValueCascadingMappingsByTable,
|
||||
} from "../controllers/categoryValueCascadingController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 모든 라우트에 인증 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
// ============================================
|
||||
// 카테고리 값 연쇄관계 그룹 CRUD
|
||||
// ============================================
|
||||
|
||||
// 그룹 목록 조회
|
||||
router.get("/groups", getCategoryValueCascadingGroups);
|
||||
|
||||
// 그룹 상세 조회 (ID)
|
||||
router.get("/groups/:groupId", getCategoryValueCascadingGroupById);
|
||||
|
||||
// 관계 코드로 조회
|
||||
router.get("/code/:code", getCategoryValueCascadingByCode);
|
||||
|
||||
// 그룹 생성
|
||||
router.post("/groups", createCategoryValueCascadingGroup);
|
||||
|
||||
// 그룹 수정
|
||||
router.put("/groups/:groupId", updateCategoryValueCascadingGroup);
|
||||
|
||||
// 그룹 삭제
|
||||
router.delete("/groups/:groupId", deleteCategoryValueCascadingGroup);
|
||||
|
||||
// ============================================
|
||||
// 카테고리 값 연쇄관계 매핑
|
||||
// ============================================
|
||||
|
||||
// 매핑 일괄 저장
|
||||
router.post("/groups/:groupId/mappings", saveCategoryValueCascadingMappings);
|
||||
|
||||
// ============================================
|
||||
// 연쇄 옵션 조회 (실제 드롭다운에서 사용)
|
||||
// ============================================
|
||||
|
||||
// 부모 카테고리 값 목록 조회
|
||||
router.get("/parent-options/:code", getCategoryValueCascadingParentOptions);
|
||||
|
||||
// 자식 카테고리 값 목록 조회 (매핑 설정 UI용)
|
||||
router.get("/child-options/:code", getCategoryValueCascadingChildOptions);
|
||||
|
||||
// 연쇄 옵션 조회 (부모 값 기반 자식 옵션)
|
||||
router.get("/options/:code", getCategoryValueCascadingOptions);
|
||||
|
||||
// ============================================
|
||||
// 테이블별 매핑 조회 (테이블 목록 표시용)
|
||||
// ============================================
|
||||
|
||||
// 테이블명으로 해당 테이블의 모든 연쇄관계 매핑 조회
|
||||
router.get(
|
||||
"/table/:tableName/mappings",
|
||||
getCategoryValueCascadingMappingsByTable
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
@ -56,6 +56,11 @@ router.post("/upload-image", upload.single("image"), (req, res, next) =>
|
|||
reportController.uploadImage(req, res, next)
|
||||
);
|
||||
|
||||
// WORD(DOCX) 내보내기
|
||||
router.post("/export-word", (req, res, next) =>
|
||||
reportController.exportToWord(req, res, next)
|
||||
);
|
||||
|
||||
// 리포트 목록
|
||||
router.get("/", (req, res, next) =>
|
||||
reportController.getReports(req, res, next)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,15 @@ const router = Router();
|
|||
// 모든 role 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
/**
|
||||
* 사용자 권한 그룹 조회 (/:id 보다 먼저 정의해야 함)
|
||||
*/
|
||||
// 현재 사용자가 속한 권한 그룹 조회
|
||||
router.get("/user/my-groups", getUserRoleGroups);
|
||||
|
||||
// 특정 사용자가 속한 권한 그룹 조회
|
||||
router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups);
|
||||
|
||||
/**
|
||||
* 권한 그룹 CRUD
|
||||
*/
|
||||
|
|
@ -67,13 +76,4 @@ router.get("/:id/menu-permissions", requireAdmin, getMenuPermissions);
|
|||
// 메뉴 권한 설정
|
||||
router.put("/:id/menu-permissions", requireAdmin, setMenuPermissions);
|
||||
|
||||
/**
|
||||
* 사용자 권한 그룹 조회
|
||||
*/
|
||||
// 현재 사용자가 속한 권한 그룹 조회
|
||||
router.get("/user/my-groups", getUserRoleGroups);
|
||||
|
||||
// 특정 사용자가 속한 권한 그룹 조회
|
||||
router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -186,8 +186,13 @@ export class EntityJoinService {
|
|||
}
|
||||
}
|
||||
|
||||
// 별칭 컬럼명 생성 (writer -> writer_name)
|
||||
const aliasColumn = `${column.column_name}_name`;
|
||||
// 🎯 별칭 컬럼명 생성 - 사용자가 선택한 displayColumns 기반으로 동적 생성
|
||||
// 단일 컬럼: manager + user_name → manager_user_name
|
||||
// 여러 컬럼: 첫 번째 컬럼 기준 (나머지는 개별 alias로 처리됨)
|
||||
const firstDisplayColumn = displayColumns[0] || "name";
|
||||
const aliasColumn = `${column.column_name}_${firstDisplayColumn}`;
|
||||
|
||||
logger.info(`🔧 별칭 컬럼명 생성: ${column.column_name} + ${firstDisplayColumn} → ${aliasColumn}`);
|
||||
|
||||
const joinConfig: EntityJoinConfig = {
|
||||
sourceTable: tableName,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -477,6 +477,12 @@ export class ReportService {
|
|||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
`;
|
||||
|
||||
// components가 이미 문자열이면 그대로, 객체면 JSON.stringify
|
||||
const componentsData =
|
||||
typeof originalLayout.components === "string"
|
||||
? originalLayout.components
|
||||
: JSON.stringify(originalLayout.components);
|
||||
|
||||
await client.query(copyLayoutQuery, [
|
||||
newLayoutId,
|
||||
newReportId,
|
||||
|
|
@ -487,7 +493,7 @@ export class ReportService {
|
|||
originalLayout.margin_bottom,
|
||||
originalLayout.margin_left,
|
||||
originalLayout.margin_right,
|
||||
JSON.stringify(originalLayout.components),
|
||||
componentsData,
|
||||
userId,
|
||||
]);
|
||||
}
|
||||
|
|
@ -561,7 +567,7 @@ export class ReportService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 저장 (쿼리 포함)
|
||||
* 레이아웃 저장 (쿼리 포함) - 페이지 기반 구조
|
||||
*/
|
||||
async saveLayout(
|
||||
reportId: string,
|
||||
|
|
@ -569,6 +575,19 @@ export class ReportService {
|
|||
userId: string
|
||||
): Promise<boolean> {
|
||||
return transaction(async (client) => {
|
||||
// 첫 번째 페이지 정보를 기본 레이아웃으로 사용
|
||||
const firstPage = data.layoutConfig.pages[0];
|
||||
const canvasWidth = firstPage?.width || 210;
|
||||
const canvasHeight = firstPage?.height || 297;
|
||||
const pageOrientation =
|
||||
canvasWidth > canvasHeight ? "landscape" : "portrait";
|
||||
const margins = firstPage?.margins || {
|
||||
top: 20,
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
right: 20,
|
||||
};
|
||||
|
||||
// 1. 레이아웃 저장
|
||||
const existingQuery = `
|
||||
SELECT layout_id FROM report_layout WHERE report_id = $1
|
||||
|
|
@ -576,7 +595,7 @@ export class ReportService {
|
|||
const existing = await client.query(existingQuery, [reportId]);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
// 업데이트
|
||||
// 업데이트 - components 컬럼에 전체 layoutConfig 저장
|
||||
const updateQuery = `
|
||||
UPDATE report_layout
|
||||
SET
|
||||
|
|
@ -594,14 +613,14 @@ export class ReportService {
|
|||
`;
|
||||
|
||||
await client.query(updateQuery, [
|
||||
data.canvasWidth,
|
||||
data.canvasHeight,
|
||||
data.pageOrientation,
|
||||
data.marginTop,
|
||||
data.marginBottom,
|
||||
data.marginLeft,
|
||||
data.marginRight,
|
||||
JSON.stringify(data.components),
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
pageOrientation,
|
||||
margins.top,
|
||||
margins.bottom,
|
||||
margins.left,
|
||||
margins.right,
|
||||
JSON.stringify(data.layoutConfig), // 전체 layoutConfig 저장
|
||||
userId,
|
||||
reportId,
|
||||
]);
|
||||
|
|
@ -627,14 +646,14 @@ export class ReportService {
|
|||
await client.query(insertQuery, [
|
||||
layoutId,
|
||||
reportId,
|
||||
data.canvasWidth,
|
||||
data.canvasHeight,
|
||||
data.pageOrientation,
|
||||
data.marginTop,
|
||||
data.marginBottom,
|
||||
data.marginLeft,
|
||||
data.marginRight,
|
||||
JSON.stringify(data.components),
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
pageOrientation,
|
||||
margins.top,
|
||||
margins.bottom,
|
||||
margins.left,
|
||||
margins.right,
|
||||
JSON.stringify(data.layoutConfig), // 전체 layoutConfig 저장
|
||||
userId,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,22 +116,38 @@ export interface UpdateReportRequest {
|
|||
useYn?: string;
|
||||
}
|
||||
|
||||
// 페이지 설정
|
||||
export interface PageConfig {
|
||||
page_id: string;
|
||||
page_name: string;
|
||||
page_order: number;
|
||||
width: number;
|
||||
height: number;
|
||||
background_color: string;
|
||||
margins: {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
};
|
||||
components: any[];
|
||||
}
|
||||
|
||||
// 레이아웃 설정
|
||||
export interface ReportLayoutConfig {
|
||||
pages: PageConfig[];
|
||||
}
|
||||
|
||||
// 레이아웃 저장 요청
|
||||
export interface SaveLayoutRequest {
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
pageOrientation: string;
|
||||
marginTop: number;
|
||||
marginBottom: number;
|
||||
marginLeft: number;
|
||||
marginRight: number;
|
||||
components: any[];
|
||||
layoutConfig: ReportLayoutConfig;
|
||||
queries?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: "MASTER" | "DETAIL";
|
||||
sqlQuery: string;
|
||||
parameters: string[];
|
||||
externalConnectionId?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -584,3 +584,5 @@ const result = await executeNodeFlow(flowId, {
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -357,3 +357,5 @@
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -343,3 +343,5 @@ const getComponentValue = (componentId: string) => {
|
|||
3. **조건부 저장**: 특정 조건 만족 시에만 저장
|
||||
4. **연쇄 저장**: 한 번의 클릭으로 여러 테이블에 저장
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Link2, Layers, Filter, FormInput, Ban } from "lucide-react";
|
||||
import { Link2, Layers, Filter, FormInput, Ban, Tags } from "lucide-react";
|
||||
|
||||
// 탭별 컴포넌트
|
||||
import CascadingRelationsTab from "./tabs/CascadingRelationsTab";
|
||||
|
|
@ -11,6 +11,7 @@ import AutoFillTab from "./tabs/AutoFillTab";
|
|||
import HierarchyTab from "./tabs/HierarchyTab";
|
||||
import ConditionTab from "./tabs/ConditionTab";
|
||||
import MutualExclusionTab from "./tabs/MutualExclusionTab";
|
||||
import CategoryValueCascadingTab from "./tabs/CategoryValueCascadingTab";
|
||||
|
||||
export default function CascadingManagementPage() {
|
||||
const searchParams = useSearchParams();
|
||||
|
|
@ -20,7 +21,7 @@ export default function CascadingManagementPage() {
|
|||
// URL 쿼리 파라미터에서 탭 설정
|
||||
useEffect(() => {
|
||||
const tab = searchParams.get("tab");
|
||||
if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion"].includes(tab)) {
|
||||
if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion", "category-value"].includes(tab)) {
|
||||
setActiveTab(tab);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
|
@ -46,7 +47,7 @@ export default function CascadingManagementPage() {
|
|||
|
||||
{/* 탭 네비게이션 */}
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
<TabsList className="grid w-full grid-cols-6">
|
||||
<TabsTrigger value="relations" className="gap-2">
|
||||
<Link2 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">2단계 연쇄관계</span>
|
||||
|
|
@ -72,6 +73,11 @@ export default function CascadingManagementPage() {
|
|||
<span className="hidden sm:inline">상호 배제</span>
|
||||
<span className="sm:hidden">배제</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="category-value" className="gap-2">
|
||||
<Tags className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">카테고리값</span>
|
||||
<span className="sm:hidden">카테고리</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 탭 컨텐츠 */}
|
||||
|
|
@ -95,6 +101,10 @@ export default function CascadingManagementPage() {
|
|||
<TabsContent value="exclusion">
|
||||
<MutualExclusionTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="category-value">
|
||||
<CategoryValueCascadingTab />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -459,11 +459,39 @@ export default function TableManagementPage() {
|
|||
if (!selectedTable) return;
|
||||
|
||||
try {
|
||||
// 🎯 Entity 타입인 경우 detailSettings에 엔티티 설정을 JSON으로 포함
|
||||
let finalDetailSettings = column.detailSettings || "";
|
||||
|
||||
if (column.inputType === "entity" && column.referenceTable) {
|
||||
// 기존 detailSettings를 파싱하거나 새로 생성
|
||||
let existingSettings: Record<string, unknown> = {};
|
||||
if (typeof column.detailSettings === "string" && column.detailSettings.trim().startsWith("{")) {
|
||||
try {
|
||||
existingSettings = JSON.parse(column.detailSettings);
|
||||
} catch {
|
||||
existingSettings = {};
|
||||
}
|
||||
}
|
||||
|
||||
// 엔티티 설정 추가
|
||||
const entitySettings = {
|
||||
...existingSettings,
|
||||
entityTable: column.referenceTable,
|
||||
entityCodeColumn: column.referenceColumn || "id",
|
||||
entityLabelColumn: column.displayColumn || "name",
|
||||
placeholder: (existingSettings.placeholder as string) || "항목을 선택하세요",
|
||||
searchable: existingSettings.searchable ?? true,
|
||||
};
|
||||
|
||||
finalDetailSettings = JSON.stringify(entitySettings);
|
||||
console.log("🔧 Entity 설정 JSON 생성:", entitySettings);
|
||||
}
|
||||
|
||||
const columnSetting = {
|
||||
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
|
||||
columnLabel: column.displayName, // 사용자가 입력한 표시명
|
||||
inputType: column.inputType || "text",
|
||||
detailSettings: column.detailSettings || "",
|
||||
detailSettings: finalDetailSettings,
|
||||
codeCategory: column.codeCategory || "",
|
||||
codeValue: column.codeValue || "",
|
||||
referenceTable: column.referenceTable || "",
|
||||
|
|
@ -547,7 +575,7 @@ export default function TableManagementPage() {
|
|||
} else if (successCount > 0 && failCount > 0) {
|
||||
toast.warning(`컬럼 설정 저장 성공. ${successCount}개 메뉴 매핑 성공, ${failCount}개 실패.`);
|
||||
} else if (failCount > 0) {
|
||||
toast.error(`컬럼 설정 저장 성공. 메뉴 매핑 생성 실패.`);
|
||||
toast.error("컬럼 설정 저장 성공. 메뉴 매핑 생성 실패.");
|
||||
}
|
||||
} else {
|
||||
toast.success("컬럼 설정이 저장되었습니다. (메뉴 매핑 없음)");
|
||||
|
|
@ -680,9 +708,7 @@ export default function TableManagementPage() {
|
|||
console.log("📊 전체 매핑 결과:", { totalSuccessCount, totalFailCount });
|
||||
|
||||
if (totalSuccessCount > 0) {
|
||||
toast.success(
|
||||
`테이블 설정 및 ${totalSuccessCount}개 카테고리 메뉴 매핑이 저장되었습니다.`
|
||||
);
|
||||
toast.success(`테이블 설정 및 ${totalSuccessCount}개 카테고리 메뉴 매핑이 저장되었습니다.`);
|
||||
} else if (totalFailCount > 0) {
|
||||
toast.warning(`테이블 설정은 저장되었으나 ${totalFailCount}개 메뉴 매핑 생성 실패.`);
|
||||
} else {
|
||||
|
|
@ -1000,14 +1026,15 @@ export default function TableManagementPage() {
|
|||
.filter(
|
||||
(table) =>
|
||||
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(table.displayName && table.displayName.toLowerCase().includes(searchTerm.toLowerCase())),
|
||||
(table.displayName &&
|
||||
table.displayName.toLowerCase().includes(searchTerm.toLowerCase())),
|
||||
)
|
||||
.every((table) => selectedTableIds.has(table.tableName))
|
||||
}
|
||||
onCheckedChange={handleSelectAll}
|
||||
aria-label="전체 선택"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{selectedTableIds.size > 0 && `${selectedTableIds.size}개 선택됨`}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -1048,8 +1075,8 @@ export default function TableManagementPage() {
|
|||
key={table.tableName}
|
||||
className={`bg-card rounded-lg p-4 shadow-sm transition-all ${
|
||||
selectedTable === table.tableName
|
||||
? "shadow-md bg-muted/30"
|
||||
: "hover:shadow-lg hover:bg-muted/20"
|
||||
? "bg-muted/30 shadow-md"
|
||||
: "hover:bg-muted/20 hover:shadow-lg"
|
||||
}`}
|
||||
style={
|
||||
selectedTable === table.tableName
|
||||
|
|
@ -1068,10 +1095,7 @@ export default function TableManagementPage() {
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="flex-1 cursor-pointer"
|
||||
onClick={() => handleTableSelect(table.tableName)}
|
||||
>
|
||||
<div className="flex-1 cursor-pointer" onClick={() => handleTableSelect(table.tableName)}>
|
||||
<h4 className="text-sm font-semibold">{table.displayName || table.tableName}</h4>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")}
|
||||
|
|
@ -1147,7 +1171,10 @@ export default function TableManagementPage() {
|
|||
) : (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* 컬럼 헤더 (고정) */}
|
||||
<div className="text-foreground grid h-12 flex-shrink-0 items-center border-b px-6 py-3 text-sm font-semibold" style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}>
|
||||
<div
|
||||
className="text-foreground grid h-12 flex-shrink-0 items-center border-b px-6 py-3 text-sm font-semibold"
|
||||
style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}
|
||||
>
|
||||
<div className="pr-4">컬럼명</div>
|
||||
<div className="px-4">라벨</div>
|
||||
<div className="pr-6">입력 타입</div>
|
||||
|
|
@ -1171,7 +1198,7 @@ export default function TableManagementPage() {
|
|||
className="bg-background hover:bg-muted/50 grid min-h-16 items-start border-b px-6 py-3 transition-colors"
|
||||
style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}
|
||||
>
|
||||
<div className="pr-4 pt-1">
|
||||
<div className="pt-1 pr-4">
|
||||
<div className="font-mono text-sm">{column.columnName}</div>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
|
|
@ -1226,9 +1253,9 @@ export default function TableManagementPage() {
|
|||
<label className="text-muted-foreground mb-1 block text-xs">
|
||||
적용할 메뉴 (2레벨)
|
||||
</label>
|
||||
<div className="border rounded-lg p-3 space-y-2 max-h-48 overflow-y-auto">
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto rounded-lg border p-3">
|
||||
{secondLevelMenus.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
2레벨 메뉴가 없습니다. 메뉴를 선택하지 않으면 모든 메뉴에서 사용 가능합니다.
|
||||
</p>
|
||||
) : (
|
||||
|
|
@ -1253,15 +1280,15 @@ export default function TableManagementPage() {
|
|||
prev.map((col) =>
|
||||
col.columnName === column.columnName
|
||||
? { ...col, categoryMenus: newMenus }
|
||||
: col
|
||||
)
|
||||
: col,
|
||||
),
|
||||
);
|
||||
}}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring"
|
||||
className="text-primary focus:ring-ring h-4 w-4 rounded border-gray-300 focus:ring-2"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`category-menu-${column.columnName}-${menu.menuObjid}`}
|
||||
className="text-xs cursor-pointer flex-1"
|
||||
className="flex-1 cursor-pointer text-xs"
|
||||
>
|
||||
{menu.parentMenuName} → {menu.menuName}
|
||||
</label>
|
||||
|
|
@ -1282,9 +1309,7 @@ export default function TableManagementPage() {
|
|||
<>
|
||||
{/* 참조 테이블 */}
|
||||
<div className="w-48">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">
|
||||
참조 테이블
|
||||
</label>
|
||||
<label className="text-muted-foreground mb-1 block text-xs">참조 테이블</label>
|
||||
<Select
|
||||
value={column.referenceTable || "none"}
|
||||
onValueChange={(value) =>
|
||||
|
|
@ -1296,15 +1321,10 @@ export default function TableManagementPage() {
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceTableOptions.map((option, index) => (
|
||||
<SelectItem
|
||||
key={`entity-${option.value}-${index}`}
|
||||
value={option.value}
|
||||
>
|
||||
<SelectItem key={`entity-${option.value}-${index}`} value={option.value}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{option.label}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{option.value}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">{option.value}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
|
|
@ -1315,9 +1335,7 @@ export default function TableManagementPage() {
|
|||
{/* 조인 컬럼 */}
|
||||
{column.referenceTable && column.referenceTable !== "none" && (
|
||||
<div className="w-48">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">
|
||||
조인 컬럼
|
||||
</label>
|
||||
<label className="text-muted-foreground mb-1 block text-xs">조인 컬럼</label>
|
||||
<Select
|
||||
value={column.referenceColumn || "none"}
|
||||
onValueChange={(value) =>
|
||||
|
|
@ -1361,9 +1379,7 @@ export default function TableManagementPage() {
|
|||
column.referenceColumn &&
|
||||
column.referenceColumn !== "none" && (
|
||||
<div className="w-48">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">
|
||||
표시 컬럼
|
||||
</label>
|
||||
<label className="text-muted-foreground mb-1 block text-xs">표시 컬럼</label>
|
||||
<Select
|
||||
value={column.displayColumn || "none"}
|
||||
onValueChange={(value) =>
|
||||
|
|
@ -1408,7 +1424,7 @@ export default function TableManagementPage() {
|
|||
column.referenceColumn !== "none" &&
|
||||
column.displayColumn &&
|
||||
column.displayColumn !== "none" && (
|
||||
<div className="bg-primary/10 text-primary flex items-center gap-1 rounded px-2 py-1 text-xs w-48">
|
||||
<div className="bg-primary/10 text-primary flex w-48 items-center gap-1 rounded px-2 py-1 text-xs">
|
||||
<span>✓</span>
|
||||
<span className="truncate">설정 완료</span>
|
||||
</div>
|
||||
|
|
@ -1460,9 +1476,10 @@ export default function TableManagementPage() {
|
|||
setDuplicateSourceTable(null);
|
||||
}}
|
||||
onSuccess={async (result) => {
|
||||
const message = duplicateModalMode === "duplicate"
|
||||
? "테이블이 성공적으로 복제되었습니다!"
|
||||
: "테이블이 성공적으로 생성되었습니다!";
|
||||
const message =
|
||||
duplicateModalMode === "duplicate"
|
||||
? "테이블이 성공적으로 복제되었습니다!"
|
||||
: "테이블이 성공적으로 생성되었습니다!";
|
||||
toast.success(message);
|
||||
// 테이블 목록 새로고침
|
||||
await loadTables();
|
||||
|
|
@ -1516,13 +1533,10 @@ export default function TableManagementPage() {
|
|||
{selectedTableIds.size > 0 ? (
|
||||
<>
|
||||
선택된 <strong>{selectedTableIds.size}개</strong>의 테이블을 삭제하시겠습니까?
|
||||
<br />
|
||||
이 작업은 되돌릴 수 없습니다.
|
||||
<br />이 작업은 되돌릴 수 없습니다.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
정말로 테이블을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</>
|
||||
<>정말로 테이블을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
|
@ -1600,4 +1614,3 @@ export default function TableManagementPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,13 @@ export function MenuCopyDialog({
|
|||
const [removeText, setRemoveText] = useState("");
|
||||
const [addPrefix, setAddPrefix] = useState("");
|
||||
|
||||
// 카테고리/코드 복사 옵션
|
||||
const [copyCodeCategory, setCopyCodeCategory] = useState(false);
|
||||
const [copyNumberingRules, setCopyNumberingRules] = useState(false);
|
||||
const [copyCategoryMapping, setCopyCategoryMapping] = useState(false);
|
||||
const [copyTableTypeColumns, setCopyTableTypeColumns] = useState(false);
|
||||
const [copyCascadingRelation, setCopyCascadingRelation] = useState(false);
|
||||
|
||||
// 회사 목록 로드
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
|
|
@ -66,6 +73,11 @@ export function MenuCopyDialog({
|
|||
setUseBulkRename(false);
|
||||
setRemoveText("");
|
||||
setAddPrefix("");
|
||||
setCopyCodeCategory(false);
|
||||
setCopyNumberingRules(false);
|
||||
setCopyCategoryMapping(false);
|
||||
setCopyTableTypeColumns(false);
|
||||
setCopyCascadingRelation(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
|
|
@ -112,10 +124,20 @@ export function MenuCopyDialog({
|
|||
}
|
||||
: undefined;
|
||||
|
||||
// 추가 복사 옵션
|
||||
const additionalCopyOptions = {
|
||||
copyCodeCategory,
|
||||
copyNumberingRules,
|
||||
copyCategoryMapping,
|
||||
copyTableTypeColumns,
|
||||
copyCascadingRelation,
|
||||
};
|
||||
|
||||
const response = await menuApi.copyMenu(
|
||||
menuObjid,
|
||||
targetCompanyCode,
|
||||
screenNameConfig
|
||||
screenNameConfig,
|
||||
additionalCopyOptions
|
||||
);
|
||||
|
||||
if (response.success && response.data) {
|
||||
|
|
@ -264,19 +286,96 @@ export function MenuCopyDialog({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 추가 복사 옵션 */}
|
||||
{!result && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-medium">추가 복사 옵션 (선택사항):</p>
|
||||
<div className="space-y-2 pl-2 border-l-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="copyCodeCategory"
|
||||
checked={copyCodeCategory}
|
||||
onCheckedChange={(checked) => setCopyCodeCategory(checked as boolean)}
|
||||
disabled={copying}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="copyCodeCategory"
|
||||
className="text-xs cursor-pointer"
|
||||
>
|
||||
코드 카테고리 + 코드 복사
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="copyNumberingRules"
|
||||
checked={copyNumberingRules}
|
||||
onCheckedChange={(checked) => setCopyNumberingRules(checked as boolean)}
|
||||
disabled={copying}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="copyNumberingRules"
|
||||
className="text-xs cursor-pointer"
|
||||
>
|
||||
채번 규칙 복사
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="copyCategoryMapping"
|
||||
checked={copyCategoryMapping}
|
||||
onCheckedChange={(checked) => setCopyCategoryMapping(checked as boolean)}
|
||||
disabled={copying}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="copyCategoryMapping"
|
||||
className="text-xs cursor-pointer"
|
||||
>
|
||||
카테고리 매핑 + 값 복사
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="copyTableTypeColumns"
|
||||
checked={copyTableTypeColumns}
|
||||
onCheckedChange={(checked) => setCopyTableTypeColumns(checked as boolean)}
|
||||
disabled={copying}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="copyTableTypeColumns"
|
||||
className="text-xs cursor-pointer"
|
||||
>
|
||||
테이블 타입관리 입력타입 설정 복사
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 복사 항목 안내 */}
|
||||
{!result && (
|
||||
<div className="rounded-md border p-3 text-xs">
|
||||
<p className="font-medium mb-2">복사되는 항목:</p>
|
||||
<p className="font-medium mb-2">기본 복사 항목:</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||
<li>메뉴 구조 (하위 메뉴 포함)</li>
|
||||
<li>화면 + 레이아웃 (모달, 조건부 컨테이너)</li>
|
||||
<li>플로우 제어 (스텝, 연결)</li>
|
||||
<li>코드 카테고리 + 코드</li>
|
||||
<li>카테고리 설정 + 채번 규칙</li>
|
||||
</ul>
|
||||
<p className="mt-2 text-warning">
|
||||
⚠️ 실제 데이터는 복사되지 않습니다.
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
* 코드, 채번규칙, 카테고리는 위 옵션 선택 시 복사됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -294,10 +393,46 @@ export function MenuCopyDialog({
|
|||
<span className="text-muted-foreground">화면:</span>{" "}
|
||||
<span className="font-medium">{result.copiedScreens}개</span>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground">플로우:</span>{" "}
|
||||
<span className="font-medium">{result.copiedFlows}개</span>
|
||||
</div>
|
||||
{(result.copiedCodeCategories ?? 0) > 0 && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">코드 카테고리:</span>{" "}
|
||||
<span className="font-medium">{result.copiedCodeCategories}개</span>
|
||||
</div>
|
||||
)}
|
||||
{(result.copiedCodes ?? 0) > 0 && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">코드:</span>{" "}
|
||||
<span className="font-medium">{result.copiedCodes}개</span>
|
||||
</div>
|
||||
)}
|
||||
{(result.copiedNumberingRules ?? 0) > 0 && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">채번규칙:</span>{" "}
|
||||
<span className="font-medium">{result.copiedNumberingRules}개</span>
|
||||
</div>
|
||||
)}
|
||||
{(result.copiedCategoryMappings ?? 0) > 0 && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">카테고리 매핑:</span>{" "}
|
||||
<span className="font-medium">{result.copiedCategoryMappings}개</span>
|
||||
</div>
|
||||
)}
|
||||
{(result.copiedTableTypeColumns ?? 0) > 0 && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">테이블 타입 설정:</span>{" "}
|
||||
<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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -916,7 +916,7 @@ export function CanvasElement({
|
|||
) : element.type === "widget" && element.subtype === "weather" ? (
|
||||
// 날씨 위젯 렌더링
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<WeatherWidget city="서울" refreshInterval={600000} />
|
||||
<WeatherWidget element={element} city="서울" refreshInterval={600000} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "exchange" ? (
|
||||
// 환율 위젯 렌더링
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { PlacedObject, ToolType, Warehouse, Area, Location, ObjectType } from "@/types/digitalTwin";
|
||||
|
|
@ -2141,45 +2142,39 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
자재가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<div className="max-h-[400px] overflow-y-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted sticky top-0">
|
||||
<TableRow>
|
||||
<TableHead className="w-[70px] whitespace-nowrap px-3 py-3 text-sm">층</TableHead>
|
||||
{(hierarchyConfig?.material?.displayColumns || []).map((col) => (
|
||||
<TableHead key={col.column} className="px-3 py-3 text-sm">
|
||||
{col.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{materials.map((material, index) => {
|
||||
const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER";
|
||||
const keyColumn = hierarchyConfig?.material?.keyColumn || "STKKEY";
|
||||
const displayColumns = hierarchyConfig?.material?.displayColumns || [];
|
||||
|
||||
const layerValue = material[layerColumn] || index + 1;
|
||||
const keyValue = material[keyColumn] || `자재 ${index + 1}`;
|
||||
const layerNumber = material[layerColumn] || index + 1;
|
||||
|
||||
return (
|
||||
<AccordionItem key={`${keyValue}-${index}`} value={`item-${index}`} className="border-b">
|
||||
<AccordionTrigger className="px-2 py-3 hover:no-underline">
|
||||
<div className="flex w-full items-center justify-between pr-2">
|
||||
<span className="text-sm font-medium">층 {layerValue}</span>
|
||||
<span className="text-muted-foreground max-w-[150px] truncate text-xs">{keyValue}</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-2 pb-3">
|
||||
{displayColumns.length === 0 ? (
|
||||
<p className="text-muted-foreground py-2 text-center text-xs">
|
||||
표시할 컬럼이 설정되지 않았습니다
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{displayColumns.map((item) => (
|
||||
<div key={item.column} className="flex justify-between gap-2 text-xs">
|
||||
<span className="text-muted-foreground shrink-0">{item.label}:</span>
|
||||
<span className="text-right font-medium break-all">
|
||||
{material[item.column] || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<TableRow key={material[keyColumn] || `material-${index}`}>
|
||||
<TableCell className="whitespace-nowrap px-3 py-3 text-sm font-medium">{layerNumber}단</TableCell>
|
||||
{displayColumns.map((col) => (
|
||||
<TableCell key={col.column} className="px-3 py-3 text-sm">
|
||||
{material[col.column] || "-"}
|
||||
</TableCell>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : selectedObject ? (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Loader2, Search, X, Grid3x3, Package, ParkingCircle, RefreshCw } from "lucide-react";
|
||||
import { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { Loader2, Search, X, Grid3x3, Package, ParkingCircle, RefreshCw, Maximize, Minimize } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -12,6 +12,7 @@ import type { PlacedObject, MaterialData } from "@/types/digitalTwin";
|
|||
import { getLayoutById, getMaterials } from "@/lib/api/digitalTwin";
|
||||
import { OBJECT_COLORS, DEFAULT_COLOR } from "./constants";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { apiCall } from "@/lib/api/client";
|
||||
|
||||
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
|
||||
ssr: false,
|
||||
|
|
@ -26,6 +27,9 @@ interface DigitalTwinViewerProps {
|
|||
layoutId: number;
|
||||
}
|
||||
|
||||
// 외부 업체 역할 코드
|
||||
const EXTERNAL_VENDOR_ROLE = "LSTHIRA_EXTERNAL_VENDOR";
|
||||
|
||||
export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) {
|
||||
const { toast } = useToast();
|
||||
const [placedObjects, setPlacedObjects] = useState<PlacedObject[]>([]);
|
||||
|
|
@ -43,6 +47,73 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
const [filterType, setFilterType] = useState<string>("all");
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
// 외부 업체 모드
|
||||
const [isExternalMode, setIsExternalMode] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [layoutKey, setLayoutKey] = useState(0); // 레이아웃 강제 리렌더링용
|
||||
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(null); // 마지막 갱신 시간
|
||||
const canvasContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 외부 업체 역할 체크
|
||||
useEffect(() => {
|
||||
const checkExternalRole = async () => {
|
||||
try {
|
||||
const response = await apiCall<any[]>("GET", "/roles/user/my-groups");
|
||||
console.log("=== 사용자 권한 그룹 조회 ===");
|
||||
console.log("API 응답:", response);
|
||||
console.log("찾는 역할:", EXTERNAL_VENDOR_ROLE);
|
||||
|
||||
if (response.success && response.data) {
|
||||
console.log("권한 그룹 목록:", response.data);
|
||||
|
||||
// 사용자의 권한 그룹 중 LSTHIRA_EXTERNAL_VENDOR가 있는지 확인
|
||||
const hasExternalRole = response.data.some((group: any) => {
|
||||
console.log("체크 중인 그룹:", group.authCode, group.authName);
|
||||
return group.authCode === EXTERNAL_VENDOR_ROLE || group.authName === EXTERNAL_VENDOR_ROLE;
|
||||
});
|
||||
|
||||
console.log("외부 업체 역할 보유:", hasExternalRole);
|
||||
setIsExternalMode(hasExternalRole);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("역할 조회 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
checkExternalRole();
|
||||
}, []);
|
||||
|
||||
// 전체 화면 토글 (3D 캔버스 영역만)
|
||||
const toggleFullscreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
// 3D 캔버스 컨테이너만 풀스크린
|
||||
canvasContainerRef.current?.requestFullscreen();
|
||||
setIsFullscreen(true);
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 전체 화면 변경 감지
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
const isNowFullscreen = !!document.fullscreenElement;
|
||||
setIsFullscreen(isNowFullscreen);
|
||||
|
||||
// 전체화면 종료 시 레이아웃 강제 리렌더링
|
||||
if (!isNowFullscreen) {
|
||||
setTimeout(() => {
|
||||
setLayoutKey((prev) => prev + 1);
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
}, 50);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
||||
return () => document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
||||
}, []);
|
||||
|
||||
// 레이아웃 데이터 로드 함수
|
||||
const loadLayout = async () => {
|
||||
try {
|
||||
|
|
@ -144,6 +215,8 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
}),
|
||||
);
|
||||
}
|
||||
// 마지막 갱신 시간 기록
|
||||
setLastRefreshedAt(new Date());
|
||||
} else {
|
||||
throw new Error(response.error || "레이아웃 조회 실패");
|
||||
}
|
||||
|
|
@ -180,6 +253,155 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [layoutId]);
|
||||
|
||||
// 10초 주기 자동 갱신 (중앙 관제 화면 자동 새로고침)
|
||||
useEffect(() => {
|
||||
const AUTO_REFRESH_INTERVAL = 10000; // 10초
|
||||
|
||||
const silentRefresh = async () => {
|
||||
// 로딩 중이거나 새로고침 중이면 스킵
|
||||
if (isLoading || isRefreshing) return;
|
||||
|
||||
try {
|
||||
// 레이아웃 데이터 조용히 갱신
|
||||
const response = await getLayoutById(layoutId);
|
||||
|
||||
if (response.success && response.data) {
|
||||
const { layout, objects } = response.data;
|
||||
const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId;
|
||||
|
||||
// hierarchy_config 파싱
|
||||
let hierarchyConfigData: any = null;
|
||||
if (layout.hierarchy_config) {
|
||||
hierarchyConfigData =
|
||||
typeof layout.hierarchy_config === "string"
|
||||
? JSON.parse(layout.hierarchy_config)
|
||||
: layout.hierarchy_config;
|
||||
setHierarchyConfig(hierarchyConfigData);
|
||||
}
|
||||
|
||||
// 객체 데이터 변환
|
||||
const loadedObjects: PlacedObject[] = objects.map((obj: any) => {
|
||||
const objectType = obj.object_type;
|
||||
return {
|
||||
id: obj.id,
|
||||
type: objectType,
|
||||
name: obj.object_name,
|
||||
position: {
|
||||
x: parseFloat(obj.position_x),
|
||||
y: parseFloat(obj.position_y),
|
||||
z: parseFloat(obj.position_z),
|
||||
},
|
||||
size: {
|
||||
x: parseFloat(obj.size_x),
|
||||
y: parseFloat(obj.size_y),
|
||||
z: parseFloat(obj.size_z),
|
||||
},
|
||||
rotation: obj.rotation ? parseFloat(obj.rotation) : 0,
|
||||
color: getObjectColor(objectType, obj.color),
|
||||
areaKey: obj.area_key,
|
||||
locaKey: obj.loca_key,
|
||||
locType: obj.loc_type,
|
||||
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
|
||||
materialPreview:
|
||||
obj.loc_type === "STP" || !obj.material_preview_height
|
||||
? undefined
|
||||
: { height: parseFloat(obj.material_preview_height) },
|
||||
parentId: obj.parent_id,
|
||||
displayOrder: obj.display_order,
|
||||
locked: obj.locked,
|
||||
visible: obj.visible !== false,
|
||||
hierarchyLevel: obj.hierarchy_level,
|
||||
parentKey: obj.parent_key,
|
||||
externalKey: obj.external_key,
|
||||
};
|
||||
});
|
||||
|
||||
// 외부 DB 연결이 있고 자재 설정이 있으면, 각 Location의 실제 자재 개수 조회
|
||||
if (dbConnectionId && hierarchyConfigData?.material) {
|
||||
const locationObjects = loadedObjects.filter(
|
||||
(obj) =>
|
||||
(obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
|
||||
obj.locaKey,
|
||||
);
|
||||
|
||||
// 각 Location에 대해 자재 개수 조회 (병렬 처리)
|
||||
const materialCountPromises = locationObjects.map(async (obj) => {
|
||||
try {
|
||||
const matResponse = await getMaterials(dbConnectionId, {
|
||||
tableName: hierarchyConfigData.material.tableName,
|
||||
keyColumn: hierarchyConfigData.material.keyColumn,
|
||||
locationKeyColumn: hierarchyConfigData.material.locationKeyColumn,
|
||||
layerColumn: hierarchyConfigData.material.layerColumn,
|
||||
locaKey: obj.locaKey!,
|
||||
});
|
||||
if (matResponse.success && matResponse.data) {
|
||||
return { id: obj.id, count: matResponse.data.length };
|
||||
}
|
||||
} catch {
|
||||
// 자동 갱신 시에는 에러 로그 생략
|
||||
}
|
||||
return { id: obj.id, count: 0 };
|
||||
});
|
||||
|
||||
const materialCounts = await Promise.all(materialCountPromises);
|
||||
|
||||
// materialCount 업데이트
|
||||
const updatedObjects = loadedObjects.map((obj) => {
|
||||
const countData = materialCounts.find((m) => m.id === obj.id);
|
||||
if (countData && countData.count > 0) {
|
||||
return { ...obj, materialCount: countData.count };
|
||||
}
|
||||
return obj;
|
||||
});
|
||||
|
||||
setPlacedObjects(updatedObjects);
|
||||
} else {
|
||||
setPlacedObjects(loadedObjects);
|
||||
}
|
||||
|
||||
// 선택된 객체가 있으면 자재 목록도 갱신
|
||||
if (selectedObject && dbConnectionId && hierarchyConfigData?.material) {
|
||||
const currentObj = loadedObjects.find((o) => o.id === selectedObject.id);
|
||||
if (
|
||||
currentObj &&
|
||||
(currentObj.type === "location-bed" ||
|
||||
currentObj.type === "location-temp" ||
|
||||
currentObj.type === "location-dest") &&
|
||||
currentObj.locaKey
|
||||
) {
|
||||
const matResponse = await getMaterials(dbConnectionId, {
|
||||
tableName: hierarchyConfigData.material.tableName,
|
||||
keyColumn: hierarchyConfigData.material.keyColumn,
|
||||
locationKeyColumn: hierarchyConfigData.material.locationKeyColumn,
|
||||
layerColumn: hierarchyConfigData.material.layerColumn,
|
||||
locaKey: currentObj.locaKey,
|
||||
});
|
||||
if (matResponse.success && matResponse.data) {
|
||||
const layerColumn = hierarchyConfigData.material.layerColumn || "LOLAYER";
|
||||
const sortedMaterials = matResponse.data.sort(
|
||||
(a: any, b: any) => (b[layerColumn] || 0) - (a[layerColumn] || 0),
|
||||
);
|
||||
setMaterials(sortedMaterials);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 마지막 갱신 시간 기록
|
||||
setLastRefreshedAt(new Date());
|
||||
}
|
||||
} catch {
|
||||
// 자동 갱신 실패 시 조용히 무시 (사용자 경험 방해 안 함)
|
||||
}
|
||||
};
|
||||
|
||||
// 10초마다 자동 갱신
|
||||
const intervalId = setInterval(silentRefresh, AUTO_REFRESH_INTERVAL);
|
||||
|
||||
// 컴포넌트 언마운트 시 인터벌 정리
|
||||
return () => clearInterval(intervalId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [layoutId, isLoading, isRefreshing, selectedObject]);
|
||||
|
||||
// Location의 자재 목록 로드
|
||||
const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => {
|
||||
if (!hierarchyConfig?.material) {
|
||||
|
|
@ -200,7 +422,8 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
});
|
||||
if (response.success && response.data) {
|
||||
const layerColumn = hierarchyConfig.material.layerColumn || "LOLAYER";
|
||||
const sortedMaterials = response.data.sort((a: any, b: any) => (a[layerColumn] || 0) - (b[layerColumn] || 0));
|
||||
// 층 내림차순 정렬 (높은 층이 위로)
|
||||
const sortedMaterials = response.data.sort((a: any, b: any) => (b[layerColumn] || 0) - (a[layerColumn] || 0));
|
||||
setMaterials(sortedMaterials);
|
||||
} else {
|
||||
setMaterials([]);
|
||||
|
|
@ -334,362 +557,400 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{layoutName || "디지털 트윈 야드"}</h2>
|
||||
<p className="text-muted-foreground text-sm">읽기 전용 뷰</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-muted-foreground text-sm">{isExternalMode ? "야드 관제 화면" : "읽기 전용 뷰"}</p>
|
||||
{lastRefreshedAt && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
마지막 갱신: {lastRefreshedAt.toLocaleTimeString("ko-KR")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 전체 화면 버튼 - 외부 업체 모드에서만 표시 */}
|
||||
{isExternalMode && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggleFullscreen}
|
||||
title={isFullscreen ? "전체 화면 종료" : "전체 화면"}
|
||||
>
|
||||
{isFullscreen ? <Minimize className="mr-2 h-4 w-4" /> : <Maximize className="mr-2 h-4 w-4" />}
|
||||
{isFullscreen ? "종료" : "전체 화면"}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing || isLoading}
|
||||
title="새로고침"
|
||||
>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
{isRefreshing ? "갱신 중..." : "새로고침"}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing || isLoading}
|
||||
title="새로고침"
|
||||
>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
{isRefreshing ? "갱신 중..." : "새로고침"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 메인 영역 */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* 좌측: 검색/필터 */}
|
||||
<div className="flex h-full w-64 flex-shrink-0 flex-col border-r">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 검색 */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-sm font-semibold">검색</Label>
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="이름, Area, Location 검색..."
|
||||
className="h-10 pl-9 text-sm"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-1/2 right-1 h-7 w-7 -translate-y-1/2 p-0"
|
||||
onClick={() => setSearchQuery("")}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
{/* 좌측: 검색/필터 - 외부 모드에서는 숨김 */}
|
||||
{!isExternalMode && (
|
||||
<div className="flex h-full w-64 flex-shrink-0 flex-col border-r">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 검색 */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-sm font-semibold">검색</Label>
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="이름, Area, Location 검색..."
|
||||
className="h-10 pl-9 text-sm"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-1/2 right-1 h-7 w-7 -translate-y-1/2 p-0"
|
||||
onClick={() => setSearchQuery("")}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 타입 필터 */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-sm font-semibold">타입 필터</Label>
|
||||
<Select value={filterType} onValueChange={setFilterType}>
|
||||
<SelectTrigger className="h-10 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체 ({typeCounts.all})</SelectItem>
|
||||
<SelectItem value="area">Area ({typeCounts.area})</SelectItem>
|
||||
<SelectItem value="location-bed">베드(BED) ({typeCounts["location-bed"]})</SelectItem>
|
||||
<SelectItem value="location-stp">정차포인트(STP) ({typeCounts["location-stp"]})</SelectItem>
|
||||
<SelectItem value="location-temp">임시베드(TMP) ({typeCounts["location-temp"]})</SelectItem>
|
||||
<SelectItem value="location-dest">지정착지(DES) ({typeCounts["location-dest"]})</SelectItem>
|
||||
<SelectItem value="crane-mobile">크레인 ({typeCounts["crane-mobile"]})</SelectItem>
|
||||
<SelectItem value="rack">랙 ({typeCounts.rack})</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 필터 초기화 */}
|
||||
{(searchQuery || filterType !== "all") && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 w-full text-sm"
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
setFilterType("all");
|
||||
}}
|
||||
>
|
||||
필터 초기화
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 타입 필터 */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-sm font-semibold">타입 필터</Label>
|
||||
<Select value={filterType} onValueChange={setFilterType}>
|
||||
<SelectTrigger className="h-10 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체 ({typeCounts.all})</SelectItem>
|
||||
<SelectItem value="area">Area ({typeCounts.area})</SelectItem>
|
||||
<SelectItem value="location-bed">베드(BED) ({typeCounts["location-bed"]})</SelectItem>
|
||||
<SelectItem value="location-stp">정차포인트(STP) ({typeCounts["location-stp"]})</SelectItem>
|
||||
<SelectItem value="location-temp">임시베드(TMP) ({typeCounts["location-temp"]})</SelectItem>
|
||||
<SelectItem value="location-dest">지정착지(DES) ({typeCounts["location-dest"]})</SelectItem>
|
||||
<SelectItem value="crane-mobile">크레인 ({typeCounts["crane-mobile"]})</SelectItem>
|
||||
<SelectItem value="rack">랙 ({typeCounts.rack})</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 객체 목록 */}
|
||||
<div className="flex-1 overflow-y-auto border-t p-4">
|
||||
<Label className="mb-2 block text-sm font-semibold">객체 목록 ({filteredObjects.length})</Label>
|
||||
{filteredObjects.length === 0 ? (
|
||||
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-sm">
|
||||
{searchQuery ? "검색 결과가 없습니다" : "객체가 없습니다"}
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
// Area 객체가 있는 경우 계층 트리 아코디언 적용
|
||||
const areaObjects = filteredObjects.filter((obj) => obj.type === "area");
|
||||
|
||||
{/* 필터 초기화 */}
|
||||
{(searchQuery || filterType !== "all") && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 w-full text-sm"
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
setFilterType("all");
|
||||
}}
|
||||
>
|
||||
필터 초기화
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
// Area가 없으면 기존 평면 리스트 유지
|
||||
if (areaObjects.length === 0) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{filteredObjects.map((obj) => {
|
||||
let typeLabel = obj.type;
|
||||
if (obj.type === "location-bed") typeLabel = "베드(BED)";
|
||||
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
|
||||
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
|
||||
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
|
||||
else if (obj.type === "crane-mobile") typeLabel = "크레인";
|
||||
else if (obj.type === "area") typeLabel = "Area";
|
||||
else if (obj.type === "rack") typeLabel = "랙";
|
||||
|
||||
{/* 객체 목록 */}
|
||||
<div className="flex-1 overflow-y-auto border-t p-4">
|
||||
<Label className="mb-2 block text-sm font-semibold">객체 목록 ({filteredObjects.length})</Label>
|
||||
{filteredObjects.length === 0 ? (
|
||||
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-sm">
|
||||
{searchQuery ? "검색 결과가 없습니다" : "객체가 없습니다"}
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
// Area 객체가 있는 경우 계층 트리 아코디언 적용
|
||||
const areaObjects = filteredObjects.filter((obj) => obj.type === "area");
|
||||
return (
|
||||
<div
|
||||
key={obj.id}
|
||||
onClick={() => handleObjectClick(obj.id)}
|
||||
className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
|
||||
selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{obj.name}</p>
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: obj.color }}
|
||||
/>
|
||||
<span>{typeLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{obj.areaKey && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Area: <span className="font-medium">{obj.areaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{obj.locaKey && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Location: <span className="font-medium">{obj.locaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{obj.materialCount !== undefined && obj.materialCount > 0 && (
|
||||
<p className="text-xs text-yellow-600">
|
||||
자재: <span className="font-semibold">{obj.materialCount}개</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Area가 없으면 기존 평면 리스트 유지
|
||||
if (areaObjects.length === 0) {
|
||||
// Area가 있는 경우: Area → Location 계층 아코디언
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{filteredObjects.map((obj) => {
|
||||
let typeLabel = obj.type;
|
||||
if (obj.type === "location-bed") typeLabel = "베드(BED)";
|
||||
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
|
||||
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
|
||||
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
|
||||
else if (obj.type === "crane-mobile") typeLabel = "크레인";
|
||||
else if (obj.type === "area") typeLabel = "Area";
|
||||
else if (obj.type === "rack") typeLabel = "랙";
|
||||
<Accordion type="multiple" className="w-full">
|
||||
{areaObjects.map((areaObj) => {
|
||||
const childLocations = filteredObjects.filter(
|
||||
(obj) =>
|
||||
obj.type !== "area" &&
|
||||
obj.areaKey === areaObj.areaKey &&
|
||||
(obj.parentId === areaObj.id || obj.externalKey === areaObj.externalKey),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={obj.id}
|
||||
onClick={() => handleObjectClick(obj.id)}
|
||||
className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
|
||||
selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{obj.name}</p>
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||
<AccordionItem key={areaObj.id} value={`area-${areaObj.id}`} className="border-b">
|
||||
<AccordionTrigger className="px-2 py-3 hover:no-underline">
|
||||
<div
|
||||
className={`flex w-full items-center justify-between pr-2 ${
|
||||
selectedObject?.id === areaObj.id ? "text-primary font-semibold" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleObjectClick(areaObj.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Grid3x3 className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">{areaObj.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">({childLocations.length})</span>
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: obj.color }}
|
||||
style={{ backgroundColor: areaObj.color }}
|
||||
/>
|
||||
<span>{typeLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{obj.areaKey && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Area: <span className="font-medium">{obj.areaKey}</span>
|
||||
</p>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-2 pb-3">
|
||||
{childLocations.length === 0 ? (
|
||||
<p className="text-muted-foreground py-2 text-center text-xs">Location이 없습니다</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{childLocations.map((locationObj) => (
|
||||
<div
|
||||
key={locationObj.id}
|
||||
onClick={() => handleObjectClick(locationObj.id)}
|
||||
className={`cursor-pointer rounded-lg border p-2 transition-all ${
|
||||
selectedObject?.id === locationObj.id
|
||||
? "border-primary bg-primary/10"
|
||||
: "hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{locationObj.type === "location-stp" ? (
|
||||
<ParkingCircle className="h-3 w-3" />
|
||||
) : (
|
||||
<Package className="h-3 w-3" />
|
||||
)}
|
||||
<span className="text-xs font-medium">{locationObj.name}</span>
|
||||
</div>
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: locationObj.color }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
위치: ({locationObj.position.x.toFixed(1)}, {locationObj.position.z.toFixed(1)})
|
||||
</p>
|
||||
{locationObj.locaKey && (
|
||||
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
||||
Location: <span className="font-medium">{locationObj.locaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{locationObj.materialCount !== undefined && locationObj.materialCount > 0 && (
|
||||
<p className="mt-0.5 text-[10px] text-yellow-600">
|
||||
자재: <span className="font-semibold">{locationObj.materialCount}개</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{obj.locaKey && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Location: <span className="font-medium">{obj.locaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{obj.materialCount !== undefined && obj.materialCount > 0 && (
|
||||
<p className="text-xs text-yellow-600">
|
||||
자재: <span className="font-semibold">{obj.materialCount}개</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
// Area가 있는 경우: Area → Location 계층 아코디언
|
||||
return (
|
||||
<Accordion type="multiple" className="w-full">
|
||||
{areaObjects.map((areaObj) => {
|
||||
const childLocations = filteredObjects.filter(
|
||||
(obj) =>
|
||||
obj.type !== "area" &&
|
||||
obj.areaKey === areaObj.areaKey &&
|
||||
(obj.parentId === areaObj.id || obj.externalKey === areaObj.externalKey),
|
||||
);
|
||||
|
||||
return (
|
||||
<AccordionItem key={areaObj.id} value={`area-${areaObj.id}`} className="border-b">
|
||||
<AccordionTrigger className="px-2 py-3 hover:no-underline">
|
||||
<div
|
||||
className={`flex w-full items-center justify-between pr-2 ${
|
||||
selectedObject?.id === areaObj.id ? "text-primary font-semibold" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleObjectClick(areaObj.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Grid3x3 className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">{areaObj.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">({childLocations.length})</span>
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: areaObj.color }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-2 pb-3">
|
||||
{childLocations.length === 0 ? (
|
||||
<p className="text-muted-foreground py-2 text-center text-xs">Location이 없습니다</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{childLocations.map((locationObj) => (
|
||||
<div
|
||||
key={locationObj.id}
|
||||
onClick={() => handleObjectClick(locationObj.id)}
|
||||
className={`cursor-pointer rounded-lg border p-2 transition-all ${
|
||||
selectedObject?.id === locationObj.id
|
||||
? "border-primary bg-primary/10"
|
||||
: "hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{locationObj.type === "location-stp" ? (
|
||||
<ParkingCircle className="h-3 w-3" />
|
||||
) : (
|
||||
<Package className="h-3 w-3" />
|
||||
)}
|
||||
<span className="text-xs font-medium">{locationObj.name}</span>
|
||||
</div>
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: locationObj.color }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
위치: ({locationObj.position.x.toFixed(1)}, {locationObj.position.z.toFixed(1)})
|
||||
</p>
|
||||
{locationObj.locaKey && (
|
||||
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
||||
Location: <span className="font-medium">{locationObj.locaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{locationObj.materialCount !== undefined && locationObj.materialCount > 0 && (
|
||||
<p className="mt-0.5 text-[10px] text-yellow-600">
|
||||
자재: <span className="font-semibold">{locationObj.materialCount}개</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
);
|
||||
})()
|
||||
{/* 중앙 + 우측 컨테이너 (전체화면 시 함께 표시) */}
|
||||
<div
|
||||
ref={canvasContainerRef}
|
||||
className={`relative flex flex-1 overflow-hidden ${isFullscreen ? "bg-background" : ""}`}
|
||||
>
|
||||
{/* 중앙: 3D 캔버스 */}
|
||||
<div className="relative min-w-0 flex-1">
|
||||
{!isLoading && (
|
||||
<Yard3DCanvas
|
||||
placements={canvasPlacements}
|
||||
selectedPlacementId={selectedObject?.id || null}
|
||||
onPlacementClick={(placement) => handleObjectClick(placement?.id || null)}
|
||||
focusOnPlacementId={null}
|
||||
onCollisionDetected={() => {}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 중앙: 3D 캔버스 */}
|
||||
<div className="relative flex-1">
|
||||
{!isLoading && (
|
||||
<Yard3DCanvas
|
||||
placements={canvasPlacements}
|
||||
selectedPlacementId={selectedObject?.id || null}
|
||||
onPlacementClick={(placement) => handleObjectClick(placement?.id || null)}
|
||||
focusOnPlacementId={null}
|
||||
onCollisionDetected={() => {}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 우측: 정보 패널 */}
|
||||
<div className="h-full w-[480px] flex-shrink-0 overflow-y-auto border-l">
|
||||
{selectedObject ? (
|
||||
<div className="p-4">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold">상세 정보</h3>
|
||||
<p className="text-muted-foreground text-xs">{selectedObject.name}</p>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<div className="bg-muted space-y-3 rounded-lg p-3">
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">타입</Label>
|
||||
<p className="text-sm font-medium">{selectedObject.type}</p>
|
||||
{/* 우측: 정보 패널 */}
|
||||
<div className="h-full w-[480px] min-w-[480px] flex-shrink-0 overflow-y-auto border-l">
|
||||
{selectedObject ? (
|
||||
<div className="p-4">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold">상세 정보</h3>
|
||||
<p className="text-muted-foreground text-xs">{selectedObject.name}</p>
|
||||
</div>
|
||||
{selectedObject.areaKey && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">Area Key</Label>
|
||||
<p className="text-sm font-medium">{selectedObject.areaKey}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedObject.locaKey && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">Location Key</Label>
|
||||
<p className="text-sm font-medium">{selectedObject.locaKey}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedObject.materialCount !== undefined && selectedObject.materialCount > 0 && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">자재 개수</Label>
|
||||
<p className="text-sm font-medium">{selectedObject.materialCount}개</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 자재 목록 (Location인 경우) - 아코디언 */}
|
||||
{(selectedObject.type === "location-bed" ||
|
||||
selectedObject.type === "location-stp" ||
|
||||
selectedObject.type === "location-temp" ||
|
||||
selectedObject.type === "location-dest") && (
|
||||
<div className="mt-4">
|
||||
{loadingMaterials ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
{/* 기본 정보 */}
|
||||
<div className="bg-muted space-y-3 rounded-lg p-3">
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">타입</Label>
|
||||
<p className="text-sm font-medium">{selectedObject.type}</p>
|
||||
</div>
|
||||
{selectedObject.areaKey && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">Area Key</Label>
|
||||
<p className="text-sm font-medium">{selectedObject.areaKey}</p>
|
||||
</div>
|
||||
) : materials.length === 0 ? (
|
||||
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-sm">
|
||||
{externalDbConnectionId ? "자재가 없습니다" : "외부 DB 연결이 설정되지 않았습니다"}
|
||||
)}
|
||||
{selectedObject.locaKey && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">Location Key</Label>
|
||||
<p className="text-sm font-medium">{selectedObject.locaKey}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label className="mb-2 block text-sm font-semibold">자재 목록 ({materials.length}개)</Label>
|
||||
{materials.map((material, index) => {
|
||||
const displayColumns = hierarchyConfig?.material?.displayColumns || [];
|
||||
return (
|
||||
<details
|
||||
key={`${material.STKKEY}-${index}`}
|
||||
className="bg-muted group hover:bg-accent rounded-lg border transition-colors"
|
||||
>
|
||||
<summary className="flex cursor-pointer items-center justify-between p-3 text-sm font-medium">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">
|
||||
층 {material[hierarchyConfig?.material?.layerColumn || "LOLAYER"]}
|
||||
</span>
|
||||
{displayColumns[0] && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{material[displayColumns[0].column]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className="text-muted-foreground h-4 w-4 transition-transform group-open:rotate-180"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</summary>
|
||||
<div className="space-y-2 border-t p-3 pt-3">
|
||||
{displayColumns.map((colConfig: any) => (
|
||||
<div key={colConfig.column} className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">{colConfig.label}:</span>
|
||||
<span className="font-medium">{material[colConfig.column] || "-"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
{selectedObject.materialCount !== undefined && selectedObject.materialCount > 0 && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">자재 개수</Label>
|
||||
<p className="text-sm font-medium">{selectedObject.materialCount}개</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center p-4">
|
||||
<p className="text-muted-foreground text-sm">객체를 선택하세요</p>
|
||||
</div>
|
||||
|
||||
{/* 자재 목록 (Location인 경우) - 테이블 형태 */}
|
||||
{(selectedObject.type === "location-bed" ||
|
||||
selectedObject.type === "location-stp" ||
|
||||
selectedObject.type === "location-temp" ||
|
||||
selectedObject.type === "location-dest") && (
|
||||
<div className="mt-4">
|
||||
{loadingMaterials ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : materials.length === 0 ? (
|
||||
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-sm">
|
||||
{externalDbConnectionId ? "자재가 없습니다" : "외부 DB 연결이 설정되지 않았습니다"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label className="mb-2 block text-sm font-semibold">자재 목록 ({materials.length}개)</Label>
|
||||
{/* 테이블 형태로 전체 조회 */}
|
||||
<div className="h-[580px] overflow-auto rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted sticky top-0">
|
||||
<tr>
|
||||
<th className="border-b px-3 py-3 text-left font-semibold whitespace-nowrap">층</th>
|
||||
{(hierarchyConfig?.material?.displayColumns || []).map((colConfig: any) => (
|
||||
<th key={colConfig.column} className="border-b px-3 py-3 text-left font-semibold">
|
||||
{colConfig.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{materials.map((material, index) => {
|
||||
const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER";
|
||||
const displayColumns = hierarchyConfig?.material?.displayColumns || [];
|
||||
return (
|
||||
<tr
|
||||
key={`${material.STKKEY}-${index}`}
|
||||
className="hover:bg-accent border-b transition-colors last:border-0"
|
||||
>
|
||||
<td className="px-3 py-3 font-medium whitespace-nowrap">
|
||||
{material[layerColumn]}단
|
||||
</td>
|
||||
{displayColumns.map((colConfig: any) => (
|
||||
<td key={colConfig.column} className="px-3 py-3">
|
||||
{material[colConfig.column] || "-"}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center p-4">
|
||||
<p className="text-muted-foreground text-sm">객체를 선택하세요</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 풀스크린 모드일 때 종료 버튼 */}
|
||||
{isFullscreen && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggleFullscreen}
|
||||
className="bg-background/80 absolute top-4 right-4 z-50 backdrop-blur-sm"
|
||||
>
|
||||
<Minimize className="mr-2 h-4 w-4" />
|
||||
종료
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Pencil, Copy, Trash2, Loader2 } from "lucide-react";
|
||||
import { Copy, Trash2, Loader2 } from "lucide-react";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
|
@ -149,7 +149,11 @@ export function ReportListTable({
|
|||
{reports.map((report, index) => {
|
||||
const rowNumber = (page - 1) * limit + index + 1;
|
||||
return (
|
||||
<TableRow key={report.report_id}>
|
||||
<TableRow
|
||||
key={report.report_id}
|
||||
onClick={() => handleEdit(report.report_id)}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
>
|
||||
<TableCell className="font-medium">{rowNumber}</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
|
|
@ -162,34 +166,25 @@ export function ReportListTable({
|
|||
<TableCell>{report.created_by || "-"}</TableCell>
|
||||
<TableCell>{formatDate(report.updated_at || report.created_at)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleEdit(report.report_id)}
|
||||
className="gap-1"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => handleCopy(report.report_id)}
|
||||
disabled={isCopying}
|
||||
className="gap-1"
|
||||
className="h-8 w-8"
|
||||
title="복사"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
복사
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteClick(report.report_id)}
|
||||
className="gap-1"
|
||||
className="h-8 w-8"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
삭제
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
canvasWidth,
|
||||
canvasHeight,
|
||||
margins,
|
||||
layoutConfig,
|
||||
currentPageId,
|
||||
} = useReportDesigner();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
|
|
@ -270,6 +272,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
color: component.fontColor,
|
||||
fontWeight: component.fontWeight,
|
||||
textAlign: component.textAlign as "left" | "center" | "right",
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
|
|
@ -291,6 +294,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
color: component.fontColor,
|
||||
fontWeight: component.fontWeight,
|
||||
textAlign: component.textAlign as "left" | "center" | "right",
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{displayValue}
|
||||
|
|
@ -561,6 +565,245 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
</div>
|
||||
);
|
||||
|
||||
case "pageNumber":
|
||||
// 페이지 번호 포맷
|
||||
const format = component.pageNumberFormat || "number";
|
||||
const sortedPages = layoutConfig.pages.sort((a, b) => a.page_order - b.page_order);
|
||||
const currentPageIndex = sortedPages.findIndex((p) => p.page_id === currentPageId);
|
||||
const totalPages = sortedPages.length;
|
||||
const currentPageNum = currentPageIndex + 1;
|
||||
|
||||
let pageNumberText = "";
|
||||
switch (format) {
|
||||
case "number":
|
||||
pageNumberText = `${currentPageNum}`;
|
||||
break;
|
||||
case "numberTotal":
|
||||
pageNumberText = `${currentPageNum} / ${totalPages}`;
|
||||
break;
|
||||
case "koreanNumber":
|
||||
pageNumberText = `${currentPageNum} 페이지`;
|
||||
break;
|
||||
default:
|
||||
pageNumberText = `${currentPageNum}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center"
|
||||
style={{
|
||||
fontSize: `${component.fontSize}px`,
|
||||
color: component.fontColor,
|
||||
fontWeight: component.fontWeight,
|
||||
textAlign: component.textAlign as "left" | "center" | "right",
|
||||
}}
|
||||
>
|
||||
{pageNumberText}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "card":
|
||||
// 카드 컴포넌트: 제목 + 항목 목록
|
||||
const cardTitle = component.cardTitle || "정보 카드";
|
||||
const cardItems = component.cardItems || [];
|
||||
const labelWidth = component.labelWidth || 80;
|
||||
const showCardTitle = component.showCardTitle !== false;
|
||||
const titleFontSize = component.titleFontSize || 14;
|
||||
const labelFontSize = component.labelFontSize || 13;
|
||||
const valueFontSize = component.valueFontSize || 13;
|
||||
const titleColor = component.titleColor || "#1e40af";
|
||||
const labelColor = component.labelColor || "#374151";
|
||||
const valueColor = component.valueColor || "#000000";
|
||||
|
||||
// 쿼리 바인딩된 값 가져오기
|
||||
const getCardItemValue = (item: { label: string; value: string; fieldName?: string }) => {
|
||||
if (item.fieldName && component.queryId) {
|
||||
const queryResult = getQueryResult(component.queryId);
|
||||
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
||||
const row = queryResult.rows[0];
|
||||
return row[item.fieldName] !== undefined ? String(row[item.fieldName]) : item.value;
|
||||
}
|
||||
}
|
||||
return item.value;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
{/* 제목 */}
|
||||
{showCardTitle && (
|
||||
<>
|
||||
<div
|
||||
className="flex-shrink-0 px-2 py-1 font-semibold"
|
||||
style={{
|
||||
fontSize: `${titleFontSize}px`,
|
||||
color: titleColor,
|
||||
}}
|
||||
>
|
||||
{cardTitle}
|
||||
</div>
|
||||
{/* 구분선 */}
|
||||
<div
|
||||
className="mx-1 flex-shrink-0 border-b"
|
||||
style={{ borderColor: component.borderColor || "#e5e7eb" }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* 항목 목록 */}
|
||||
<div className="flex-1 overflow-auto px-2 py-1">
|
||||
{cardItems.map((item: { label: string; value: string; fieldName?: string }, index: number) => (
|
||||
<div key={index} className="flex py-0.5">
|
||||
<span
|
||||
className="flex-shrink-0 font-medium"
|
||||
style={{
|
||||
width: `${labelWidth}px`,
|
||||
fontSize: `${labelFontSize}px`,
|
||||
color: labelColor,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span
|
||||
className="flex-1"
|
||||
style={{
|
||||
fontSize: `${valueFontSize}px`,
|
||||
color: valueColor,
|
||||
}}
|
||||
>
|
||||
{getCardItemValue(item)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "calculation":
|
||||
// 계산 컴포넌트
|
||||
const calcItems = component.calcItems || [];
|
||||
const resultLabel = component.resultLabel || "합계";
|
||||
const calcLabelWidth = component.labelWidth || 120;
|
||||
const calcLabelFontSize = component.labelFontSize || 13;
|
||||
const calcValueFontSize = component.valueFontSize || 13;
|
||||
const calcResultFontSize = component.resultFontSize || 16;
|
||||
const calcLabelColor = component.labelColor || "#374151";
|
||||
const calcValueColor = component.valueColor || "#000000";
|
||||
const calcResultColor = component.resultColor || "#2563eb";
|
||||
const numberFormat = component.numberFormat || "currency";
|
||||
const currencySuffix = component.currencySuffix || "원";
|
||||
|
||||
// 숫자 포맷팅 함수
|
||||
const formatNumber = (num: number): string => {
|
||||
if (numberFormat === "none") return String(num);
|
||||
if (numberFormat === "comma") return num.toLocaleString();
|
||||
if (numberFormat === "currency") return num.toLocaleString() + currencySuffix;
|
||||
return String(num);
|
||||
};
|
||||
|
||||
// 쿼리 바인딩된 값 가져오기
|
||||
const getCalcItemValue = (item: { label: string; value: number | string; operator: string; fieldName?: string }): number => {
|
||||
if (item.fieldName && component.queryId) {
|
||||
const queryResult = getQueryResult(component.queryId);
|
||||
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
||||
const row = queryResult.rows[0];
|
||||
const val = row[item.fieldName];
|
||||
return typeof val === "number" ? val : parseFloat(String(val)) || 0;
|
||||
}
|
||||
}
|
||||
return typeof item.value === "number" ? item.value : parseFloat(String(item.value)) || 0;
|
||||
};
|
||||
|
||||
// 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용)
|
||||
const calculateResult = (): number => {
|
||||
if (calcItems.length === 0) return 0;
|
||||
|
||||
// 첫 번째 항목은 기준값
|
||||
let result = getCalcItemValue(calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string });
|
||||
|
||||
// 두 번째 항목부터 연산자 적용
|
||||
for (let i = 1; i < calcItems.length; i++) {
|
||||
const item = calcItems[i];
|
||||
const val = getCalcItemValue(item as { label: string; value: number | string; operator: string; fieldName?: string });
|
||||
switch (item.operator) {
|
||||
case "+":
|
||||
result += val;
|
||||
break;
|
||||
case "-":
|
||||
result -= val;
|
||||
break;
|
||||
case "x":
|
||||
result *= val;
|
||||
break;
|
||||
case "÷":
|
||||
result = val !== 0 ? result / val : result;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const calcResult = calculateResult();
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
{/* 항목 목록 */}
|
||||
<div className="flex-1 overflow-auto px-2 py-1">
|
||||
{calcItems.map((item: { label: string; value: number | string; operator: string; fieldName?: string }, index: number) => {
|
||||
const itemValue = getCalcItemValue(item);
|
||||
return (
|
||||
<div key={index} className="flex items-center justify-between py-1">
|
||||
<span
|
||||
className="flex-shrink-0"
|
||||
style={{
|
||||
width: `${calcLabelWidth}px`,
|
||||
fontSize: `${calcLabelFontSize}px`,
|
||||
color: calcLabelColor,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span
|
||||
className="text-right"
|
||||
style={{
|
||||
fontSize: `${calcValueFontSize}px`,
|
||||
color: calcValueColor,
|
||||
}}
|
||||
>
|
||||
{formatNumber(itemValue)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* 구분선 */}
|
||||
<div
|
||||
className="mx-1 flex-shrink-0 border-t"
|
||||
style={{ borderColor: component.borderColor || "#374151" }}
|
||||
/>
|
||||
{/* 결과 */}
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{
|
||||
width: `${calcLabelWidth}px`,
|
||||
fontSize: `${calcResultFontSize}px`,
|
||||
color: calcLabelColor,
|
||||
}}
|
||||
>
|
||||
{resultLabel}
|
||||
</span>
|
||||
<span
|
||||
className="text-right font-bold"
|
||||
style={{
|
||||
fontSize: `${calcResultFontSize}px`,
|
||||
color: calcResultColor,
|
||||
}}
|
||||
>
|
||||
{formatNumber(calcResult)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return <div>알 수 없는 컴포넌트</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useDrag } from "react-dnd";
|
||||
import { Type, Table, Tag, Image, Minus, PenLine, Stamp as StampIcon } from "lucide-react";
|
||||
import { Type, Table, Image, Minus, PenLine, Stamp as StampIcon, Hash, CreditCard, Calculator } from "lucide-react";
|
||||
|
||||
interface ComponentItem {
|
||||
type: string;
|
||||
|
|
@ -12,11 +12,13 @@ interface ComponentItem {
|
|||
const COMPONENTS: ComponentItem[] = [
|
||||
{ type: "text", label: "텍스트", icon: <Type className="h-4 w-4" /> },
|
||||
{ type: "table", label: "테이블", icon: <Table className="h-4 w-4" /> },
|
||||
{ type: "label", label: "레이블", icon: <Tag className="h-4 w-4" /> },
|
||||
{ type: "image", label: "이미지", icon: <Image className="h-4 w-4" /> },
|
||||
{ type: "divider", label: "구분선", icon: <Minus className="h-4 w-4" /> },
|
||||
{ type: "signature", label: "서명란", icon: <PenLine className="h-4 w-4" /> },
|
||||
{ type: "stamp", label: "도장란", icon: <StampIcon className="h-4 w-4" /> },
|
||||
{ type: "pageNumber", label: "페이지번호", icon: <Hash className="h-4 w-4" /> },
|
||||
{ type: "card", label: "정보카드", icon: <CreditCard className="h-4 w-4" /> },
|
||||
{ type: "calculation", label: "계산", icon: <Calculator className="h-4 w-4" /> },
|
||||
];
|
||||
|
||||
function DraggableComponentItem({ type, label, icon }: ComponentItem) {
|
||||
|
|
|
|||
|
|
@ -76,25 +76,25 @@ export function PageListPanel() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="bg-background flex h-full w-64 flex-col border-r">
|
||||
<div className="bg-background flex h-full w-32 flex-col border-r">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between border-b p-3">
|
||||
<h3 className="text-sm font-semibold">페이지 목록</h3>
|
||||
<Button size="sm" variant="ghost" onClick={() => addPage()}>
|
||||
<Plus className="h-4 w-4" />
|
||||
<div className="flex items-center justify-between border-b px-2 py-1.5">
|
||||
<h3 className="text-[10px] font-semibold">페이지</h3>
|
||||
<Button size="sm" variant="ghost" className="h-5 w-5 p-0" onClick={() => addPage()}>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 페이지 목록 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ScrollArea className="h-full p-2">
|
||||
<div className="space-y-2">
|
||||
<ScrollArea className="h-full p-1">
|
||||
<div className="space-y-1">
|
||||
{layoutConfig.pages
|
||||
.sort((a, b) => a.page_order - b.page_order)
|
||||
.map((page, index) => (
|
||||
<div
|
||||
key={page.page_id}
|
||||
className={`group relative cursor-pointer rounded-md border p-2 transition-all ${
|
||||
className={`group relative cursor-pointer rounded border p-1.5 transition-all ${
|
||||
page.page_id === currentPageId
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border hover:border-primary/50 hover:bg-accent/50"
|
||||
|
|
@ -103,7 +103,7 @@ export function PageListPanel() {
|
|||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 드래그 핸들 */}
|
||||
<div
|
||||
draggable
|
||||
|
|
@ -115,13 +115,13 @@ export function PageListPanel() {
|
|||
className="text-muted-foreground cursor-grab opacity-0 transition-opacity group-hover:opacity-100 active:cursor-grabbing"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical className="h-3 w-3" />
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
|
||||
{/* 페이지 정보 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
{editingPageId === page.page_id ? (
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<Input
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
|
|
@ -129,21 +129,21 @@ export function PageListPanel() {
|
|||
if (e.key === "Enter") handleSaveEdit();
|
||||
if (e.key === "Escape") handleCancelEdit();
|
||||
}}
|
||||
className="h-6 text-xs"
|
||||
className="h-5 text-[10px]"
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="sm" variant="ghost" className="h-5 w-5 p-0" onClick={handleSaveEdit}>
|
||||
<Check className="h-3 w-3" />
|
||||
<Button size="sm" variant="ghost" className="h-4 w-4 p-0" onClick={handleSaveEdit}>
|
||||
<Check className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-5 w-5 p-0" onClick={handleCancelEdit}>
|
||||
<X className="h-3 w-3" />
|
||||
<Button size="sm" variant="ghost" className="h-4 w-4 p-0" onClick={handleCancelEdit}>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="truncate text-xs font-medium">{page.page_name}</div>
|
||||
<div className="truncate text-[10px] font-medium">{page.page_name}</div>
|
||||
)}
|
||||
<div className="text-muted-foreground text-[10px]">
|
||||
{page.width}x{page.height}mm • {page.components.length}개
|
||||
<div className="text-muted-foreground text-[8px]">
|
||||
{page.width}x{page.height}mm
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -153,10 +153,10 @@ export function PageListPanel() {
|
|||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 p-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
className="h-4 w-4 p-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<span className="sr-only">메뉴</span>
|
||||
<span className="text-sm leading-none">⋮</span>
|
||||
<span className="text-[10px] leading-none">⋮</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
|
|
@ -199,9 +199,9 @@ export function PageListPanel() {
|
|||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="border-t p-2">
|
||||
<Button size="sm" variant="outline" className="w-full" onClick={() => addPage()}>
|
||||
<Plus className="mr-2 h-4 w-4" />새 페이지 추가
|
||||
<div className="border-t p-1">
|
||||
<Button size="sm" variant="outline" className="h-6 w-full text-[10px]" onClick={() => addPage()}>
|
||||
<Plus className="mr-1 h-3 w-3" />추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -201,7 +201,8 @@ export function QueryManager() {
|
|||
setIsTestRunning({ ...isTestRunning, [query.id]: true });
|
||||
try {
|
||||
const testReportId = reportId === "new" ? "TEMP_TEST" : reportId;
|
||||
const sqlQuery = reportId === "new" ? query.sqlQuery : undefined;
|
||||
// 항상 sqlQuery를 전달 (새 쿼리가 아직 DB에 저장되지 않았을 수 있음)
|
||||
const sqlQuery = query.sqlQuery;
|
||||
const externalConnectionId = (query as any).externalConnectionId || null;
|
||||
const queryParams = parameterValues[query.id] || {};
|
||||
|
||||
|
|
@ -264,24 +265,24 @@ export function QueryManager() {
|
|||
|
||||
return (
|
||||
<AccordionItem key={query.id} value={query.id} className="border-b border-gray-200">
|
||||
<AccordionTrigger className="px-0 py-2.5 hover:no-underline">
|
||||
<div className="flex w-full items-center justify-between pr-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<AccordionTrigger className="flex-1 px-0 py-2.5 hover:no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{query.name}</span>
|
||||
<Badge variant={query.type === "MASTER" ? "default" : "secondary"} className="text-xs">
|
||||
{query.type}
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => handleDeleteQuery(query.id, e)}
|
||||
className="h-7 w-7 p-0"
|
||||
className="h-7 w-7 shrink-0 p-0"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-1 pr-0 pb-3 pl-0">
|
||||
{/* 쿼리 이름 */}
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -65,6 +65,9 @@ export function ReportDesignerCanvas() {
|
|||
} else if (item.componentType === "stamp") {
|
||||
width = 70;
|
||||
height = 70;
|
||||
} else if (item.componentType === "pageNumber") {
|
||||
width = 100;
|
||||
height = 30;
|
||||
}
|
||||
|
||||
// 여백을 px로 변환 (1mm ≈ 3.7795px)
|
||||
|
|
@ -143,6 +146,55 @@ export function ReportDesignerCanvas() {
|
|||
borderWidth: 0,
|
||||
borderColor: "#cccccc",
|
||||
}),
|
||||
// 페이지 번호 전용
|
||||
...(item.componentType === "pageNumber" && {
|
||||
pageNumberFormat: "number" as const, // number, numberTotal, koreanNumber
|
||||
textAlign: "center" as const,
|
||||
}),
|
||||
// 카드 컴포넌트 전용
|
||||
...(item.componentType === "card" && {
|
||||
width: 300,
|
||||
height: 180,
|
||||
cardTitle: "정보 카드",
|
||||
showCardTitle: true,
|
||||
cardItems: [
|
||||
{ label: "항목1", value: "내용1", fieldName: "" },
|
||||
{ label: "항목2", value: "내용2", fieldName: "" },
|
||||
{ label: "항목3", value: "내용3", fieldName: "" },
|
||||
],
|
||||
labelWidth: 80,
|
||||
showCardBorder: true,
|
||||
titleFontSize: 14,
|
||||
labelFontSize: 13,
|
||||
valueFontSize: 13,
|
||||
titleColor: "#1e40af",
|
||||
labelColor: "#374151",
|
||||
valueColor: "#000000",
|
||||
borderWidth: 1,
|
||||
borderColor: "#e5e7eb",
|
||||
}),
|
||||
// 계산 컴포넌트 전용
|
||||
...(item.componentType === "calculation" && {
|
||||
width: 350,
|
||||
height: 120,
|
||||
calcItems: [
|
||||
{ label: "공급가액", value: 0, operator: "+" as const, fieldName: "" },
|
||||
{ label: "부가세 (10%)", value: 0, operator: "+" as const, fieldName: "" },
|
||||
],
|
||||
resultLabel: "합계 금액",
|
||||
labelWidth: 120,
|
||||
labelFontSize: 13,
|
||||
valueFontSize: 13,
|
||||
resultFontSize: 16,
|
||||
labelColor: "#374151",
|
||||
valueColor: "#000000",
|
||||
resultColor: "#2563eb",
|
||||
showCalcBorder: false,
|
||||
numberFormat: "currency" as const,
|
||||
currencySuffix: "원",
|
||||
borderWidth: 0,
|
||||
borderColor: "#e5e7eb",
|
||||
}),
|
||||
// 테이블 전용
|
||||
...(item.componentType === "table" && {
|
||||
queryId: undefined,
|
||||
|
|
@ -297,13 +349,8 @@ export function ReportDesignerCanvas() {
|
|||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden bg-gray-100">
|
||||
{/* 작업 영역 제목 */}
|
||||
<div className="border-b bg-white px-4 py-2 text-center text-sm font-medium text-gray-700">
|
||||
{currentPage.page_name} ({currentPage.width} x {currentPage.height}mm)
|
||||
</div>
|
||||
|
||||
{/* 캔버스 스크롤 영역 */}
|
||||
<div className="flex flex-1 items-center justify-center overflow-auto p-8">
|
||||
<div className="flex flex-1 items-center justify-center overflow-auto px-8 pt-[280px] pb-8">
|
||||
{/* 눈금자와 캔버스를 감싸는 컨테이너 */}
|
||||
<div className="inline-flex flex-col">
|
||||
{/* 좌상단 코너 + 가로 눈금자 */}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
|
@ -27,6 +28,7 @@ export function ReportDesignerRightPanel() {
|
|||
currentPage,
|
||||
currentPageId,
|
||||
updatePageSettings,
|
||||
getQueryResult,
|
||||
} = context;
|
||||
const [activeTab, setActiveTab] = useState<string>("properties");
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
|
|
@ -918,6 +920,717 @@ export function ReportDesignerRightPanel() {
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{/* 페이지 번호 설정 */}
|
||||
{selectedComponent.type === "pageNumber" && (
|
||||
<Card className="mt-4 border-purple-200 bg-purple-50">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-purple-900">페이지 번호 설정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">표시 형식</Label>
|
||||
<Select
|
||||
value={selectedComponent.pageNumberFormat || "number"}
|
||||
onValueChange={(value) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
pageNumberFormat: value as "number" | "numberTotal" | "koreanNumber",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="number">숫자만 (1, 2, 3...)</SelectItem>
|
||||
<SelectItem value="numberTotal">현재/전체 (1 / 3)</SelectItem>
|
||||
<SelectItem value="koreanNumber">한글 (1 페이지)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 카드 컴포넌트 설정 */}
|
||||
{selectedComponent.type === "card" && (
|
||||
<Card className="mt-4 border-teal-200 bg-teal-50">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-teal-900">카드 설정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* 제목 표시 여부 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="showCardTitle"
|
||||
checked={selectedComponent.showCardTitle !== false}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
showCardTitle: e.target.checked,
|
||||
})
|
||||
}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label htmlFor="showCardTitle" className="text-xs">
|
||||
제목 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 제목 텍스트 */}
|
||||
{selectedComponent.showCardTitle !== false && (
|
||||
<div>
|
||||
<Label className="text-xs">카드 제목</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={selectedComponent.cardTitle || ""}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
cardTitle: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="정보 카드"
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 라벨 너비 */}
|
||||
<div>
|
||||
<Label className="text-xs">라벨 너비 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={selectedComponent.labelWidth || 80}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
labelWidth: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
min={40}
|
||||
max={200}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 테두리 표시 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="showCardBorder"
|
||||
checked={selectedComponent.showCardBorder !== false}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
showCardBorder: e.target.checked,
|
||||
borderWidth: e.target.checked ? 1 : 0,
|
||||
})
|
||||
}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label htmlFor="showCardBorder" className="text-xs">
|
||||
테두리 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 폰트 크기 설정 */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">제목 크기</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={selectedComponent.titleFontSize || 14}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
titleFontSize: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
min={10}
|
||||
max={24}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">라벨 크기</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={selectedComponent.labelFontSize || 13}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
labelFontSize: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
min={10}
|
||||
max={20}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">값 크기</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={selectedComponent.valueFontSize || 13}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
valueFontSize: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
min={10}
|
||||
max={20}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 색상 설정 */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">제목 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={selectedComponent.titleColor || "#1e40af"}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
titleColor: e.target.value,
|
||||
})
|
||||
}
|
||||
className="h-8 w-full cursor-pointer p-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">라벨 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={selectedComponent.labelColor || "#374151"}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
labelColor: e.target.value,
|
||||
})
|
||||
}
|
||||
className="h-8 w-full cursor-pointer p-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">값 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={selectedComponent.valueColor || "#000000"}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
valueColor: e.target.value,
|
||||
})
|
||||
}
|
||||
className="h-8 w-full cursor-pointer p-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 항목 목록 관리 */}
|
||||
<div className="mt-4 border-t pt-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold">항목 목록</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => {
|
||||
const currentItems = selectedComponent.cardItems || [];
|
||||
updateComponent(selectedComponent.id, {
|
||||
cardItems: [
|
||||
...currentItems,
|
||||
{ label: `항목${currentItems.length + 1}`, value: "", fieldName: "" },
|
||||
],
|
||||
});
|
||||
}}
|
||||
>
|
||||
+ 항목 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 쿼리 선택 (데이터 바인딩용) */}
|
||||
<div className="mb-2">
|
||||
<Label className="text-xs">데이터 소스 (쿼리)</Label>
|
||||
<Select
|
||||
value={selectedComponent.queryId || "none"}
|
||||
onValueChange={(value) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
queryId: value === "none" ? undefined : value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="쿼리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">직접 입력</SelectItem>
|
||||
{queries.map((q) => (
|
||||
<SelectItem key={q.id} value={q.id}>
|
||||
{q.name} ({q.type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 항목 리스트 */}
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto">
|
||||
{(selectedComponent.cardItems || []).map(
|
||||
(item: { label: string; value: string; fieldName?: string }, index: number) => (
|
||||
<div key={index} className="rounded border bg-white p-2">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-xs font-medium">항목 {index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
|
||||
onClick={() => {
|
||||
const currentItems = [...(selectedComponent.cardItems || [])];
|
||||
currentItems.splice(index, 1);
|
||||
updateComponent(selectedComponent.id, {
|
||||
cardItems: currentItems,
|
||||
});
|
||||
}}
|
||||
>
|
||||
x
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<div>
|
||||
<Label className="text-[10px]">라벨</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={item.label}
|
||||
onChange={(e) => {
|
||||
const currentItems = [...(selectedComponent.cardItems || [])];
|
||||
currentItems[index] = { ...item, label: e.target.value };
|
||||
updateComponent(selectedComponent.id, {
|
||||
cardItems: currentItems,
|
||||
});
|
||||
}}
|
||||
className="h-6 text-xs"
|
||||
placeholder="항목명"
|
||||
/>
|
||||
</div>
|
||||
{selectedComponent.queryId ? (
|
||||
<div>
|
||||
<Label className="text-[10px]">필드</Label>
|
||||
<Select
|
||||
value={item.fieldName || "none"}
|
||||
onValueChange={(value) => {
|
||||
const currentItems = [...(selectedComponent.cardItems || [])];
|
||||
currentItems[index] = {
|
||||
...item,
|
||||
fieldName: value === "none" ? "" : value,
|
||||
};
|
||||
updateComponent(selectedComponent.id, {
|
||||
cardItems: currentItems,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">직접 입력</SelectItem>
|
||||
{(() => {
|
||||
const query = queries.find((q) => q.id === selectedComponent.queryId);
|
||||
const result = query ? getQueryResult(query.id) : null;
|
||||
if (result && result.fields) {
|
||||
return result.fields.map((field: string) => (
|
||||
<SelectItem key={field} value={field}>
|
||||
{field}
|
||||
</SelectItem>
|
||||
));
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Label className="text-[10px]">값</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={item.value}
|
||||
onChange={(e) => {
|
||||
const currentItems = [...(selectedComponent.cardItems || [])];
|
||||
currentItems[index] = { ...item, value: e.target.value };
|
||||
updateComponent(selectedComponent.id, {
|
||||
cardItems: currentItems,
|
||||
});
|
||||
}}
|
||||
className="h-6 text-xs"
|
||||
placeholder="내용"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 계산 컴포넌트 설정 */}
|
||||
{selectedComponent.type === "calculation" && (
|
||||
<Card className="mt-4 border-orange-200 bg-orange-50">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-orange-900">계산 설정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* 결과 라벨 */}
|
||||
<div>
|
||||
<Label className="text-xs">결과 라벨</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={selectedComponent.resultLabel || "합계"}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
resultLabel: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="합계 금액"
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 라벨 너비 */}
|
||||
<div>
|
||||
<Label className="text-xs">라벨 너비 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={selectedComponent.labelWidth || 120}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
labelWidth: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
min={60}
|
||||
max={200}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 숫자 포맷 */}
|
||||
<div>
|
||||
<Label className="text-xs">숫자 포맷</Label>
|
||||
<Select
|
||||
value={selectedComponent.numberFormat || "currency"}
|
||||
onValueChange={(value) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
numberFormat: value as "none" | "comma" | "currency",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">없음</SelectItem>
|
||||
<SelectItem value="comma">천단위 구분</SelectItem>
|
||||
<SelectItem value="currency">통화 (원)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 통화 접미사 */}
|
||||
{selectedComponent.numberFormat === "currency" && (
|
||||
<div>
|
||||
<Label className="text-xs">통화 단위</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={selectedComponent.currencySuffix || "원"}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
currencySuffix: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="원"
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 폰트 크기 설정 */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">라벨 크기</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={selectedComponent.labelFontSize || 13}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
labelFontSize: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
min={10}
|
||||
max={20}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">값 크기</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={selectedComponent.valueFontSize || 13}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
valueFontSize: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
min={10}
|
||||
max={20}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">결과 크기</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={selectedComponent.resultFontSize || 16}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
resultFontSize: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
min={12}
|
||||
max={24}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 색상 설정 */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">라벨 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={selectedComponent.labelColor || "#374151"}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
labelColor: e.target.value,
|
||||
})
|
||||
}
|
||||
className="h-8 w-full cursor-pointer p-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">값 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={selectedComponent.valueColor || "#000000"}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
valueColor: e.target.value,
|
||||
})
|
||||
}
|
||||
className="h-8 w-full cursor-pointer p-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">결과 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={selectedComponent.resultColor || "#2563eb"}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
resultColor: e.target.value,
|
||||
})
|
||||
}
|
||||
className="h-8 w-full cursor-pointer p-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 계산 항목 목록 관리 */}
|
||||
<div className="mt-4 border-t pt-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold">계산 항목</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => {
|
||||
const currentItems = selectedComponent.calcItems || [];
|
||||
updateComponent(selectedComponent.id, {
|
||||
calcItems: [
|
||||
...currentItems,
|
||||
{
|
||||
label: `항목${currentItems.length + 1}`,
|
||||
value: 0,
|
||||
operator: "+" as const,
|
||||
fieldName: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
}}
|
||||
>
|
||||
+ 항목 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 쿼리 선택 (데이터 바인딩용) */}
|
||||
<div className="mb-2">
|
||||
<Label className="text-xs">데이터 소스 (쿼리)</Label>
|
||||
<Select
|
||||
value={selectedComponent.queryId || "none"}
|
||||
onValueChange={(value) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
queryId: value === "none" ? undefined : value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="쿼리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">직접 입력</SelectItem>
|
||||
{queries.map((q) => (
|
||||
<SelectItem key={q.id} value={q.id}>
|
||||
{q.name} ({q.type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 항목 리스트 */}
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto">
|
||||
{(selectedComponent.calcItems || []).map((item, index: number) => (
|
||||
<div key={index} className="rounded border bg-white p-2">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-xs font-medium">항목 {index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
|
||||
onClick={() => {
|
||||
const currentItems = [...(selectedComponent.calcItems || [])];
|
||||
currentItems.splice(index, 1);
|
||||
updateComponent(selectedComponent.id, {
|
||||
calcItems: currentItems,
|
||||
});
|
||||
}}
|
||||
>
|
||||
x
|
||||
</Button>
|
||||
</div>
|
||||
<div className={`grid gap-1 ${index === 0 ? "grid-cols-1" : "grid-cols-3"}`}>
|
||||
<div className={index === 0 ? "" : "col-span-2"}>
|
||||
<Label className="text-[10px]">라벨</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={item.label}
|
||||
onChange={(e) => {
|
||||
const currentItems = [...(selectedComponent.calcItems || [])];
|
||||
currentItems[index] = { ...currentItems[index], label: e.target.value };
|
||||
updateComponent(selectedComponent.id, {
|
||||
calcItems: currentItems,
|
||||
});
|
||||
}}
|
||||
className="h-6 text-xs"
|
||||
placeholder="항목명"
|
||||
/>
|
||||
</div>
|
||||
{/* 두 번째 항목부터 연산자 표시 */}
|
||||
{index > 0 && (
|
||||
<div>
|
||||
<Label className="text-[10px]">연산자</Label>
|
||||
<Select
|
||||
value={item.operator}
|
||||
onValueChange={(value) => {
|
||||
const currentItems = [...(selectedComponent.calcItems || [])];
|
||||
currentItems[index] = {
|
||||
...currentItems[index],
|
||||
operator: value as "+" | "-" | "x" | "÷",
|
||||
};
|
||||
updateComponent(selectedComponent.id, {
|
||||
calcItems: currentItems,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="+">+</SelectItem>
|
||||
<SelectItem value="-">-</SelectItem>
|
||||
<SelectItem value="x">x</SelectItem>
|
||||
<SelectItem value="÷">÷</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
{selectedComponent.queryId ? (
|
||||
<div>
|
||||
<Label className="text-[10px]">필드</Label>
|
||||
<Select
|
||||
value={item.fieldName || "none"}
|
||||
onValueChange={(value) => {
|
||||
const currentItems = [...(selectedComponent.calcItems || [])];
|
||||
currentItems[index] = {
|
||||
...currentItems[index],
|
||||
fieldName: value === "none" ? "" : value,
|
||||
};
|
||||
updateComponent(selectedComponent.id, {
|
||||
calcItems: currentItems,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">직접 입력</SelectItem>
|
||||
{(() => {
|
||||
const query = queries.find((q) => q.id === selectedComponent.queryId);
|
||||
const result = query ? getQueryResult(query.id) : null;
|
||||
if (result && result.fields) {
|
||||
return result.fields.map((field: string) => (
|
||||
<SelectItem key={field} value={field}>
|
||||
{field}
|
||||
</SelectItem>
|
||||
));
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Label className="text-[10px]">값</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={item.value}
|
||||
onChange={(e) => {
|
||||
const currentItems = [...(selectedComponent.calcItems || [])];
|
||||
currentItems[index] = {
|
||||
...currentItems[index],
|
||||
value: Number(e.target.value),
|
||||
};
|
||||
updateComponent(selectedComponent.id, {
|
||||
calcItems: currentItems,
|
||||
});
|
||||
}}
|
||||
className="h-6 text-xs"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 데이터 바인딩 (텍스트/라벨/테이블 컴포넌트) */}
|
||||
{(selectedComponent.type === "text" ||
|
||||
selectedComponent.type === "label" ||
|
||||
|
|
@ -1120,16 +1833,16 @@ export function ReportDesignerRightPanel() {
|
|||
{/* 기본값 (텍스트/라벨만) */}
|
||||
{(selectedComponent.type === "text" || selectedComponent.type === "label") && (
|
||||
<div>
|
||||
<Label className="text-xs">기본값</Label>
|
||||
<Input
|
||||
<Label className="text-xs">텍스트 내용</Label>
|
||||
<Textarea
|
||||
value={selectedComponent.defaultValue || ""}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
defaultValue: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="데이터가 없을 때 표시할 값"
|
||||
className="h-8"
|
||||
placeholder="텍스트 내용 (엔터로 줄바꿈 가능)"
|
||||
className="min-h-[80px] resize-y"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -13,21 +13,6 @@ import { Printer, FileDown, FileText } from "lucide-react";
|
|||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { useState } from "react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
// @ts-ignore - docx 라이브러리 타입 이슈
|
||||
import {
|
||||
Document,
|
||||
Packer,
|
||||
Paragraph,
|
||||
TextRun,
|
||||
Table,
|
||||
TableCell,
|
||||
TableRow,
|
||||
WidthType,
|
||||
ImageRun,
|
||||
AlignmentType,
|
||||
VerticalAlign,
|
||||
convertInchesToTwip,
|
||||
} from "docx";
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
|
||||
interface ReportPreviewModalProps {
|
||||
|
|
@ -73,6 +58,8 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
pageWidth: number,
|
||||
pageHeight: number,
|
||||
backgroundColor: string,
|
||||
pageIndex: number = 0,
|
||||
totalPages: number = 1,
|
||||
): string => {
|
||||
const componentsHTML = pageComponents
|
||||
.map((component) => {
|
||||
|
|
@ -82,7 +69,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
// Text/Label 컴포넌트
|
||||
if (component.type === "text" || component.type === "label") {
|
||||
const displayValue = getComponentValue(component);
|
||||
content = `<div style="font-size: ${component.fontSize || 13}px; color: ${component.fontColor || "#000000"}; font-weight: ${component.fontWeight || "normal"}; text-align: ${component.textAlign || "left"};">${displayValue}</div>`;
|
||||
content = `<div style="font-size: ${component.fontSize || 13}px; color: ${component.fontColor || "#000000"}; font-weight: ${component.fontWeight || "normal"}; text-align: ${component.textAlign || "left"}; white-space: pre-wrap;">${displayValue}</div>`;
|
||||
}
|
||||
|
||||
// Image 컴포넌트
|
||||
|
|
@ -154,6 +141,163 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
</div>`;
|
||||
}
|
||||
|
||||
// PageNumber 컴포넌트
|
||||
else if (component.type === "pageNumber") {
|
||||
const format = component.pageNumberFormat || "number";
|
||||
let pageNumberText = "";
|
||||
switch (format) {
|
||||
case "number":
|
||||
pageNumberText = `${pageIndex + 1}`;
|
||||
break;
|
||||
case "numberTotal":
|
||||
pageNumberText = `${pageIndex + 1} / ${totalPages}`;
|
||||
break;
|
||||
case "koreanNumber":
|
||||
pageNumberText = `${pageIndex + 1} 페이지`;
|
||||
break;
|
||||
default:
|
||||
pageNumberText = `${pageIndex + 1}`;
|
||||
}
|
||||
content = `<div style="display: flex; align-items: center; justify-content: center; height: 100%; font-size: ${component.fontSize}px; color: ${component.fontColor}; font-weight: ${component.fontWeight}; text-align: ${component.textAlign};">${pageNumberText}</div>`;
|
||||
}
|
||||
|
||||
// Card 컴포넌트
|
||||
else if (component.type === "card") {
|
||||
const cardTitle = component.cardTitle || "정보 카드";
|
||||
const cardItems = component.cardItems || [];
|
||||
const labelWidth = component.labelWidth || 80;
|
||||
const showCardTitle = component.showCardTitle !== false;
|
||||
const titleFontSize = component.titleFontSize || 14;
|
||||
const labelFontSize = component.labelFontSize || 13;
|
||||
const valueFontSize = component.valueFontSize || 13;
|
||||
const titleColor = component.titleColor || "#1e40af";
|
||||
const labelColor = component.labelColor || "#374151";
|
||||
const valueColor = component.valueColor || "#000000";
|
||||
const borderColor = component.borderColor || "#e5e7eb";
|
||||
|
||||
// 쿼리 바인딩된 값 가져오기
|
||||
const getCardValue = (item: { label: string; value: string; fieldName?: string }) => {
|
||||
if (item.fieldName && queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
||||
const row = queryResult.rows[0];
|
||||
return row[item.fieldName] !== undefined ? String(row[item.fieldName]) : item.value;
|
||||
}
|
||||
return item.value;
|
||||
};
|
||||
|
||||
const itemsHtml = cardItems
|
||||
.map(
|
||||
(item: { label: string; value: string; fieldName?: string }) => `
|
||||
<div style="display: flex; padding: 2px 0;">
|
||||
<span style="width: ${labelWidth}px; flex-shrink: 0; font-size: ${labelFontSize}px; color: ${labelColor}; font-weight: 500;">${item.label}</span>
|
||||
<span style="flex: 1; font-size: ${valueFontSize}px; color: ${valueColor};">${getCardValue(item)}</span>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
|
||||
content = `
|
||||
<div style="display: flex; flex-direction: column; height: 100%; overflow: hidden;">
|
||||
${
|
||||
showCardTitle
|
||||
? `
|
||||
<div style="flex-shrink: 0; padding: 4px 8px; font-size: ${titleFontSize}px; font-weight: 600; color: ${titleColor};">
|
||||
${cardTitle}
|
||||
</div>
|
||||
<div style="flex-shrink: 0; margin: 0 4px; border-bottom: 1px solid ${borderColor};"></div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
<div style="flex: 1; padding: 4px 8px; overflow: auto;">
|
||||
${itemsHtml}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 계산 컴포넌트
|
||||
else if (component.type === "calculation") {
|
||||
const calcItems = component.calcItems || [];
|
||||
const resultLabel = component.resultLabel || "합계";
|
||||
const calcLabelWidth = component.labelWidth || 120;
|
||||
const calcLabelFontSize = component.labelFontSize || 13;
|
||||
const calcValueFontSize = component.valueFontSize || 13;
|
||||
const calcResultFontSize = component.resultFontSize || 16;
|
||||
const calcLabelColor = component.labelColor || "#374151";
|
||||
const calcValueColor = component.valueColor || "#000000";
|
||||
const calcResultColor = component.resultColor || "#2563eb";
|
||||
const numberFormat = component.numberFormat || "currency";
|
||||
const currencySuffix = component.currencySuffix || "원";
|
||||
const borderColor = component.borderColor || "#374151";
|
||||
|
||||
// 숫자 포맷팅 함수
|
||||
const formatNumber = (num: number): string => {
|
||||
if (numberFormat === "none") return String(num);
|
||||
if (numberFormat === "comma") return num.toLocaleString();
|
||||
if (numberFormat === "currency") return num.toLocaleString() + currencySuffix;
|
||||
return String(num);
|
||||
};
|
||||
|
||||
// 쿼리 바인딩된 값 가져오기
|
||||
const getCalcItemValue = (item: { label: string; value: number | string; operator: string; fieldName?: string }): number => {
|
||||
if (item.fieldName && queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
||||
const row = queryResult.rows[0];
|
||||
const val = row[item.fieldName];
|
||||
return typeof val === "number" ? val : parseFloat(String(val)) || 0;
|
||||
}
|
||||
return typeof item.value === "number" ? item.value : parseFloat(String(item.value)) || 0;
|
||||
};
|
||||
|
||||
// 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용)
|
||||
let calcResult = 0;
|
||||
if (calcItems.length > 0) {
|
||||
// 첫 번째 항목은 기준값
|
||||
calcResult = getCalcItemValue(calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string });
|
||||
|
||||
// 두 번째 항목부터 연산자 적용
|
||||
for (let i = 1; i < calcItems.length; i++) {
|
||||
const item = calcItems[i];
|
||||
const val = getCalcItemValue(item as { label: string; value: number | string; operator: string; fieldName?: string });
|
||||
switch ((item as { operator: string }).operator) {
|
||||
case "+":
|
||||
calcResult += val;
|
||||
break;
|
||||
case "-":
|
||||
calcResult -= val;
|
||||
break;
|
||||
case "x":
|
||||
calcResult *= val;
|
||||
break;
|
||||
case "÷":
|
||||
calcResult = val !== 0 ? calcResult / val : calcResult;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const itemsHtml = calcItems
|
||||
.map((item: { label: string; value: number | string; operator: string; fieldName?: string }) => {
|
||||
const itemValue = getCalcItemValue(item);
|
||||
return `
|
||||
<div style="display: flex; justify-content: space-between; padding: 4px 8px;">
|
||||
<span style="width: ${calcLabelWidth}px; font-size: ${calcLabelFontSize}px; color: ${calcLabelColor};">${item.label}</span>
|
||||
<span style="font-size: ${calcValueFontSize}px; color: ${calcValueColor}; text-align: right;">${formatNumber(itemValue)}</span>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
content = `
|
||||
<div style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div style="flex: 1;">
|
||||
${itemsHtml}
|
||||
</div>
|
||||
<div style="border-top: 1px solid ${borderColor}; margin: 4px 8px;"></div>
|
||||
<div style="display: flex; justify-content: space-between; padding: 4px 8px;">
|
||||
<span style="width: ${calcLabelWidth}px; font-size: ${calcResultFontSize}px; font-weight: 600; color: ${calcLabelColor};">${resultLabel}</span>
|
||||
<span style="font-size: ${calcResultFontSize}px; font-weight: 700; color: ${calcResultColor}; text-align: right;">${formatNumber(calcResult)}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Table 컴포넌트
|
||||
else if (component.type === "table" && queryResult && queryResult.rows.length > 0) {
|
||||
const columns =
|
||||
|
|
@ -204,9 +348,20 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
|
||||
// 모든 페이지 HTML 생성 (인쇄/PDF용)
|
||||
const generatePrintHTML = (): string => {
|
||||
const pagesHTML = layoutConfig.pages
|
||||
.sort((a, b) => a.page_order - b.page_order)
|
||||
.map((page) => generatePageHTML(page.components, page.width, page.height, page.background_color))
|
||||
const sortedPages = layoutConfig.pages.sort((a, b) => a.page_order - b.page_order);
|
||||
const totalPages = sortedPages.length;
|
||||
|
||||
const pagesHTML = sortedPages
|
||||
.map((page, pageIndex) =>
|
||||
generatePageHTML(
|
||||
Array.isArray(page.components) ? page.components : [],
|
||||
page.width,
|
||||
page.height,
|
||||
page.background_color,
|
||||
pageIndex,
|
||||
totalPages,
|
||||
),
|
||||
)
|
||||
.join('<div style="page-break-after: always;"></div>');
|
||||
|
||||
return `
|
||||
|
|
@ -282,270 +437,94 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
});
|
||||
};
|
||||
|
||||
// Base64를 Uint8Array로 변환
|
||||
const base64ToUint8Array = (base64: string): Uint8Array => {
|
||||
const base64Data = base64.split(",")[1] || base64;
|
||||
const binaryString = atob(base64Data);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
// 이미지 URL을 Base64로 변환
|
||||
const imageUrlToBase64 = async (url: string): Promise<string> => {
|
||||
try {
|
||||
// 이미 Base64인 경우 그대로 반환
|
||||
if (url.startsWith("data:")) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// 컴포넌트를 TableCell로 변환
|
||||
const createTableCell = (component: any, pageWidth: number, widthPercent?: number): TableCell | null => {
|
||||
const cellWidth = widthPercent || 100;
|
||||
// 서버 이미지 URL을 fetch하여 Base64로 변환
|
||||
const fullUrl = getFullImageUrl(url);
|
||||
const response = await fetch(fullUrl);
|
||||
const blob = await response.blob();
|
||||
|
||||
if (component.type === "text" || component.type === "label") {
|
||||
const value = getComponentValue(component);
|
||||
return new TableCell({
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: value,
|
||||
size: (component.fontSize || 13) * 2,
|
||||
color: component.fontColor?.replace("#", "") || "000000",
|
||||
bold: component.fontWeight === "bold",
|
||||
}),
|
||||
],
|
||||
alignment:
|
||||
component.textAlign === "center"
|
||||
? AlignmentType.CENTER
|
||||
: component.textAlign === "right"
|
||||
? AlignmentType.RIGHT
|
||||
: AlignmentType.LEFT,
|
||||
}),
|
||||
],
|
||||
width: { size: cellWidth, type: WidthType.PERCENTAGE },
|
||||
verticalAlign: VerticalAlign.CENTER,
|
||||
borders: {
|
||||
top: { style: 0, size: 0, color: "FFFFFF" },
|
||||
bottom: { style: 0, size: 0, color: "FFFFFF" },
|
||||
left: { style: 0, size: 0, color: "FFFFFF" },
|
||||
right: { style: 0, size: 0, color: "FFFFFF" },
|
||||
},
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
} else if (component.type === "signature" || component.type === "stamp") {
|
||||
if (component.imageUrl) {
|
||||
try {
|
||||
const imageData = base64ToUint8Array(component.imageUrl);
|
||||
return new TableCell({
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: [
|
||||
new ImageRun({
|
||||
data: imageData,
|
||||
transformation: {
|
||||
width: component.width || 150,
|
||||
height: component.height || 50,
|
||||
},
|
||||
}),
|
||||
],
|
||||
alignment: AlignmentType.CENTER,
|
||||
}),
|
||||
],
|
||||
width: { size: cellWidth, type: WidthType.PERCENTAGE },
|
||||
verticalAlign: VerticalAlign.CENTER,
|
||||
borders: {
|
||||
top: { style: 0, size: 0, color: "FFFFFF" },
|
||||
bottom: { style: 0, size: 0, color: "FFFFFF" },
|
||||
left: { style: 0, size: 0, color: "FFFFFF" },
|
||||
right: { style: 0, size: 0, color: "FFFFFF" },
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return new TableCell({
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: `[${component.type === "signature" ? "서명" : "도장"}]`,
|
||||
size: 24,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
width: { size: cellWidth, type: WidthType.PERCENTAGE },
|
||||
borders: {
|
||||
top: { style: 0, size: 0, color: "FFFFFF" },
|
||||
bottom: { style: 0, size: 0, color: "FFFFFF" },
|
||||
left: { style: 0, size: 0, color: "FFFFFF" },
|
||||
right: { style: 0, size: 0, color: "FFFFFF" },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (component.type === "table" && component.queryId) {
|
||||
const queryResult = getQueryResult(component.queryId);
|
||||
if (queryResult && queryResult.rows.length > 0) {
|
||||
const headerCells = queryResult.fields.map(
|
||||
(field) =>
|
||||
new TableCell({
|
||||
children: [new Paragraph({ text: field })],
|
||||
width: { size: 100 / queryResult.fields.length, type: WidthType.PERCENTAGE },
|
||||
}),
|
||||
);
|
||||
|
||||
const dataRows = queryResult.rows.map(
|
||||
(row) =>
|
||||
new TableRow({
|
||||
children: queryResult.fields.map(
|
||||
(field) =>
|
||||
new TableCell({
|
||||
children: [new Paragraph({ text: String(row[field] ?? "") })],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const table = new Table({
|
||||
rows: [new TableRow({ children: headerCells }), ...dataRows],
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
});
|
||||
|
||||
return new TableCell({
|
||||
children: [table],
|
||||
width: { size: cellWidth, type: WidthType.PERCENTAGE },
|
||||
borders: {
|
||||
top: { style: 0, size: 0, color: "FFFFFF" },
|
||||
bottom: { style: 0, size: 0, color: "FFFFFF" },
|
||||
left: { style: 0, size: 0, color: "FFFFFF" },
|
||||
right: { style: 0, size: 0, color: "FFFFFF" },
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("이미지 변환 실패:", error);
|
||||
return "";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// WORD 다운로드
|
||||
// WORD 다운로드 (백엔드 API 사용 - 컴포넌트 데이터 전송)
|
||||
const handleDownloadWord = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
// 페이지별로 섹션 생성
|
||||
const sections = layoutConfig.pages
|
||||
.sort((a, b) => a.page_order - b.page_order)
|
||||
.map((page) => {
|
||||
// 페이지 크기 설정 (A4 기준)
|
||||
const pageWidth = convertInchesToTwip(8.27); // A4 width in inches
|
||||
const pageHeight = convertInchesToTwip(11.69); // A4 height in inches
|
||||
const marginTop = convertInchesToTwip(page.margins.top / 96); // px to inches (96 DPI)
|
||||
const marginBottom = convertInchesToTwip(page.margins.bottom / 96);
|
||||
const marginLeft = convertInchesToTwip(page.margins.left / 96);
|
||||
const marginRight = convertInchesToTwip(page.margins.right / 96);
|
||||
|
||||
// 페이지 내 컴포넌트를 Y좌표 기준으로 정렬
|
||||
const sortedComponents = [...page.components].sort((a, b) => {
|
||||
// Y좌표 우선, 같으면 X좌표
|
||||
if (Math.abs(a.y - b.y) < 5) {
|
||||
return a.x - b.x;
|
||||
}
|
||||
return a.y - b.y;
|
||||
});
|
||||
|
||||
// 컴포넌트들을 행으로 그룹화 (같은 Y 범위 = 같은 행)
|
||||
const rows: Array<Array<(typeof sortedComponents)[0]>> = [];
|
||||
const rowTolerance = 20; // Y 좌표 허용 오차
|
||||
|
||||
for (const component of sortedComponents) {
|
||||
const existingRow = rows.find((row) => Math.abs(row[0].y - component.y) < rowTolerance);
|
||||
if (existingRow) {
|
||||
existingRow.push(component);
|
||||
} else {
|
||||
rows.push([component]);
|
||||
}
|
||||
}
|
||||
|
||||
// 각 행 내에서 X좌표로 정렬
|
||||
rows.forEach((row) => row.sort((a, b) => a.x - b.x));
|
||||
|
||||
// 페이지 전체를 큰 테이블로 구성 (레이아웃 제어용)
|
||||
const tableRows: TableRow[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.length === 1) {
|
||||
// 단일 컴포넌트 - 전체 너비 사용
|
||||
const component = row[0];
|
||||
const cell = createTableCell(component, pageWidth);
|
||||
if (cell) {
|
||||
tableRows.push(
|
||||
new TableRow({
|
||||
children: [cell],
|
||||
height: { value: component.height * 15, rule: 1 }, // 최소 높이 설정
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 여러 컴포넌트 - 가로 배치
|
||||
const cells: TableCell[] = [];
|
||||
const totalWidth = row.reduce((sum, c) => sum + c.width, 0);
|
||||
|
||||
for (const component of row) {
|
||||
const widthPercent = (component.width / totalWidth) * 100;
|
||||
const cell = createTableCell(component, pageWidth, widthPercent);
|
||||
if (cell) {
|
||||
cells.push(cell);
|
||||
}
|
||||
}
|
||||
|
||||
if (cells.length > 0) {
|
||||
const maxHeight = Math.max(...row.map((c) => c.height));
|
||||
tableRows.push(
|
||||
new TableRow({
|
||||
children: cells,
|
||||
height: { value: maxHeight * 15, rule: 1 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
properties: {
|
||||
page: {
|
||||
width: pageWidth,
|
||||
height: pageHeight,
|
||||
margin: {
|
||||
top: marginTop,
|
||||
bottom: marginBottom,
|
||||
left: marginLeft,
|
||||
right: marginRight,
|
||||
},
|
||||
},
|
||||
},
|
||||
children:
|
||||
tableRows.length > 0
|
||||
? [
|
||||
new Table({
|
||||
rows: tableRows,
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
borders: {
|
||||
top: { style: 0, size: 0, color: "FFFFFF" },
|
||||
bottom: { style: 0, size: 0, color: "FFFFFF" },
|
||||
left: { style: 0, size: 0, color: "FFFFFF" },
|
||||
right: { style: 0, size: 0, color: "FFFFFF" },
|
||||
insideHorizontal: { style: 0, size: 0, color: "FFFFFF" },
|
||||
insideVertical: { style: 0, size: 0, color: "FFFFFF" },
|
||||
},
|
||||
}),
|
||||
]
|
||||
: [new Paragraph({ text: "" })],
|
||||
};
|
||||
});
|
||||
|
||||
// 문서 생성
|
||||
const doc = new Document({
|
||||
sections,
|
||||
toast({
|
||||
title: "처리 중",
|
||||
description: "WORD 파일을 생성하고 있습니다...",
|
||||
});
|
||||
|
||||
// Blob 생성 및 다운로드
|
||||
const blob = await Packer.toBlob(doc);
|
||||
const fileName = reportDetail?.report?.report_name_kor || "리포트";
|
||||
const timestamp = new Date().toISOString().slice(0, 10);
|
||||
// 이미지를 Base64로 변환하여 컴포넌트 데이터에 포함
|
||||
const pagesWithBase64 = await Promise.all(
|
||||
layoutConfig.pages.map(async (page) => {
|
||||
const componentsWithBase64 = await Promise.all(
|
||||
(Array.isArray(page.components) ? page.components : []).map(async (component) => {
|
||||
// 이미지가 있는 컴포넌트는 Base64로 변환
|
||||
if (component.imageUrl) {
|
||||
try {
|
||||
const base64 = await imageUrlToBase64(component.imageUrl);
|
||||
return { ...component, imageBase64: base64 };
|
||||
} catch {
|
||||
return component;
|
||||
}
|
||||
}
|
||||
return component;
|
||||
}),
|
||||
);
|
||||
return { ...page, components: componentsWithBase64 };
|
||||
}),
|
||||
);
|
||||
|
||||
// 쿼리 결과 수집
|
||||
const queryResults: Record<string, { fields: string[]; rows: Record<string, unknown>[] }> = {};
|
||||
for (const page of layoutConfig.pages) {
|
||||
const pageComponents = Array.isArray(page.components) ? page.components : [];
|
||||
for (const component of pageComponents) {
|
||||
if (component.queryId) {
|
||||
const result = getQueryResult(component.queryId);
|
||||
if (result) {
|
||||
queryResults[component.queryId] = result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fileName = reportDetail?.report?.report_name_kor || "리포트";
|
||||
|
||||
// 백엔드 API 호출 (컴포넌트 데이터 전송)
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const response = await apiClient.post(
|
||||
"/admin/reports/export-word",
|
||||
{
|
||||
layoutConfig: { ...layoutConfig, pages: pagesWithBase64 },
|
||||
queryResults,
|
||||
fileName,
|
||||
},
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
|
||||
// Blob 다운로드
|
||||
const blob = new Blob([response.data], {
|
||||
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
});
|
||||
const timestamp = new Date().toISOString().slice(0, 10);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
|
|
@ -558,6 +537,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
description: "WORD 파일이 다운로드되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("WORD 변환 오류:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "WORD 생성에 실패했습니다.";
|
||||
toast({
|
||||
title: "오류",
|
||||
|
|
@ -586,11 +566,6 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
.sort((a, b) => a.page_order - b.page_order)
|
||||
.map((page) => (
|
||||
<div key={page.page_id} className="relative">
|
||||
{/* 페이지 번호 라벨 */}
|
||||
<div className="mb-2 text-center text-xs text-gray-500">
|
||||
페이지 {page.page_order + 1} - {page.page_name}
|
||||
</div>
|
||||
|
||||
{/* 페이지 컨텐츠 */}
|
||||
<div
|
||||
className="relative mx-auto shadow-lg"
|
||||
|
|
@ -600,7 +575,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
backgroundColor: page.background_color,
|
||||
}}
|
||||
>
|
||||
{page.components.map((component) => {
|
||||
{(Array.isArray(page.components) ? page.components : []).map((component) => {
|
||||
const displayValue = getComponentValue(component);
|
||||
const queryResult = component.queryId ? getQueryResult(component.queryId) : null;
|
||||
|
||||
|
|
@ -627,6 +602,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
color: component.fontColor,
|
||||
fontWeight: component.fontWeight,
|
||||
textAlign: component.textAlign as "left" | "center" | "right",
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{displayValue}
|
||||
|
|
@ -640,6 +616,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
color: component.fontColor,
|
||||
fontWeight: component.fontWeight,
|
||||
textAlign: component.textAlign as "left" | "center" | "right",
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{displayValue}
|
||||
|
|
@ -886,6 +863,256 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{component.type === "pageNumber" && (() => {
|
||||
const format = component.pageNumberFormat || "number";
|
||||
const pageIndex = layoutConfig.pages
|
||||
.sort((a, b) => a.page_order - b.page_order)
|
||||
.findIndex((p) => p.page_id === page.page_id);
|
||||
const totalPages = layoutConfig.pages.length;
|
||||
let pageNumberText = "";
|
||||
switch (format) {
|
||||
case "number":
|
||||
pageNumberText = `${pageIndex + 1}`;
|
||||
break;
|
||||
case "numberTotal":
|
||||
pageNumberText = `${pageIndex + 1} / ${totalPages}`;
|
||||
break;
|
||||
case "koreanNumber":
|
||||
pageNumberText = `${pageIndex + 1} 페이지`;
|
||||
break;
|
||||
default:
|
||||
pageNumberText = `${pageIndex + 1}`;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
fontSize: `${component.fontSize}px`,
|
||||
color: component.fontColor,
|
||||
fontWeight: component.fontWeight,
|
||||
}}
|
||||
>
|
||||
{pageNumberText}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Card 컴포넌트 */}
|
||||
{component.type === "card" && (() => {
|
||||
const cardTitle = component.cardTitle || "정보 카드";
|
||||
const cardItems = component.cardItems || [];
|
||||
const labelWidth = component.labelWidth || 80;
|
||||
const showCardTitle = component.showCardTitle !== false;
|
||||
const titleFontSize = component.titleFontSize || 14;
|
||||
const labelFontSize = component.labelFontSize || 13;
|
||||
const valueFontSize = component.valueFontSize || 13;
|
||||
const titleColor = component.titleColor || "#1e40af";
|
||||
const labelColor = component.labelColor || "#374151";
|
||||
const valueColor = component.valueColor || "#000000";
|
||||
const borderColor = component.borderColor || "#e5e7eb";
|
||||
|
||||
// 쿼리 바인딩된 값 가져오기
|
||||
const getCardValue = (item: { label: string; value: string; fieldName?: string }) => {
|
||||
if (item.fieldName && component.queryId) {
|
||||
const qResult = getQueryResult(component.queryId);
|
||||
if (qResult && qResult.rows && qResult.rows.length > 0) {
|
||||
const row = qResult.rows[0];
|
||||
return row[item.fieldName] !== undefined ? String(row[item.fieldName]) : item.value;
|
||||
}
|
||||
}
|
||||
return item.value;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{showCardTitle && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
padding: "4px 8px",
|
||||
fontSize: `${titleFontSize}px`,
|
||||
fontWeight: 600,
|
||||
color: titleColor,
|
||||
}}
|
||||
>
|
||||
{cardTitle}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
margin: "0 4px",
|
||||
borderBottom: `1px solid ${borderColor}`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div style={{ flex: 1, padding: "4px 8px", overflow: "auto" }}>
|
||||
{cardItems.map((item: { label: string; value: string; fieldName?: string }, idx: number) => (
|
||||
<div key={idx} style={{ display: "flex", padding: "2px 0" }}>
|
||||
<span
|
||||
style={{
|
||||
width: `${labelWidth}px`,
|
||||
flexShrink: 0,
|
||||
fontSize: `${labelFontSize}px`,
|
||||
color: labelColor,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: `${valueFontSize}px`,
|
||||
color: valueColor,
|
||||
}}
|
||||
>
|
||||
{getCardValue(item)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 계산 컴포넌트 */}
|
||||
{component.type === "calculation" && (() => {
|
||||
const calcItems = component.calcItems || [];
|
||||
const resultLabel = component.resultLabel || "합계";
|
||||
const calcLabelWidth = component.labelWidth || 120;
|
||||
const calcLabelFontSize = component.labelFontSize || 13;
|
||||
const calcValueFontSize = component.valueFontSize || 13;
|
||||
const calcResultFontSize = component.resultFontSize || 16;
|
||||
const calcLabelColor = component.labelColor || "#374151";
|
||||
const calcValueColor = component.valueColor || "#000000";
|
||||
const calcResultColor = component.resultColor || "#2563eb";
|
||||
const numberFormat = component.numberFormat || "currency";
|
||||
const currencySuffix = component.currencySuffix || "원";
|
||||
const borderColor = component.borderColor || "#374151";
|
||||
|
||||
// 숫자 포맷팅 함수
|
||||
const formatNumber = (num: number): string => {
|
||||
if (numberFormat === "none") return String(num);
|
||||
if (numberFormat === "comma") return num.toLocaleString();
|
||||
if (numberFormat === "currency") return num.toLocaleString() + currencySuffix;
|
||||
return String(num);
|
||||
};
|
||||
|
||||
// 쿼리 바인딩된 값 가져오기
|
||||
const getCalcItemValue = (item: { label: string; value: number | string; operator: string; fieldName?: string }): number => {
|
||||
if (item.fieldName && component.queryId) {
|
||||
const qResult = getQueryResult(component.queryId);
|
||||
if (qResult && qResult.rows && qResult.rows.length > 0) {
|
||||
const row = qResult.rows[0];
|
||||
const val = row[item.fieldName];
|
||||
return typeof val === "number" ? val : parseFloat(String(val)) || 0;
|
||||
}
|
||||
}
|
||||
return typeof item.value === "number" ? item.value : parseFloat(String(item.value)) || 0;
|
||||
};
|
||||
|
||||
// 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용)
|
||||
let calcResult = 0;
|
||||
if (calcItems.length > 0) {
|
||||
// 첫 번째 항목은 기준값
|
||||
calcResult = getCalcItemValue(calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string });
|
||||
|
||||
// 두 번째 항목부터 연산자 적용
|
||||
for (let i = 1; i < calcItems.length; i++) {
|
||||
const item = calcItems[i];
|
||||
const val = getCalcItemValue(item as { label: string; value: number | string; operator: string; fieldName?: string });
|
||||
switch ((item as { operator: string }).operator) {
|
||||
case "+":
|
||||
calcResult += val;
|
||||
break;
|
||||
case "-":
|
||||
calcResult -= val;
|
||||
break;
|
||||
case "x":
|
||||
calcResult *= val;
|
||||
break;
|
||||
case "÷":
|
||||
calcResult = val !== 0 ? calcResult / val : calcResult;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
{calcItems.map((item: { label: string; value: number | string; operator: string; fieldName?: string }, idx: number) => {
|
||||
const itemValue = getCalcItemValue(item);
|
||||
return (
|
||||
<div key={idx} style={{ display: "flex", justifyContent: "space-between", padding: "4px 8px" }}>
|
||||
<span
|
||||
style={{
|
||||
width: `${calcLabelWidth}px`,
|
||||
fontSize: `${calcLabelFontSize}px`,
|
||||
color: calcLabelColor,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${calcValueFontSize}px`,
|
||||
color: calcValueColor,
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{formatNumber(itemValue)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ borderTop: `1px solid ${borderColor}`, margin: "4px 8px" }} />
|
||||
<div style={{ display: "flex", justifyContent: "space-between", padding: "4px 8px" }}>
|
||||
<span
|
||||
style={{
|
||||
width: `${calcLabelWidth}px`,
|
||||
fontSize: `${calcResultFontSize}px`,
|
||||
fontWeight: 600,
|
||||
color: calcLabelColor,
|
||||
}}
|
||||
>
|
||||
{resultLabel}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${calcResultFontSize}px`,
|
||||
fontWeight: 700,
|
||||
color: calcResultColor,
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{formatNumber(calcResult)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { Trash2, Loader2, RefreshCw } from "lucide-react";
|
|||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface Template {
|
||||
template_id: string;
|
||||
|
|
@ -17,7 +16,6 @@ interface Template {
|
|||
|
||||
export function TemplatePalette() {
|
||||
const { applyTemplate } = useReportDesigner();
|
||||
const [systemTemplates, setSystemTemplates] = useState<Template[]>([]);
|
||||
const [customTemplates, setCustomTemplates] = useState<Template[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
|
@ -28,7 +26,6 @@ export function TemplatePalette() {
|
|||
try {
|
||||
const response = await reportApi.getTemplates();
|
||||
if (response.success && response.data) {
|
||||
setSystemTemplates(Array.isArray(response.data.system) ? response.data.system : []);
|
||||
setCustomTemplates(Array.isArray(response.data.custom) ? response.data.custom : []);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -79,31 +76,10 @@ export function TemplatePalette() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 시스템 템플릿 (DB에서 조회) */}
|
||||
{systemTemplates.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold text-gray-600">시스템 템플릿</p>
|
||||
</div>
|
||||
{systemTemplates.map((template) => (
|
||||
<Button
|
||||
key={template.template_id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 text-sm"
|
||||
onClick={() => handleApplyTemplate(template.template_id)}
|
||||
>
|
||||
<span>{template.template_name_kor}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* 사용자 정의 템플릿 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold text-gray-600">사용자 정의 템플릿</p>
|
||||
<div className="flex items-center justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={fetchTemplates} disabled={isLoading} className="h-6 w-6 p-0">
|
||||
<RefreshCw className={`h-3 w-3 ${isLoading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { GripVertical, Eye, EyeOff } from "lucide-react";
|
||||
import { GripVertical, Eye, EyeOff, Lock } from "lucide-react";
|
||||
import { ColumnVisibility } from "@/types/table-options";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -30,6 +30,7 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
|||
|
||||
const [localColumns, setLocalColumns] = useState<ColumnVisibility[]>([]);
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [frozenColumnCount, setFrozenColumnCount] = useState<number>(0);
|
||||
|
||||
// 테이블 정보 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -42,6 +43,8 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
|||
order: 0,
|
||||
}))
|
||||
);
|
||||
// 현재 틀고정 컬럼 수 로드
|
||||
setFrozenColumnCount(table.frozenColumnCount ?? 0);
|
||||
}
|
||||
}, [table]);
|
||||
|
||||
|
|
@ -94,6 +97,11 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
|||
table.onColumnOrderChange(newOrder);
|
||||
}
|
||||
|
||||
// 틀고정 컬럼 수 변경 콜백 호출
|
||||
if (table?.onFrozenColumnCountChange) {
|
||||
table.onFrozenColumnCountChange(frozenColumnCount);
|
||||
}
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
|
@ -107,9 +115,18 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
|||
order: 0,
|
||||
}))
|
||||
);
|
||||
setFrozenColumnCount(0);
|
||||
}
|
||||
};
|
||||
|
||||
// 틀고정 컬럼 수 변경 핸들러
|
||||
const handleFrozenColumnCountChange = (value: string) => {
|
||||
const count = parseInt(value) || 0;
|
||||
// 최대값은 표시 가능한 컬럼 수
|
||||
const maxCount = localColumns.filter((col) => col.visible).length;
|
||||
setFrozenColumnCount(Math.min(Math.max(0, count), maxCount));
|
||||
};
|
||||
|
||||
const visibleCount = localColumns.filter((col) => col.visible).length;
|
||||
|
||||
return (
|
||||
|
|
@ -126,11 +143,34 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
|||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{/* 상태 표시 */}
|
||||
<div className="flex items-center justify-between rounded-lg border bg-muted/50 p-3">
|
||||
<div className="text-xs text-muted-foreground sm:text-sm">
|
||||
{visibleCount}/{localColumns.length}개 컬럼 표시 중
|
||||
{/* 상태 표시 및 틀고정 설정 */}
|
||||
<div className="flex flex-col gap-3 rounded-lg border bg-muted/50 p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-xs text-muted-foreground sm:text-sm">
|
||||
{visibleCount}/{localColumns.length}개 컬럼 표시 중
|
||||
</div>
|
||||
|
||||
{/* 틀고정 설정 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className="h-4 w-4 text-muted-foreground" />
|
||||
<Label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
틀고정:
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={frozenColumnCount}
|
||||
onChange={(e) => handleFrozenColumnCountChange(e.target.value)}
|
||||
className="h-7 w-16 text-xs sm:h-8 sm:w-20 sm:text-sm"
|
||||
min={0}
|
||||
max={visibleCount}
|
||||
placeholder="0"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
개 컬럼
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -148,6 +188,12 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
|||
const columnMeta = table?.columns.find(
|
||||
(c) => c.columnName === col.columnName
|
||||
);
|
||||
// 표시 가능한 컬럼 중 몇 번째인지 계산 (틀고정 표시용)
|
||||
const visibleIndex = localColumns
|
||||
.slice(0, index + 1)
|
||||
.filter((c) => c.visible).length;
|
||||
const isFrozen = col.visible && visibleIndex <= frozenColumnCount;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={col.columnName}
|
||||
|
|
@ -155,7 +201,11 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
|||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className="flex items-center gap-3 rounded-lg border bg-background p-3 transition-colors hover:bg-muted/50 cursor-move"
|
||||
className={`flex items-center gap-3 rounded-lg border p-3 transition-colors cursor-move ${
|
||||
isFrozen
|
||||
? "bg-blue-50 border-blue-200 dark:bg-blue-950/30 dark:border-blue-800"
|
||||
: "bg-background hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
{/* 드래그 핸들 */}
|
||||
<GripVertical className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
|
|
@ -171,8 +221,10 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
|||
}
|
||||
/>
|
||||
|
||||
{/* 가시성 아이콘 */}
|
||||
{col.visible ? (
|
||||
{/* 가시성/틀고정 아이콘 */}
|
||||
{isFrozen ? (
|
||||
<Lock className="h-4 w-4 shrink-0 text-blue-500" />
|
||||
) : col.visible ? (
|
||||
<Eye className="h-4 w-4 shrink-0 text-primary" />
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
|
|
@ -180,8 +232,15 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
|||
|
||||
{/* 컬럼명 */}
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium sm:text-sm">
|
||||
{columnMeta?.columnLabel}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium sm:text-sm">
|
||||
{columnMeta?.columnLabel}
|
||||
</span>
|
||||
{isFrozen && (
|
||||
<span className="text-[10px] text-blue-600 dark:text-blue-400 font-medium">
|
||||
(고정)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground sm:text-xs">
|
||||
{col.columnName}
|
||||
|
|
@ -217,7 +276,7 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
|||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
onClick={onClose}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
|
|
|
|||
|
|
@ -137,3 +137,5 @@ export const useActiveTabOptional = () => {
|
|||
return useContext(ActiveTabContext);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -162,8 +162,8 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
// 현재 페이지 계산
|
||||
const currentPage = layoutConfig.pages.find((p) => p.page_id === currentPageId);
|
||||
|
||||
// 현재 페이지의 컴포넌트 (읽기 전용)
|
||||
const components = currentPage?.components || [];
|
||||
// 현재 페이지의 컴포넌트 (읽기 전용) - 배열인지 확인
|
||||
const components = Array.isArray(currentPage?.components) ? currentPage.components : [];
|
||||
|
||||
// currentPageId를 ref로 저장하여 클로저 문제 해결
|
||||
const currentPageIdRef = useRef<string | null>(currentPageId);
|
||||
|
|
|
|||
|
|
@ -43,25 +43,24 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
|
|||
|
||||
/**
|
||||
* 테이블 등록 해제
|
||||
* 주의:
|
||||
* 1. selectedTableId를 의존성으로 사용하면 무한 루프 발생 가능
|
||||
* 2. 재등록 시에도 unregister가 호출되므로 selectedTableId를 변경하면 안됨
|
||||
*/
|
||||
const unregisterTable = useCallback(
|
||||
(tableId: string) => {
|
||||
setRegisteredTables((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const removed = newMap.delete(tableId);
|
||||
|
||||
if (removed) {
|
||||
// 선택된 테이블이 제거되면 첫 번째 테이블 선택
|
||||
if (selectedTableId === tableId) {
|
||||
const firstTableId = newMap.keys().next().value;
|
||||
setSelectedTableId(firstTableId || null);
|
||||
}
|
||||
}
|
||||
|
||||
newMap.delete(tableId);
|
||||
return newMap;
|
||||
});
|
||||
|
||||
// 🚫 selectedTableId를 변경하지 않음
|
||||
// 이유: useEffect 재실행 시 cleanup → register 순서로 호출되는데,
|
||||
// cleanup에서 selectedTableId를 null로 만들면 필터 설정이 초기화됨
|
||||
// 다른 테이블이 선택되어야 하면 TableSearchWidget에서 자동 선택함
|
||||
},
|
||||
[selectedTableId]
|
||||
[] // 의존성 없음 - 무한 루프 방지
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -194,3 +194,5 @@ export function applyAutoFillToFormData(
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
* });
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { CascadingDropdownConfig } from "@/types/screen-management";
|
||||
|
||||
|
|
@ -38,12 +38,16 @@ export interface CascadingOption {
|
|||
export interface UseCascadingDropdownProps {
|
||||
/** 🆕 연쇄 관계 코드 (관계 관리에서 정의한 코드) */
|
||||
relationCode?: string;
|
||||
/** 🆕 카테고리 값 연쇄 관계 코드 (카테고리 값 연쇄관계용) */
|
||||
categoryRelationCode?: string;
|
||||
/** 🆕 역할: parent(부모-전체 옵션) 또는 child(자식-필터링된 옵션) */
|
||||
role?: "parent" | "child";
|
||||
/** @deprecated 직접 설정 방식 - relationCode 사용 권장 */
|
||||
config?: CascadingDropdownConfig;
|
||||
/** 부모 필드의 현재 값 (자식 역할일 때 필요) */
|
||||
/** 부모 필드의 현재 값 (자식 역할일 때 필요) - 단일 값 또는 배열(다중 선택) */
|
||||
parentValue?: string | number | null;
|
||||
/** 🆕 다중 부모값 (배열) - parentValue보다 우선 */
|
||||
parentValues?: (string | number)[];
|
||||
/** 초기 옵션 (캐시된 데이터가 있을 경우) */
|
||||
initialOptions?: CascadingOption[];
|
||||
}
|
||||
|
|
@ -71,9 +75,11 @@ const CACHE_TTL = 5 * 60 * 1000; // 5분
|
|||
|
||||
export function useCascadingDropdown({
|
||||
relationCode,
|
||||
categoryRelationCode,
|
||||
role = "child", // 기본값은 자식 역할 (기존 동작 유지)
|
||||
config,
|
||||
parentValue,
|
||||
parentValues,
|
||||
initialOptions = [],
|
||||
}: UseCascadingDropdownProps): UseCascadingDropdownResult {
|
||||
const [options, setOptions] = useState<CascadingOption[]>(initialOptions);
|
||||
|
|
@ -85,25 +91,50 @@ export function useCascadingDropdown({
|
|||
const prevParentValueRef = useRef<string | number | null | undefined>(undefined);
|
||||
|
||||
// 관계 코드 또는 직접 설정 중 하나라도 있는지 확인
|
||||
const isEnabled = !!relationCode || config?.enabled;
|
||||
const isEnabled = !!relationCode || !!categoryRelationCode || config?.enabled;
|
||||
|
||||
// 유효한 부모값 배열 계산 (다중 또는 단일) - 메모이제이션으로 불필요한 리렌더 방지
|
||||
const effectiveParentValues: string[] = useMemo(() => {
|
||||
if (parentValues && parentValues.length > 0) {
|
||||
return parentValues.map(v => String(v));
|
||||
}
|
||||
if (parentValue !== null && parentValue !== undefined) {
|
||||
return [String(parentValue)];
|
||||
}
|
||||
return [];
|
||||
}, [parentValues, parentValue]);
|
||||
|
||||
// 부모값 배열의 문자열 키 (의존성 비교용)
|
||||
const parentValuesKey = useMemo(() => JSON.stringify(effectiveParentValues), [effectiveParentValues]);
|
||||
|
||||
// 캐시 키 생성
|
||||
const getCacheKey = useCallback(() => {
|
||||
if (categoryRelationCode) {
|
||||
// 카테고리 값 연쇄관계
|
||||
if (role === "parent") {
|
||||
return `category-value:${categoryRelationCode}:parent:all`;
|
||||
}
|
||||
if (effectiveParentValues.length === 0) return null;
|
||||
const sortedValues = [...effectiveParentValues].sort().join(',');
|
||||
return `category-value:${categoryRelationCode}:child:${sortedValues}`;
|
||||
}
|
||||
if (relationCode) {
|
||||
// 부모 역할: 전체 옵션 캐시
|
||||
if (role === "parent") {
|
||||
return `relation:${relationCode}:parent:all`;
|
||||
}
|
||||
// 자식 역할: 부모 값별 캐시
|
||||
if (!parentValue) return null;
|
||||
return `relation:${relationCode}:child:${parentValue}`;
|
||||
// 자식 역할: 부모 값별 캐시 (다중 부모값 지원)
|
||||
if (effectiveParentValues.length === 0) return null;
|
||||
const sortedValues = [...effectiveParentValues].sort().join(',');
|
||||
return `relation:${relationCode}:child:${sortedValues}`;
|
||||
}
|
||||
if (config) {
|
||||
if (!parentValue) return null;
|
||||
return `${config.sourceTable}:${config.parentKeyColumn}:${parentValue}`;
|
||||
if (effectiveParentValues.length === 0) return null;
|
||||
const sortedValues = [...effectiveParentValues].sort().join(',');
|
||||
return `${config.sourceTable}:${config.parentKeyColumn}:${sortedValues}`;
|
||||
}
|
||||
return null;
|
||||
}, [relationCode, role, config, parentValue]);
|
||||
}, [categoryRelationCode, relationCode, role, config, effectiveParentValues]);
|
||||
|
||||
// 🆕 부모 역할 옵션 로드 (관계의 parent_table에서 전체 옵션 로드)
|
||||
const loadParentOptions = useCallback(async () => {
|
||||
|
|
@ -158,9 +189,9 @@ export function useCascadingDropdown({
|
|||
}
|
||||
}, [relationCode, getCacheKey]);
|
||||
|
||||
// 자식 역할 옵션 로드 (관계 코드 방식)
|
||||
// 자식 역할 옵션 로드 (관계 코드 방식) - 다중 부모값 지원
|
||||
const loadChildOptions = useCallback(async () => {
|
||||
if (!relationCode || !parentValue) {
|
||||
if (!relationCode || effectiveParentValues.length === 0) {
|
||||
setOptions([]);
|
||||
return;
|
||||
}
|
||||
|
|
@ -180,8 +211,18 @@ export function useCascadingDropdown({
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
// 관계 코드로 옵션 조회 API 호출 (자식 역할 - 필터링된 옵션)
|
||||
const response = await apiClient.get(`/cascading-relations/options/${relationCode}?parentValue=${encodeURIComponent(String(parentValue))}`);
|
||||
// 다중 부모값 지원: parentValues 파라미터 사용
|
||||
let url: string;
|
||||
if (effectiveParentValues.length === 1) {
|
||||
// 단일 값 (기존 호환)
|
||||
url = `/cascading-relations/options/${relationCode}?parentValue=${encodeURIComponent(effectiveParentValues[0])}`;
|
||||
} else {
|
||||
// 다중 값
|
||||
const parentValuesParam = effectiveParentValues.join(',');
|
||||
url = `/cascading-relations/options/${relationCode}?parentValues=${encodeURIComponent(parentValuesParam)}`;
|
||||
}
|
||||
|
||||
const response = await apiClient.get(url);
|
||||
|
||||
if (response.data?.success) {
|
||||
const loadedOptions: CascadingOption[] = response.data.data || [];
|
||||
|
|
@ -195,9 +236,9 @@ export function useCascadingDropdown({
|
|||
});
|
||||
}
|
||||
|
||||
console.log("✅ Child options 로드 완료:", {
|
||||
console.log("✅ Child options 로드 완료 (다중 부모값 지원):", {
|
||||
relationCode,
|
||||
parentValue,
|
||||
parentValues: effectiveParentValues,
|
||||
count: loadedOptions.length,
|
||||
});
|
||||
} else {
|
||||
|
|
@ -210,7 +251,121 @@ export function useCascadingDropdown({
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [relationCode, parentValue, getCacheKey]);
|
||||
}, [relationCode, effectiveParentValues, getCacheKey]);
|
||||
|
||||
// 🆕 카테고리 값 연쇄관계 - 부모 옵션 로드
|
||||
const loadCategoryParentOptions = useCallback(async () => {
|
||||
if (!categoryRelationCode) {
|
||||
setOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = getCacheKey();
|
||||
|
||||
// 캐시 확인
|
||||
if (cacheKey) {
|
||||
const cached = optionsCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||
setOptions(cached.options);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/category-value-cascading/parent-options/${categoryRelationCode}`);
|
||||
|
||||
if (response.data?.success) {
|
||||
const loadedOptions: CascadingOption[] = response.data.data || [];
|
||||
setOptions(loadedOptions);
|
||||
|
||||
// 캐시 저장
|
||||
if (cacheKey) {
|
||||
optionsCache.set(cacheKey, {
|
||||
options: loadedOptions,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
console.log("✅ Category parent options 로드 완료:", {
|
||||
categoryRelationCode,
|
||||
count: loadedOptions.length,
|
||||
});
|
||||
} else {
|
||||
throw new Error(response.data?.message || "옵션 로드 실패");
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("❌ Category parent options 로드 실패:", err);
|
||||
setError(err.message || "옵션을 불러오는 데 실패했습니다.");
|
||||
setOptions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [categoryRelationCode, getCacheKey]);
|
||||
|
||||
// 🆕 카테고리 값 연쇄관계 - 자식 옵션 로드 (다중 부모값 지원)
|
||||
const loadCategoryChildOptions = useCallback(async () => {
|
||||
if (!categoryRelationCode || effectiveParentValues.length === 0) {
|
||||
setOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = getCacheKey();
|
||||
|
||||
// 캐시 확인
|
||||
if (cacheKey) {
|
||||
const cached = optionsCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||
setOptions(cached.options);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 다중 부모값 지원
|
||||
let url: string;
|
||||
if (effectiveParentValues.length === 1) {
|
||||
url = `/category-value-cascading/options/${categoryRelationCode}?parentValue=${encodeURIComponent(effectiveParentValues[0])}`;
|
||||
} else {
|
||||
const parentValuesParam = effectiveParentValues.join(',');
|
||||
url = `/category-value-cascading/options/${categoryRelationCode}?parentValues=${encodeURIComponent(parentValuesParam)}`;
|
||||
}
|
||||
|
||||
const response = await apiClient.get(url);
|
||||
|
||||
if (response.data?.success) {
|
||||
const loadedOptions: CascadingOption[] = response.data.data || [];
|
||||
setOptions(loadedOptions);
|
||||
|
||||
// 캐시 저장
|
||||
if (cacheKey) {
|
||||
optionsCache.set(cacheKey, {
|
||||
options: loadedOptions,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
console.log("✅ Category child options 로드 완료 (다중 부모값 지원):", {
|
||||
categoryRelationCode,
|
||||
parentValues: effectiveParentValues,
|
||||
count: loadedOptions.length,
|
||||
});
|
||||
} else {
|
||||
throw new Error(response.data?.message || "옵션 로드 실패");
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("❌ Category child options 로드 실패:", err);
|
||||
setError(err.message || "옵션을 불러오는 데 실패했습니다.");
|
||||
setOptions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [categoryRelationCode, effectiveParentValues, getCacheKey]);
|
||||
|
||||
// 옵션 로드 (직접 설정 방식 - 레거시)
|
||||
const loadOptionsByConfig = useCallback(async () => {
|
||||
|
|
@ -279,7 +434,14 @@ export function useCascadingDropdown({
|
|||
|
||||
// 통합 로드 함수
|
||||
const loadOptions = useCallback(() => {
|
||||
if (relationCode) {
|
||||
// 카테고리 값 연쇄관계 우선
|
||||
if (categoryRelationCode) {
|
||||
if (role === "parent") {
|
||||
loadCategoryParentOptions();
|
||||
} else {
|
||||
loadCategoryChildOptions();
|
||||
}
|
||||
} else if (relationCode) {
|
||||
// 역할에 따라 다른 로드 함수 호출
|
||||
if (role === "parent") {
|
||||
loadParentOptions();
|
||||
|
|
@ -291,7 +453,7 @@ export function useCascadingDropdown({
|
|||
} else {
|
||||
setOptions([]);
|
||||
}
|
||||
}, [relationCode, role, config?.enabled, loadParentOptions, loadChildOptions, loadOptionsByConfig]);
|
||||
}, [categoryRelationCode, relationCode, role, config?.enabled, loadCategoryParentOptions, loadCategoryChildOptions, loadParentOptions, loadChildOptions, loadOptionsByConfig]);
|
||||
|
||||
// 옵션 로드 트리거
|
||||
useEffect(() => {
|
||||
|
|
@ -300,24 +462,28 @@ export function useCascadingDropdown({
|
|||
return;
|
||||
}
|
||||
|
||||
// 부모 역할: 즉시 전체 옵션 로드
|
||||
// 부모 역할: 즉시 전체 옵션 로드 (최초 1회만)
|
||||
if (role === "parent") {
|
||||
loadOptions();
|
||||
return;
|
||||
}
|
||||
|
||||
// 자식 역할: 부모 값이 있을 때만 로드
|
||||
// 부모 값이 변경되었는지 확인
|
||||
const parentChanged = prevParentValueRef.current !== parentValue;
|
||||
prevParentValueRef.current = parentValue;
|
||||
// 부모 값 배열의 변경 감지를 위해 JSON 문자열 비교
|
||||
const prevParentKey = prevParentValueRef.current;
|
||||
|
||||
if (parentValue) {
|
||||
loadOptions();
|
||||
} else {
|
||||
// 부모 값이 없으면 옵션 초기화
|
||||
setOptions([]);
|
||||
if (prevParentKey !== parentValuesKey) {
|
||||
prevParentValueRef.current = parentValuesKey as any;
|
||||
|
||||
if (effectiveParentValues.length > 0) {
|
||||
loadOptions();
|
||||
} else {
|
||||
// 부모 값이 없으면 옵션 초기화
|
||||
setOptions([]);
|
||||
}
|
||||
}
|
||||
}, [isEnabled, role, parentValue, loadOptions]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isEnabled, role, parentValuesKey]);
|
||||
|
||||
// 옵션 새로고침
|
||||
const refresh = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export interface CascadingRelationUpdateInput extends Partial<CascadingRelationC
|
|||
export interface CascadingOption {
|
||||
value: string;
|
||||
label: string;
|
||||
parent_value?: string; // 다중 부모 선택 시 어떤 부모에 속하는지 구분용
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -99,10 +100,28 @@ export const getCascadingRelationByCode = async (code: string) => {
|
|||
|
||||
/**
|
||||
* 연쇄 관계로 자식 옵션 조회
|
||||
* 단일 부모값 또는 다중 부모값 지원
|
||||
*/
|
||||
export const getCascadingOptions = async (code: string, parentValue: string): Promise<{ success: boolean; data?: CascadingOption[]; error?: string }> => {
|
||||
export const getCascadingOptions = async (
|
||||
code: string,
|
||||
parentValue: string | string[]
|
||||
): Promise<{ success: boolean; data?: CascadingOption[]; error?: string }> => {
|
||||
try {
|
||||
const response = await apiClient.get(`/cascading-relations/options/${code}?parentValue=${encodeURIComponent(parentValue)}`);
|
||||
let url: string;
|
||||
|
||||
if (Array.isArray(parentValue)) {
|
||||
// 다중 부모값: parentValues 파라미터 사용
|
||||
if (parentValue.length === 0) {
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
const parentValuesParam = parentValue.join(',');
|
||||
url = `/cascading-relations/options/${code}?parentValues=${encodeURIComponent(parentValuesParam)}`;
|
||||
} else {
|
||||
// 단일 부모값: 기존 호환
|
||||
url = `/cascading-relations/options/${code}?parentValue=${encodeURIComponent(parentValue)}`;
|
||||
}
|
||||
|
||||
const response = await apiClient.get(url);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("연쇄 옵션 조회 실패:", error);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,255 @@
|
|||
import { apiClient } from "./client";
|
||||
|
||||
// ============================================
|
||||
// 타입 정의
|
||||
// ============================================
|
||||
|
||||
export interface CategoryValueCascadingGroup {
|
||||
group_id: number;
|
||||
relation_code: string;
|
||||
relation_name: string;
|
||||
description?: string;
|
||||
parent_table_name: string;
|
||||
parent_column_name: string;
|
||||
parent_menu_objid?: number;
|
||||
child_table_name: string;
|
||||
child_column_name: string;
|
||||
child_menu_objid?: number;
|
||||
clear_on_parent_change?: string;
|
||||
show_group_label?: string;
|
||||
empty_parent_message?: string;
|
||||
no_options_message?: string;
|
||||
company_code: string;
|
||||
is_active?: string;
|
||||
created_by?: string;
|
||||
created_date?: string;
|
||||
updated_by?: string;
|
||||
updated_date?: string;
|
||||
// 상세 조회 시 포함
|
||||
mappings?: CategoryValueCascadingMapping[];
|
||||
mappingsByParent?: Record<string, { childValueCode: string; childValueLabel: string; displayOrder: number }[]>;
|
||||
}
|
||||
|
||||
export interface CategoryValueCascadingMapping {
|
||||
mapping_id?: number;
|
||||
parent_value_code: string;
|
||||
parent_value_label?: string;
|
||||
child_value_code: string;
|
||||
child_value_label?: string;
|
||||
display_order?: number;
|
||||
}
|
||||
|
||||
export interface CategoryValueCascadingGroupInput {
|
||||
relationCode: string;
|
||||
relationName: string;
|
||||
description?: string;
|
||||
parentTableName: string;
|
||||
parentColumnName: string;
|
||||
parentMenuObjid?: number;
|
||||
childTableName: string;
|
||||
childColumnName: string;
|
||||
childMenuObjid?: number;
|
||||
clearOnParentChange?: boolean;
|
||||
showGroupLabel?: boolean;
|
||||
emptyParentMessage?: string;
|
||||
noOptionsMessage?: string;
|
||||
}
|
||||
|
||||
export interface CategoryValueCascadingMappingInput {
|
||||
parentValueCode: string;
|
||||
parentValueLabel?: string;
|
||||
childValueCode: string;
|
||||
childValueLabel?: string;
|
||||
displayOrder?: number;
|
||||
}
|
||||
|
||||
export interface CategoryValueCascadingOption {
|
||||
value: string;
|
||||
label: string;
|
||||
parent_value?: string;
|
||||
parent_label?: string;
|
||||
display_order?: number;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API 함수
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 카테고리 값 연쇄관계 그룹 목록 조회
|
||||
*/
|
||||
export const getCategoryValueCascadingGroups = async (isActive?: string) => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (isActive !== undefined) {
|
||||
params.append("isActive", isActive);
|
||||
}
|
||||
const response = await apiClient.get(`/category-value-cascading/groups?${params.toString()}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("카테고리 값 연쇄관계 그룹 목록 조회 실패:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리 값 연쇄관계 그룹 상세 조회
|
||||
*/
|
||||
export const getCategoryValueCascadingGroupById = async (groupId: number) => {
|
||||
try {
|
||||
const response = await apiClient.get(`/category-value-cascading/groups/${groupId}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("카테고리 값 연쇄관계 그룹 상세 조회 실패:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 관계 코드로 조회
|
||||
*/
|
||||
export const getCategoryValueCascadingByCode = async (code: string) => {
|
||||
try {
|
||||
const response = await apiClient.get(`/category-value-cascading/code/${code}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("카테고리 값 연쇄관계 코드 조회 실패:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리 값 연쇄관계 그룹 생성
|
||||
*/
|
||||
export const createCategoryValueCascadingGroup = async (data: CategoryValueCascadingGroupInput) => {
|
||||
try {
|
||||
const response = await apiClient.post("/category-value-cascading/groups", data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("카테고리 값 연쇄관계 그룹 생성 실패:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리 값 연쇄관계 그룹 수정
|
||||
*/
|
||||
export const updateCategoryValueCascadingGroup = async (
|
||||
groupId: number,
|
||||
data: Partial<CategoryValueCascadingGroupInput> & { isActive?: boolean }
|
||||
) => {
|
||||
try {
|
||||
const response = await apiClient.put(`/category-value-cascading/groups/${groupId}`, data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("카테고리 값 연쇄관계 그룹 수정 실패:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리 값 연쇄관계 그룹 삭제
|
||||
*/
|
||||
export const deleteCategoryValueCascadingGroup = async (groupId: number) => {
|
||||
try {
|
||||
const response = await apiClient.delete(`/category-value-cascading/groups/${groupId}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("카테고리 값 연쇄관계 그룹 삭제 실패:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 매핑 일괄 저장
|
||||
*/
|
||||
export const saveCategoryValueCascadingMappings = async (
|
||||
groupId: number,
|
||||
mappings: CategoryValueCascadingMappingInput[]
|
||||
) => {
|
||||
try {
|
||||
const response = await apiClient.post(`/category-value-cascading/groups/${groupId}/mappings`, { mappings });
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("카테고리 값 연쇄관계 매핑 저장 실패:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 연쇄 옵션 조회 (실제 드롭다운에서 사용)
|
||||
* 다중 부모값 지원
|
||||
*/
|
||||
export const getCategoryValueCascadingOptions = async (
|
||||
code: string,
|
||||
parentValue: string | string[]
|
||||
): Promise<{ success: boolean; data?: CategoryValueCascadingOption[]; showGroupLabel?: boolean; error?: string }> => {
|
||||
try {
|
||||
let url: string;
|
||||
|
||||
if (Array.isArray(parentValue)) {
|
||||
if (parentValue.length === 0) {
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
const parentValuesParam = parentValue.join(',');
|
||||
url = `/category-value-cascading/options/${code}?parentValues=${encodeURIComponent(parentValuesParam)}`;
|
||||
} else {
|
||||
url = `/category-value-cascading/options/${code}?parentValue=${encodeURIComponent(parentValue)}`;
|
||||
}
|
||||
|
||||
const response = await apiClient.get(url);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("카테고리 값 연쇄 옵션 조회 실패:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 부모 카테고리 값 목록 조회
|
||||
*/
|
||||
export const getCategoryValueCascadingParentOptions = async (code: string) => {
|
||||
try {
|
||||
const response = await apiClient.get(`/category-value-cascading/parent-options/${code}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("부모 카테고리 값 조회 실패:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 자식 카테고리 값 목록 조회 (매핑 설정 UI용)
|
||||
*/
|
||||
export const getCategoryValueCascadingChildOptions = async (code: string) => {
|
||||
try {
|
||||
const response = await apiClient.get(`/category-value-cascading/child-options/${code}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("자식 카테고리 값 조회 실패:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// API 객체 export
|
||||
// ============================================
|
||||
|
||||
export const categoryValueCascadingApi = {
|
||||
// 그룹 CRUD
|
||||
getGroups: getCategoryValueCascadingGroups,
|
||||
getGroupById: getCategoryValueCascadingGroupById,
|
||||
getByCode: getCategoryValueCascadingByCode,
|
||||
createGroup: createCategoryValueCascadingGroup,
|
||||
updateGroup: updateCategoryValueCascadingGroup,
|
||||
deleteGroup: deleteCategoryValueCascadingGroup,
|
||||
|
||||
// 매핑
|
||||
saveMappings: saveCategoryValueCascadingMappings,
|
||||
|
||||
// 옵션 조회
|
||||
getOptions: getCategoryValueCascadingOptions,
|
||||
getParentOptions: getCategoryValueCascadingParentOptions,
|
||||
getChildOptions: getCategoryValueCascadingChildOptions,
|
||||
};
|
||||
|
||||
|
|
@ -163,13 +163,20 @@ export const menuApi = {
|
|||
}
|
||||
},
|
||||
|
||||
// 메뉴 복사
|
||||
// 메뉴 복사 (타임아웃 5분 - 대량 데이터 처리)
|
||||
copyMenu: async (
|
||||
menuObjid: number,
|
||||
targetCompanyCode: string,
|
||||
screenNameConfig?: {
|
||||
removeText?: string;
|
||||
addPrefix?: string;
|
||||
},
|
||||
additionalCopyOptions?: {
|
||||
copyCodeCategory?: boolean;
|
||||
copyNumberingRules?: boolean;
|
||||
copyCategoryMapping?: boolean;
|
||||
copyTableTypeColumns?: boolean;
|
||||
copyCascadingRelation?: boolean;
|
||||
}
|
||||
): Promise<ApiResponse<MenuCopyResult>> => {
|
||||
try {
|
||||
|
|
@ -177,12 +184,26 @@ export const menuApi = {
|
|||
`/admin/menus/${menuObjid}/copy`,
|
||||
{
|
||||
targetCompanyCode,
|
||||
screenNameConfig
|
||||
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 || "메뉴 복사 중 오류가 발생했습니다",
|
||||
|
|
@ -199,6 +220,12 @@ export interface MenuCopyResult {
|
|||
copiedMenus: number;
|
||||
copiedScreens: number;
|
||||
copiedFlows: number;
|
||||
copiedCodeCategories?: number;
|
||||
copiedCodes?: number;
|
||||
copiedNumberingRules?: number;
|
||||
copiedCategoryMappings?: number;
|
||||
copiedTableTypeColumns?: number;
|
||||
copiedCascadingRelations?: number;
|
||||
menuIdMap: Record<number, number>;
|
||||
screenIdMap: Record<number, number>;
|
||||
flowIdMap: Record<number, number>;
|
||||
|
|
|
|||
|
|
@ -60,6 +60,9 @@ export function useEntityJoinOptimization(columnMeta: Record<string, ColumnMetaI
|
|||
// 변환된 값 캐시 (중복 변환 방지)
|
||||
const convertedCache = useRef(new Map<string, string>());
|
||||
|
||||
// 초기화 완료 플래그 (무한 루프 방지)
|
||||
const initialLoadDone = useRef(false);
|
||||
|
||||
// 공통 코드 카테고리 추출 (메모이제이션)
|
||||
const codeCategories = useMemo(() => {
|
||||
return Object.values(columnMeta)
|
||||
|
|
@ -293,24 +296,40 @@ export function useEntityJoinOptimization(columnMeta: Record<string, ColumnMetaI
|
|||
[codeCategories, batchLoadCodes, updateMetrics],
|
||||
);
|
||||
|
||||
// 초기화 시 공통 코드 프리로딩
|
||||
// 초기화 시 공통 코드 프리로딩 (한 번만 실행)
|
||||
useEffect(() => {
|
||||
// 이미 초기화되었으면 스킵 (무한 루프 방지)
|
||||
if (initialLoadDone.current) return;
|
||||
initialLoadDone.current = true;
|
||||
|
||||
preloadCommonCodesOnMount();
|
||||
}, [preloadCommonCodesOnMount]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 컬럼 메타 변경 시 필요한 코드 추가 로딩
|
||||
// 이미 로딩 중이면 스킵하여 무한 루프 방지
|
||||
const loadedCategoriesRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
// 이미 최적화 중이거나 초기화 전이면 스킵
|
||||
if (isOptimizing) return;
|
||||
|
||||
if (codeCategories.length > 0) {
|
||||
const unloadedCategories = codeCategories.filter((category) => {
|
||||
// 이미 로드 요청을 보낸 카테고리는 스킵
|
||||
if (loadedCategoriesRef.current.has(category)) return false;
|
||||
return codeCache.getCodeSync(category) === null;
|
||||
});
|
||||
|
||||
if (unloadedCategories.length > 0) {
|
||||
// 로딩 요청 카테고리 기록
|
||||
unloadedCategories.forEach(cat => loadedCategoriesRef.current.add(cat));
|
||||
console.log(`🔄 새로운 코드 카테고리 감지, 추가 로딩: ${unloadedCategories.join(", ")}`);
|
||||
batchLoadCodes(unloadedCategories);
|
||||
}
|
||||
}
|
||||
}, [codeCategories, batchLoadCodes]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [codeCategories.join(",")]); // 배열 내용 기반 의존성
|
||||
|
||||
// 주기적으로 메트릭 업데이트
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -416,6 +416,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
// originalData를 사용 (최초 전달된 값, formData는 계속 변경되므로 사용하면 안됨)
|
||||
_initialData: originalData || formData,
|
||||
_originalData: originalData,
|
||||
// 🆕 탭 관련 정보 전달 (탭 내부의 테이블 컴포넌트에서 사용)
|
||||
parentTabId: props.parentTabId,
|
||||
parentTabsComponentId: props.parentTabsComponentId,
|
||||
};
|
||||
|
||||
// 렌더러가 클래스인지 함수인지 확인
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -156,23 +156,49 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
|
||||
// 🆕 연쇄 드롭다운 설정 확인
|
||||
const cascadingRelationCode = config?.cascadingRelationCode || componentConfig?.cascadingRelationCode;
|
||||
// 🆕 카테고리 값 연쇄관계 설정
|
||||
const categoryRelationCode = config?.categoryRelationCode || componentConfig?.categoryRelationCode;
|
||||
const cascadingRole = config?.cascadingRole || componentConfig?.cascadingRole || "child";
|
||||
const cascadingParentField = config?.cascadingParentField || componentConfig?.cascadingParentField;
|
||||
// 자식 역할일 때만 부모 값 필요
|
||||
const parentValue = cascadingRole === "child" && cascadingParentField && formData
|
||||
|
||||
// 🆕 자식 역할일 때 부모 값 추출 (단일 또는 다중)
|
||||
const rawParentValue = cascadingRole === "child" && cascadingParentField && formData
|
||||
? formData[cascadingParentField]
|
||||
: undefined;
|
||||
|
||||
// 🆕 연쇄 드롭다운 훅 사용 (역할에 따라 다른 옵션 로드)
|
||||
// 🆕 부모값이 콤마로 구분된 문자열이면 배열로 변환 (다중 선택 지원)
|
||||
const parentValues: string[] | undefined = useMemo(() => {
|
||||
if (!rawParentValue) return undefined;
|
||||
|
||||
// 이미 배열인 경우
|
||||
if (Array.isArray(rawParentValue)) {
|
||||
return rawParentValue.map(v => String(v)).filter(v => v);
|
||||
}
|
||||
|
||||
// 콤마로 구분된 문자열인 경우
|
||||
const strValue = String(rawParentValue);
|
||||
if (strValue.includes(',')) {
|
||||
return strValue.split(',').map(v => v.trim()).filter(v => v);
|
||||
}
|
||||
|
||||
// 단일 값
|
||||
return [strValue];
|
||||
}, [rawParentValue]);
|
||||
|
||||
// 🆕 연쇄 드롭다운 훅 사용 (역할에 따라 다른 옵션 로드) - 다중 부모값 지원
|
||||
const {
|
||||
options: cascadingOptions,
|
||||
loading: isLoadingCascading,
|
||||
} = useCascadingDropdown({
|
||||
relationCode: cascadingRelationCode,
|
||||
categoryRelationCode: categoryRelationCode, // 🆕 카테고리 값 연쇄관계 지원
|
||||
role: cascadingRole, // 부모/자식 역할 전달
|
||||
parentValue: parentValue,
|
||||
parentValues: parentValues, // 다중 부모값
|
||||
});
|
||||
|
||||
// 🆕 카테고리 값 연쇄관계가 활성화되었는지 확인
|
||||
const hasCategoryRelation = !!categoryRelationCode;
|
||||
|
||||
useEffect(() => {
|
||||
if (webType === "category" && component.tableName && component.columnName) {
|
||||
console.log("🔍 [SelectBasic] 카테고리 값 로딩 시작:", {
|
||||
|
|
@ -324,6 +350,10 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
// 선택된 값에 따른 라벨 업데이트
|
||||
useEffect(() => {
|
||||
const getAllOptionsForLabel = () => {
|
||||
// 🆕 카테고리 값 연쇄관계가 설정된 경우 연쇄 옵션 사용
|
||||
if (categoryRelationCode) {
|
||||
return cascadingOptions;
|
||||
}
|
||||
// 🆕 연쇄 드롭다운이 설정된 경우 연쇄 옵션만 사용
|
||||
if (cascadingRelationCode) {
|
||||
return cascadingOptions;
|
||||
|
|
@ -353,7 +383,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
if (newLabel !== selectedLabel) {
|
||||
setSelectedLabel(newLabel);
|
||||
}
|
||||
}, [selectedValue, codeOptions, config.options, cascadingOptions, cascadingRelationCode]);
|
||||
}, [selectedValue, codeOptions, config.options, cascadingOptions, cascadingRelationCode, categoryRelationCode]);
|
||||
|
||||
// 클릭 이벤트 핸들러 (React Query로 간소화)
|
||||
const handleToggle = () => {
|
||||
|
|
@ -404,6 +434,10 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
|
||||
// 모든 옵션 가져오기
|
||||
const getAllOptions = () => {
|
||||
// 🆕 카테고리 값 연쇄관계가 설정된 경우 연쇄 옵션 사용
|
||||
if (categoryRelationCode) {
|
||||
return cascadingOptions;
|
||||
}
|
||||
// 🆕 연쇄 드롭다운이 설정된 경우 연쇄 옵션만 사용
|
||||
if (cascadingRelationCode) {
|
||||
return cascadingOptions;
|
||||
|
|
@ -776,50 +810,121 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
{(isLoadingCodes || isLoadingCategories) ? (
|
||||
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
||||
) : allOptions.length > 0 ? (
|
||||
allOptions.map((option, index) => {
|
||||
const isOptionSelected = selectedValues.includes(option.value);
|
||||
return (
|
||||
<div
|
||||
key={`${option.value}-${index}`}
|
||||
className={cn(
|
||||
"cursor-pointer px-3 py-2 text-gray-900 hover:bg-gray-100",
|
||||
isOptionSelected && "bg-blue-50 font-medium"
|
||||
)}
|
||||
onClick={() => {
|
||||
const newVals = isOptionSelected
|
||||
? selectedValues.filter((v) => v !== option.value)
|
||||
: [...selectedValues, option.value];
|
||||
setSelectedValues(newVals);
|
||||
const newValue = newVals.join(",");
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, newValue);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isOptionSelected}
|
||||
value={option.value}
|
||||
onChange={(e) => {
|
||||
// 체크박스 직접 클릭 시에도 올바른 값으로 처리
|
||||
e.stopPropagation();
|
||||
const newVals = isOptionSelected
|
||||
? selectedValues.filter((v) => v !== option.value)
|
||||
: [...selectedValues, option.value];
|
||||
setSelectedValues(newVals);
|
||||
const newValue = newVals.join(",");
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, newValue);
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 pointer-events-auto"
|
||||
/>
|
||||
<span>{option.label || option.value}</span>
|
||||
(() => {
|
||||
// 부모별 그룹핑 (카테고리 연쇄관계인 경우)
|
||||
const hasParentInfo = allOptions.some((opt: any) => opt.parent_label);
|
||||
|
||||
if (hasParentInfo) {
|
||||
// 부모별로 그룹핑
|
||||
const groupedOptions: Record<string, { parentLabel: string; options: typeof allOptions }> = {};
|
||||
allOptions.forEach((opt: any) => {
|
||||
const parentKey = opt.parent_value || "기타";
|
||||
const parentLabel = opt.parent_label || "기타";
|
||||
if (!groupedOptions[parentKey]) {
|
||||
groupedOptions[parentKey] = { parentLabel, options: [] };
|
||||
}
|
||||
groupedOptions[parentKey].options.push(opt);
|
||||
});
|
||||
|
||||
return Object.entries(groupedOptions).map(([parentKey, group]) => (
|
||||
<div key={parentKey}>
|
||||
{/* 그룹 헤더 */}
|
||||
<div className="sticky top-0 bg-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-600 border-b">
|
||||
{group.parentLabel}
|
||||
</div>
|
||||
{/* 그룹 옵션들 */}
|
||||
{group.options.map((option, index) => {
|
||||
const isOptionSelected = selectedValues.includes(option.value);
|
||||
return (
|
||||
<div
|
||||
key={`${option.value}-${index}`}
|
||||
className={cn(
|
||||
"cursor-pointer px-3 py-2 text-gray-900 hover:bg-gray-100",
|
||||
isOptionSelected && "bg-blue-50 font-medium"
|
||||
)}
|
||||
onClick={() => {
|
||||
const newVals = isOptionSelected
|
||||
? selectedValues.filter((v) => v !== option.value)
|
||||
: [...selectedValues, option.value];
|
||||
setSelectedValues(newVals);
|
||||
const newValue = newVals.join(",");
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, newValue);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isOptionSelected}
|
||||
value={option.value}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
const newVals = isOptionSelected
|
||||
? selectedValues.filter((v) => v !== option.value)
|
||||
: [...selectedValues, option.value];
|
||||
setSelectedValues(newVals);
|
||||
const newValue = newVals.join(",");
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, newValue);
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 pointer-events-auto"
|
||||
/>
|
||||
<span>{option.label || option.value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
// 부모 정보가 없으면 기존 방식
|
||||
return allOptions.map((option, index) => {
|
||||
const isOptionSelected = selectedValues.includes(option.value);
|
||||
return (
|
||||
<div
|
||||
key={`${option.value}-${index}`}
|
||||
className={cn(
|
||||
"cursor-pointer px-3 py-2 text-gray-900 hover:bg-gray-100",
|
||||
isOptionSelected && "bg-blue-50 font-medium"
|
||||
)}
|
||||
onClick={() => {
|
||||
const newVals = isOptionSelected
|
||||
? selectedValues.filter((v) => v !== option.value)
|
||||
: [...selectedValues, option.value];
|
||||
setSelectedValues(newVals);
|
||||
const newValue = newVals.join(",");
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, newValue);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isOptionSelected}
|
||||
value={option.value}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
const newVals = isOptionSelected
|
||||
? selectedValues.filter((v) => v !== option.value)
|
||||
: [...selectedValues, option.value];
|
||||
setSelectedValues(newVals);
|
||||
const newValue = newVals.join(",");
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, newValue);
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 pointer-events-auto"
|
||||
/>
|
||||
<span>{option.label || option.value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()
|
||||
) : (
|
||||
<div className="bg-white px-3 py-2 text-gray-900">옵션이 없습니다</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { Link2, ExternalLink } from "lucide-react";
|
|||
import Link from "next/link";
|
||||
import { SelectBasicConfig } from "./types";
|
||||
import { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation";
|
||||
import { categoryValueCascadingApi, CategoryValueCascadingGroup } from "@/lib/api/categoryValueCascading";
|
||||
|
||||
export interface SelectBasicConfigPanelProps {
|
||||
config: SelectBasicConfig;
|
||||
|
|
@ -36,6 +37,11 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
|
|||
const [relationList, setRelationList] = useState<CascadingRelation[]>([]);
|
||||
const [loadingRelations, setLoadingRelations] = useState(false);
|
||||
|
||||
// 🆕 카테고리 값 연쇄관계 상태
|
||||
const [categoryRelationEnabled, setCategoryRelationEnabled] = useState(!!(config as any).categoryRelationCode);
|
||||
const [categoryRelationList, setCategoryRelationList] = useState<CategoryValueCascadingGroup[]>([]);
|
||||
const [loadingCategoryRelations, setLoadingCategoryRelations] = useState(false);
|
||||
|
||||
// 연쇄 관계 목록 로드
|
||||
useEffect(() => {
|
||||
if (cascadingEnabled && relationList.length === 0) {
|
||||
|
|
@ -43,10 +49,18 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
|
|||
}
|
||||
}, [cascadingEnabled]);
|
||||
|
||||
// 🆕 카테고리 값 연쇄관계 목록 로드
|
||||
useEffect(() => {
|
||||
if (categoryRelationEnabled && categoryRelationList.length === 0) {
|
||||
loadCategoryRelationList();
|
||||
}
|
||||
}, [categoryRelationEnabled]);
|
||||
|
||||
// config 변경 시 상태 동기화
|
||||
useEffect(() => {
|
||||
setCascadingEnabled(!!config.cascadingRelationCode);
|
||||
}, [config.cascadingRelationCode]);
|
||||
setCategoryRelationEnabled(!!(config as any).categoryRelationCode);
|
||||
}, [config.cascadingRelationCode, (config as any).categoryRelationCode]);
|
||||
|
||||
const loadRelationList = async () => {
|
||||
setLoadingRelations(true);
|
||||
|
|
@ -62,6 +76,21 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// 🆕 카테고리 값 연쇄관계 목록 로드
|
||||
const loadCategoryRelationList = async () => {
|
||||
setLoadingCategoryRelations(true);
|
||||
try {
|
||||
const response = await categoryValueCascadingApi.getGroups("Y");
|
||||
if (response.success && response.data) {
|
||||
setCategoryRelationList(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 값 연쇄관계 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoadingCategoryRelations(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (key: keyof SelectBasicConfig, value: any) => {
|
||||
// 기존 config와 병합하여 전체 객체 전달 (다른 속성 보호)
|
||||
const newConfig = { ...config, [key]: value };
|
||||
|
|
@ -82,6 +111,33 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
|
|||
onChange(newConfig);
|
||||
} else {
|
||||
loadRelationList();
|
||||
// 카테고리 값 연쇄관계 비활성화 (둘 중 하나만 사용)
|
||||
if (categoryRelationEnabled) {
|
||||
setCategoryRelationEnabled(false);
|
||||
onChange({ ...config, categoryRelationCode: undefined } as any);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 🆕 카테고리 값 연쇄관계 토글
|
||||
const handleCategoryRelationToggle = (enabled: boolean) => {
|
||||
setCategoryRelationEnabled(enabled);
|
||||
if (!enabled) {
|
||||
// 비활성화 시 관계 설정 제거
|
||||
const newConfig = {
|
||||
...config,
|
||||
categoryRelationCode: undefined,
|
||||
cascadingRole: undefined,
|
||||
cascadingParentField: undefined,
|
||||
} as any;
|
||||
onChange(newConfig);
|
||||
} else {
|
||||
loadCategoryRelationList();
|
||||
// 일반 연쇄관계 비활성화 (둘 중 하나만 사용)
|
||||
if (cascadingEnabled) {
|
||||
setCascadingEnabled(false);
|
||||
onChange({ ...config, cascadingRelationCode: undefined });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -280,52 +336,56 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
|
|||
)}
|
||||
|
||||
{/* 부모 필드 설정 (자식 역할일 때만) */}
|
||||
{config.cascadingRelationCode && config.cascadingRole === "child" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">부모 필드명</Label>
|
||||
{(() => {
|
||||
const parentComp = findParentComponent(config.cascadingRelationCode);
|
||||
const isAutoDetected = parentComp && config.cascadingParentField === parentComp.columnName;
|
||||
{config.cascadingRelationCode && config.cascadingRole === "child" && (() => {
|
||||
// 선택된 관계에서 부모 값 컬럼 가져오기
|
||||
const expectedParentColumn = selectedRelation?.parent_value_column;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
value={config.cascadingParentField || ""}
|
||||
onChange={(e) => handleChange("cascadingParentField", e.target.value || undefined)}
|
||||
placeholder="예: warehouse_code"
|
||||
className="text-xs flex-1"
|
||||
/>
|
||||
{parentComp && !isAutoDetected && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs shrink-0"
|
||||
onClick={() => handleChange("cascadingParentField", parentComp.columnName)}
|
||||
>
|
||||
자동감지
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{isAutoDetected ? (
|
||||
<p className="text-xs text-green-600">
|
||||
자동 감지됨: {parentComp.label || parentComp.columnName}
|
||||
</p>
|
||||
) : parentComp ? (
|
||||
<p className="text-xs text-amber-600">
|
||||
감지된 부모 필드: {parentComp.columnName} ({parentComp.label || "라벨 없음"})
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
같은 관계의 부모 역할 필드가 없습니다. 수동으로 입력하세요.
|
||||
</p>
|
||||
// 부모 역할에 맞는 컴포넌트만 필터링
|
||||
const parentFieldCandidates = allComponents.filter((comp) => {
|
||||
// 현재 컴포넌트 제외
|
||||
if (currentComponent && comp.id === currentComponent.id) return false;
|
||||
// 관계에서 지정한 부모 컬럼명과 일치하는 컴포넌트만
|
||||
if (expectedParentColumn && comp.columnName !== expectedParentColumn) return false;
|
||||
// columnName이 있어야 함
|
||||
return !!comp.columnName;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">부모 필드 선택</Label>
|
||||
{expectedParentColumn && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
관계에서 지정된 부모 컬럼: <strong>{expectedParentColumn}</strong>
|
||||
</p>
|
||||
)}
|
||||
<Select
|
||||
value={config.cascadingParentField || ""}
|
||||
onValueChange={(value) => handleChange("cascadingParentField", value || undefined)}
|
||||
>
|
||||
<SelectTrigger className="text-xs">
|
||||
<SelectValue placeholder="부모 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{parentFieldCandidates.map((comp) => (
|
||||
<SelectItem key={comp.id} value={comp.columnName}>
|
||||
{comp.label || comp.columnName} ({comp.columnName})
|
||||
</SelectItem>
|
||||
))}
|
||||
{parentFieldCandidates.length === 0 && (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{expectedParentColumn
|
||||
? `'${expectedParentColumn}' 컬럼을 가진 컴포넌트가 화면에 없습니다`
|
||||
: "선택 가능한 부모 필드가 없습니다"}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
상위 값을 제공할 필드를 선택하세요.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 선택된 관계 정보 표시 */}
|
||||
{selectedRelation && config.cascadingRole && (
|
||||
|
|
@ -374,6 +434,152 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 🆕 카테고리 값 연쇄관계 설정 */}
|
||||
<div className="border-t pt-4 mt-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="h-4 w-4" />
|
||||
<Label className="text-sm font-medium">카테고리 값 연쇄</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={categoryRelationEnabled}
|
||||
onCheckedChange={handleCategoryRelationToggle}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
부모 카테고리 값 선택에 따라 자식 카테고리 옵션이 변경됩니다.
|
||||
<br />예: 검사유형 선택 시 해당 유형에 맞는 적용대상만 표시
|
||||
</p>
|
||||
|
||||
{categoryRelationEnabled && (
|
||||
<div className="space-y-3 rounded-md border p-3 bg-muted/30">
|
||||
{/* 관계 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">카테고리 값 연쇄 관계 선택</Label>
|
||||
<Select
|
||||
value={(config as any).categoryRelationCode || ""}
|
||||
onValueChange={(value) => handleChange("categoryRelationCode" as any, value || undefined)}
|
||||
>
|
||||
<SelectTrigger className="text-xs">
|
||||
<SelectValue placeholder={loadingCategoryRelations ? "로딩 중..." : "관계 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categoryRelationList.map((relation) => (
|
||||
<SelectItem key={relation.relation_code} value={relation.relation_code}>
|
||||
<div className="flex flex-col">
|
||||
<span>{relation.relation_name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{relation.parent_table_name}.{relation.parent_column_name} → {relation.child_table_name}.{relation.child_column_name}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 역할 선택 */}
|
||||
{(config as any).categoryRelationCode && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">역할 선택</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={config.cascadingRole === "parent" ? "default" : "outline"}
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => handleRoleChange("parent")}
|
||||
>
|
||||
부모 (상위 선택)
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={config.cascadingRole === "child" ? "default" : "outline"}
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => handleRoleChange("child")}
|
||||
>
|
||||
자식 (하위 선택)
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{config.cascadingRole === "parent"
|
||||
? "이 필드가 상위 카테고리 선택 역할을 합니다. (예: 검사유형)"
|
||||
: config.cascadingRole === "child"
|
||||
? "이 필드는 상위 카테고리 값에 따라 옵션이 변경됩니다. (예: 적용대상)"
|
||||
: "이 필드의 역할을 선택하세요."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 부모 필드 설정 (자식 역할일 때만) */}
|
||||
{(config as any).categoryRelationCode && config.cascadingRole === "child" && (() => {
|
||||
// 선택된 관계 정보 가져오기
|
||||
const selectedRelation = categoryRelationList.find(
|
||||
(r) => r.relation_code === (config as any).categoryRelationCode
|
||||
);
|
||||
const expectedParentColumn = selectedRelation?.parent_column_name;
|
||||
|
||||
// 부모 역할에 맞는 컴포넌트만 필터링
|
||||
const parentFieldCandidates = allComponents.filter((comp) => {
|
||||
// 현재 컴포넌트 제외
|
||||
if (currentComponent && comp.id === currentComponent.id) return false;
|
||||
// 관계에서 지정한 부모 컬럼명과 일치하는 컴포넌트만
|
||||
if (expectedParentColumn && comp.columnName !== expectedParentColumn) return false;
|
||||
// columnName이 있어야 함
|
||||
return !!comp.columnName;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">부모 필드 선택</Label>
|
||||
{expectedParentColumn && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
관계에서 지정된 부모 컬럼: <strong>{expectedParentColumn}</strong>
|
||||
</p>
|
||||
)}
|
||||
<Select
|
||||
value={config.cascadingParentField || ""}
|
||||
onValueChange={(value) => handleChange("cascadingParentField", value || undefined)}
|
||||
>
|
||||
<SelectTrigger className="text-xs">
|
||||
<SelectValue placeholder="부모 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{parentFieldCandidates.map((comp) => (
|
||||
<SelectItem key={comp.id} value={comp.columnName}>
|
||||
{comp.label || comp.columnName} ({comp.columnName})
|
||||
</SelectItem>
|
||||
))}
|
||||
{parentFieldCandidates.length === 0 && (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{expectedParentColumn
|
||||
? `'${expectedParentColumn}' 컬럼을 가진 컴포넌트가 화면에 없습니다`
|
||||
: "선택 가능한 부모 필드가 없습니다"}
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
상위 카테고리 값을 제공할 필드를 선택하세요.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 관계 관리 페이지 링크 */}
|
||||
<div className="flex justify-end">
|
||||
<Link href="/admin/cascading-management?tab=category-value" target="_blank">
|
||||
<Button variant="link" size="sm" className="h-auto p-0 text-xs">
|
||||
<ExternalLink className="mr-1 h-3 w-3" />
|
||||
카테고리 값 연쇄 관리
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ export interface TableListComponentProps {
|
|||
onConfigChange?: (config: any) => void;
|
||||
refreshKey?: number;
|
||||
// 탭 관련 정보 (탭 내부의 테이블에서 사용)
|
||||
parentTabId?: string; // 부모 탭 ID
|
||||
parentTabId?: string; // 부모 탭 ID
|
||||
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
|
||||
}
|
||||
|
||||
|
|
@ -687,7 +687,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const [isTableOptionsOpen, setIsTableOptionsOpen] = useState(false);
|
||||
const [showGridLines, setShowGridLines] = useState(true);
|
||||
const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table");
|
||||
const [frozenColumns, setFrozenColumns] = useState<string[]>([]);
|
||||
// 체크박스 컬럼은 항상 기본 틀고정
|
||||
const [frozenColumns, setFrozenColumns] = useState<string[]>(
|
||||
(tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : [],
|
||||
);
|
||||
const [frozenColumnCount, setFrozenColumnCount] = useState<number>(0);
|
||||
|
||||
// 🆕 Search Panel (통합 검색) 관련 상태
|
||||
const [globalSearchTerm, setGlobalSearchTerm] = useState("");
|
||||
|
|
@ -1022,6 +1026,19 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getColumnUniqueValues, // 고유 값 조회 함수 등록
|
||||
onGroupSumChange: setGroupSumConfig, // 그룹별 합산 설정
|
||||
// 틀고정 컬럼 관련
|
||||
frozenColumnCount, // 현재 틀고정 컬럼 수
|
||||
onFrozenColumnCountChange: (count: number) => {
|
||||
setFrozenColumnCount(count);
|
||||
// 체크박스 컬럼은 항상 틀고정에 포함
|
||||
const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : [];
|
||||
// 표시 가능한 컬럼 중 처음 N개를 틀고정 컬럼으로 설정
|
||||
const visibleCols = columnsToRegister
|
||||
.filter((col) => col.visible !== false)
|
||||
.map((col) => col.columnName || col.field);
|
||||
const newFrozenColumns = [...checkboxColumn, ...visibleCols.slice(0, count)];
|
||||
setFrozenColumns(newFrozenColumns);
|
||||
},
|
||||
// 탭 관련 정보 (탭 내부의 테이블인 경우)
|
||||
parentTabId,
|
||||
parentTabsComponentId,
|
||||
|
|
@ -1033,6 +1050,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
return () => {
|
||||
unregisterTable(tableId);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
tableId,
|
||||
tableConfig.selectedTable,
|
||||
|
|
@ -1044,7 +1062,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
data, // 데이터 자체가 변경되면 재등록 (고유 값 조회용)
|
||||
totalItems, // 전체 항목 수가 변경되면 재등록
|
||||
registerTable,
|
||||
unregisterTable,
|
||||
// unregisterTable은 의존성에서 제외 - 무한 루프 방지
|
||||
// unregisterTable 함수는 의존성이 없어 안정적임
|
||||
]);
|
||||
|
||||
// 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기
|
||||
|
|
@ -1292,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 }> = {};
|
||||
|
||||
|
|
@ -1357,7 +1374,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
col.columnName,
|
||||
})) || [];
|
||||
|
||||
|
||||
// 조인 테이블별로 그룹화
|
||||
const joinedTableColumns: Record<string, { columnName: string; actualColumn: string }[]> = {};
|
||||
|
||||
|
|
@ -1389,7 +1405,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
});
|
||||
}
|
||||
|
||||
|
||||
// 조인된 테이블별로 inputType 정보 가져오기
|
||||
const newJoinedColumnMeta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
|
||||
|
||||
|
|
@ -1452,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);
|
||||
|
|
@ -1476,7 +1526,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
// ========================================
|
||||
|
||||
const fetchTableDataInternal = useCallback(async () => {
|
||||
|
||||
if (!tableConfig.selectedTable || isDesignMode) {
|
||||
setData([]);
|
||||
setTotalPages(0);
|
||||
|
|
@ -1495,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 || [];
|
||||
|
|
@ -1590,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,
|
||||
|
|
@ -1666,7 +1714,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
};
|
||||
});
|
||||
|
||||
|
||||
// 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||
let excludeFilterParam: any = undefined;
|
||||
if (tableConfig.excludeFilter?.enabled) {
|
||||
|
|
@ -2408,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,
|
||||
|
|
@ -2449,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,
|
||||
|
|
@ -2877,6 +2924,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
sortDirection,
|
||||
groupByColumns,
|
||||
frozenColumns,
|
||||
frozenColumnCount, // 틀고정 컬럼 수 저장
|
||||
showGridLines,
|
||||
headerFilters: Object.fromEntries(
|
||||
Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set<string>)]),
|
||||
|
|
@ -2898,6 +2946,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
sortDirection,
|
||||
groupByColumns,
|
||||
frozenColumns,
|
||||
frozenColumnCount,
|
||||
showGridLines,
|
||||
headerFilters,
|
||||
localPageSize,
|
||||
|
|
@ -2918,7 +2967,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
if (state.sortColumn !== undefined) setSortColumn(state.sortColumn);
|
||||
if (state.sortDirection) setSortDirection(state.sortDirection);
|
||||
if (state.groupByColumns) setGroupByColumns(state.groupByColumns);
|
||||
if (state.frozenColumns) setFrozenColumns(state.frozenColumns);
|
||||
if (state.frozenColumns) {
|
||||
// 체크박스 컬럼이 항상 포함되도록 보장
|
||||
const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? "__checkbox__" : null;
|
||||
const restoredFrozenColumns =
|
||||
checkboxColumn && !state.frozenColumns.includes(checkboxColumn)
|
||||
? [checkboxColumn, ...state.frozenColumns]
|
||||
: state.frozenColumns;
|
||||
setFrozenColumns(restoredFrozenColumns);
|
||||
}
|
||||
if (state.frozenColumnCount !== undefined) setFrozenColumnCount(state.frozenColumnCount); // 틀고정 컬럼 수 복원
|
||||
if (state.showGridLines !== undefined) setShowGridLines(state.showGridLines);
|
||||
if (state.headerFilters) {
|
||||
const filters: Record<string, Set<string>> = {};
|
||||
|
|
@ -2927,7 +2985,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
});
|
||||
setHeaderFilters(filters);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 상태 복원 실패:", error);
|
||||
}
|
||||
|
|
@ -3547,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);
|
||||
|
|
@ -4104,13 +4161,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
// 선택된 컬럼들의 값을 구분자로 조합
|
||||
const values = displayColumns
|
||||
.map((colName: string) => {
|
||||
// 1. 먼저 직접 컬럼명으로 시도 (기본 테이블 컬럼인 경우)
|
||||
let cellValue = rowData[colName];
|
||||
// 🎯 백엔드 alias 규칙: ${sourceColumn}_${displayColumn}
|
||||
// 예: manager 컬럼에서 user_name 선택 시 → manager_user_name
|
||||
const joinedKey = `${column.columnName}_${colName}`;
|
||||
let cellValue = rowData[joinedKey];
|
||||
|
||||
// 2. 없으면 ${sourceColumn}_${colName} 형식으로 시도 (조인 테이블 컬럼인 경우)
|
||||
// fallback: 직접 컬럼명으로 시도 (기본 테이블 컬럼인 경우)
|
||||
if (cellValue === null || cellValue === undefined) {
|
||||
const joinedKey = `${column.columnName}_${colName}`;
|
||||
cellValue = rowData[joinedKey];
|
||||
cellValue = rowData[colName];
|
||||
}
|
||||
|
||||
if (cellValue === null || cellValue === undefined) return "";
|
||||
|
|
@ -4897,7 +4955,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
// relatedButtonFilter가 있으면 데이터 로드, null이면 빈 상태 (setRefreshTrigger로 트리거)
|
||||
console.log("🔄 [TableList] RelatedDataButtons 상태 변경:", {
|
||||
relatedButtonFilter,
|
||||
isRelatedButtonTarget
|
||||
isRelatedButtonTarget,
|
||||
});
|
||||
setRefreshTrigger((prev) => prev + 1);
|
||||
}
|
||||
|
|
@ -5587,7 +5645,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
if (isFrozen && frozenIndex > 0) {
|
||||
for (let i = 0; i < frozenIndex; i++) {
|
||||
const frozenCol = frozenColumns[i];
|
||||
const frozenColWidth = columnWidths[frozenCol] || 150;
|
||||
// 체크박스 컬럼은 48px 고정
|
||||
const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
|
||||
leftPosition += frozenColWidth;
|
||||
}
|
||||
}
|
||||
|
|
@ -5606,7 +5665,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
column.sortable !== false &&
|
||||
column.columnName !== "__checkbox__" &&
|
||||
"hover:bg-muted/70 cursor-pointer transition-colors",
|
||||
isFrozen && "sticky z-60 shadow-[2px_0_4px_rgba(0,0,0,0.1)]",
|
||||
isFrozen && "sticky z-40 shadow-[2px_0_4px_rgba(0,0,0,0.1)]",
|
||||
// 🆕 Column Reordering 스타일
|
||||
isColumnDragEnabled &&
|
||||
column.columnName !== "__checkbox__" &&
|
||||
|
|
@ -5898,7 +5957,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
if (isFrozen && frozenIndex > 0) {
|
||||
for (let i = 0; i < frozenIndex; i++) {
|
||||
const frozenCol = frozenColumns[i];
|
||||
const frozenColWidth = columnWidths[frozenCol] || 150;
|
||||
// 체크박스 컬럼은 48px 고정
|
||||
const frozenColWidth =
|
||||
frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
|
||||
leftPosition += frozenColWidth;
|
||||
}
|
||||
}
|
||||
|
|
@ -5911,7 +5972,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
column.columnName === "__checkbox__"
|
||||
? "px-0 py-1"
|
||||
: "px-2 py-1 sm:px-4 sm:py-1.5",
|
||||
isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]",
|
||||
isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]",
|
||||
)}
|
||||
style={{
|
||||
textAlign:
|
||||
|
|
@ -5926,7 +5987,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
: `${100 / visibleColumns.length}%`,
|
||||
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
||||
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
||||
...(isFrozen && { left: `${leftPosition}px` }),
|
||||
...(isFrozen && {
|
||||
left: `${leftPosition}px`,
|
||||
backgroundColor: "hsl(var(--background))",
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{column.columnName === "__checkbox__"
|
||||
|
|
@ -6058,7 +6122,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
if (isFrozen && frozenIndex > 0) {
|
||||
for (let i = 0; i < frozenIndex; i++) {
|
||||
const frozenCol = frozenColumns[i];
|
||||
const frozenColWidth = columnWidths[frozenCol] || 150;
|
||||
// 체크박스 컬럼은 48px 고정
|
||||
const frozenColWidth =
|
||||
frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
|
||||
leftPosition += frozenColWidth;
|
||||
}
|
||||
}
|
||||
|
|
@ -6071,7 +6137,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
className={cn(
|
||||
"text-foreground overflow-hidden text-xs font-normal text-ellipsis whitespace-nowrap sm:text-sm",
|
||||
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-1.5",
|
||||
isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]",
|
||||
isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]",
|
||||
// 🆕 포커스된 셀 스타일
|
||||
isCellFocused && !editingCell && "ring-primary bg-primary/5 ring-2 ring-inset",
|
||||
// 🆕 편집 중인 셀 스타일
|
||||
|
|
@ -6098,7 +6164,10 @@ 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 && { left: `${leftPosition}px` }),
|
||||
...(isFrozen && {
|
||||
left: `${leftPosition}px`,
|
||||
backgroundColor: "hsl(var(--background))",
|
||||
}),
|
||||
}}
|
||||
onClick={(e) => handleCellClick(index, colIndex, e)}
|
||||
onDoubleClick={() =>
|
||||
|
|
@ -6219,7 +6288,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
if (isFrozen && frozenIndex > 0) {
|
||||
for (let i = 0; i < frozenIndex; i++) {
|
||||
const frozenCol = frozenColumns[i];
|
||||
const frozenColWidth = columnWidths[frozenCol] || 150;
|
||||
// 체크박스 컬럼은 48px 고정
|
||||
const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
|
||||
leftPosition += frozenColWidth;
|
||||
}
|
||||
}
|
||||
|
|
@ -6234,7 +6304,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
className={cn(
|
||||
"text-foreground text-xs font-semibold sm:text-sm",
|
||||
column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-4",
|
||||
isFrozen && "bg-muted/80 sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]",
|
||||
isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]",
|
||||
)}
|
||||
style={{
|
||||
textAlign: isNumeric ? "right" : column.align || "left",
|
||||
|
|
@ -6244,7 +6314,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
: columnWidth
|
||||
? `${columnWidth}px`
|
||||
: undefined,
|
||||
...(isFrozen && { left: `${leftPosition}px` }),
|
||||
...(isFrozen && {
|
||||
left: `${leftPosition}px`,
|
||||
backgroundColor: "hsl(var(--muted) / 0.8)",
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{summary ? (
|
||||
|
|
|
|||
|
|
@ -138,33 +138,84 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
|
||||
// currentTable은 tableList(필터링된 목록)에서 가져와야 함
|
||||
const currentTable = useMemo(() => {
|
||||
console.log("🔍 [TableSearchWidget] currentTable 계산:", {
|
||||
selectedTableId,
|
||||
tableListLength: tableList.length,
|
||||
tableList: tableList.map(t => ({ id: t.tableId, name: t.tableName, parentTabId: t.parentTabId }))
|
||||
});
|
||||
|
||||
if (!selectedTableId) return undefined;
|
||||
|
||||
// 먼저 tableList(필터링된 목록)에서 찾기
|
||||
const tableFromList = tableList.find(t => t.tableId === selectedTableId);
|
||||
if (tableFromList) {
|
||||
console.log("✅ [TableSearchWidget] 테이블 찾음 (tableList):", tableFromList.tableName);
|
||||
return tableFromList;
|
||||
}
|
||||
|
||||
// tableList에 없으면 전체에서 찾기 (폴백)
|
||||
return getTable(selectedTableId);
|
||||
const tableFromAll = getTable(selectedTableId);
|
||||
console.log("🔄 [TableSearchWidget] 테이블 찾음 (전체):", tableFromAll?.tableName);
|
||||
return tableFromAll;
|
||||
}, [selectedTableId, tableList, getTable]);
|
||||
|
||||
// 🆕 활성 탭 ID 문자열 (변경 감지용)
|
||||
const activeTabIdsStr = useMemo(() => activeTabIds.join(","), [activeTabIds]);
|
||||
|
||||
// 🆕 이전 활성 탭 ID 추적 (탭 전환 감지용)
|
||||
const prevActiveTabIdsRef = useRef<string>(activeTabIdsStr);
|
||||
|
||||
// 대상 패널의 첫 번째 테이블 자동 선택
|
||||
useEffect(() => {
|
||||
if (!autoSelectFirstTable || tableList.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 🆕 탭 전환 감지: 활성 탭이 변경되었는지 확인
|
||||
const tabChanged = prevActiveTabIdsRef.current !== activeTabIdsStr;
|
||||
if (tabChanged) {
|
||||
console.log("🔄 [TableSearchWidget] 탭 전환 감지:", {
|
||||
이전탭: prevActiveTabIdsRef.current,
|
||||
현재탭: activeTabIdsStr,
|
||||
가용테이블: tableList.map(t => ({ id: t.tableId, tableName: t.tableName, parentTabId: t.parentTabId })),
|
||||
현재선택테이블: selectedTableId
|
||||
});
|
||||
prevActiveTabIdsRef.current = activeTabIdsStr;
|
||||
|
||||
// 🆕 탭 전환 시: 해당 탭에 속한 테이블 중 첫 번째 강제 선택
|
||||
const activeTabTable = tableList.find(t => t.parentTabId && activeTabIds.includes(t.parentTabId));
|
||||
const targetTable = activeTabTable || tableList[0];
|
||||
|
||||
if (targetTable) {
|
||||
console.log("✅ [TableSearchWidget] 탭 전환으로 테이블 강제 선택:", {
|
||||
테이블ID: targetTable.tableId,
|
||||
테이블명: targetTable.tableName,
|
||||
탭ID: targetTable.parentTabId,
|
||||
이전테이블: selectedTableId
|
||||
});
|
||||
setSelectedTableId(targetTable.tableId);
|
||||
}
|
||||
return; // 탭 전환 시에는 여기서 종료
|
||||
}
|
||||
|
||||
// 현재 선택된 테이블이 대상 패널에 있는지 확인
|
||||
const isCurrentTableInTarget = selectedTableId && tableList.some(t => t.tableId === selectedTableId);
|
||||
|
||||
// 현재 선택된 테이블이 대상 패널에 없으면 대상 패널의 첫 번째 테이블 선택
|
||||
// 현재 선택된 테이블이 대상 패널에 없으면 첫 번째 테이블 선택
|
||||
if (!selectedTableId || !isCurrentTableInTarget) {
|
||||
const targetTable = tableList[0];
|
||||
setSelectedTableId(targetTable.tableId);
|
||||
const activeTabTable = tableList.find(t => t.parentTabId && activeTabIds.includes(t.parentTabId));
|
||||
const targetTable = activeTabTable || tableList[0];
|
||||
|
||||
if (targetTable && targetTable.tableId !== selectedTableId) {
|
||||
console.log("✅ [TableSearchWidget] 테이블 자동 선택 (초기):", {
|
||||
테이블ID: targetTable.tableId,
|
||||
테이블명: targetTable.tableName,
|
||||
탭ID: targetTable.parentTabId
|
||||
});
|
||||
setSelectedTableId(targetTable.tableId);
|
||||
}
|
||||
}
|
||||
}, [tableList, selectedTableId, autoSelectFirstTable, setSelectedTableId, targetPanelPosition]);
|
||||
}, [tableList, selectedTableId, autoSelectFirstTable, setSelectedTableId, targetPanelPosition, activeTabIdsStr, activeTabIds]);
|
||||
|
||||
// 현재 선택된 테이블의 탭 ID (탭별 필터 저장용)
|
||||
const currentTableTabId = currentTable?.parentTabId;
|
||||
|
|
@ -196,6 +247,13 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
|
||||
// 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드)
|
||||
useEffect(() => {
|
||||
console.log("📋 [TableSearchWidget] 필터 설정 useEffect 실행:", {
|
||||
currentTable: currentTable?.tableName,
|
||||
currentTableTabId,
|
||||
filterMode,
|
||||
selectedTableId,
|
||||
컬럼수: currentTable?.columns?.length
|
||||
});
|
||||
if (!currentTable?.tableName) return;
|
||||
|
||||
// 고정 모드: presetFilters를 activeFilters로 설정
|
||||
|
|
@ -229,12 +287,20 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
return;
|
||||
}
|
||||
|
||||
// 동적 모드: 화면별 + 탭별로 독립적인 필터 설정 불러오기
|
||||
// 동적 모드: 화면별로 독립적인 필터 설정 불러오기
|
||||
// 참고: FilterPanel.tsx에서도 screenId만 사용하여 저장하므로 키가 일치해야 함
|
||||
const filterConfigKey = screenId
|
||||
? `table_filters_${currentTable.tableName}_screen_${screenId}${currentTableTabId ? `_tab_${currentTableTabId}` : ''}`
|
||||
? `table_filters_${currentTable.tableName}_screen_${screenId}`
|
||||
: `table_filters_${currentTable.tableName}`;
|
||||
const savedFilters = localStorage.getItem(filterConfigKey);
|
||||
|
||||
console.log("🔑 [TableSearchWidget] 필터 설정 키 확인:", {
|
||||
filterConfigKey,
|
||||
savedFilters: savedFilters ? `${savedFilters.substring(0, 100)}...` : null,
|
||||
screenId,
|
||||
tableName: currentTable.tableName
|
||||
});
|
||||
|
||||
if (savedFilters) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedFilters) as Array<{
|
||||
|
|
@ -257,6 +323,13 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
width: f.width || 200,
|
||||
}));
|
||||
|
||||
console.log("📌 [TableSearchWidget] 필터 설정 로드:", {
|
||||
filterConfigKey,
|
||||
총필터수: parsed.length,
|
||||
활성화필터수: activeFiltersList.length,
|
||||
활성화필터: activeFiltersList.map(f => f.columnName)
|
||||
});
|
||||
|
||||
setActiveFilters(activeFiltersList);
|
||||
|
||||
// 탭별 저장된 필터 값 복원
|
||||
|
|
@ -280,10 +353,19 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
}
|
||||
} catch (error) {
|
||||
console.error("저장된 필터 불러오기 실패:", error);
|
||||
// 파싱 에러 시 필터 초기화
|
||||
setActiveFilters([]);
|
||||
setFilterValues({});
|
||||
}
|
||||
} else {
|
||||
// 필터 설정이 없으면 초기화
|
||||
// 필터 설정이 없으면 activeFilters와 filterValues 모두 초기화
|
||||
console.log("⚠️ [TableSearchWidget] 저장된 필터 설정 없음 - 필터 초기화:", {
|
||||
tableName: currentTable.tableName,
|
||||
filterConfigKey
|
||||
});
|
||||
setActiveFilters([]);
|
||||
setFilterValues({});
|
||||
setSelectOptions({});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentTable?.tableName, filterMode, screenId, currentTableTabId, JSON.stringify(presetFilters)]);
|
||||
|
|
|
|||
|
|
@ -449,7 +449,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) {
|
||||
|
|
@ -697,7 +697,7 @@ export function UniversalFormModalComponent({
|
|||
}
|
||||
|
||||
// 옵셔널 필드 그룹 필드 값 초기화
|
||||
group.fields.forEach((field) => {
|
||||
(group.fields || []).forEach((field) => {
|
||||
handleFieldChange(field.columnName, field.defaultValue || "");
|
||||
});
|
||||
}, [config, handleFieldChange]);
|
||||
|
|
@ -1023,7 +1023,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가 설정되지 않은 경우, 첫 번째 반복 섹션 사용
|
||||
|
|
@ -1069,7 +1069,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];
|
||||
}
|
||||
|
|
@ -1928,7 +1928,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],
|
||||
|
|
@ -1972,7 +1972,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],
|
||||
|
|
|
|||
|
|
@ -158,6 +158,37 @@ export interface ComponentConfig {
|
|||
headerTextColor?: string; // 헤더 텍스트 색상
|
||||
showBorder?: boolean; // 테두리 표시
|
||||
rowHeight?: number; // 행 높이 (px)
|
||||
// 페이지 번호 전용
|
||||
pageNumberFormat?: "number" | "numberTotal" | "koreanNumber"; // 페이지 번호 포맷
|
||||
// 카드 컴포넌트 전용
|
||||
cardTitle?: string; // 카드 제목
|
||||
cardItems?: Array<{
|
||||
label: string; // 항목 라벨 (예: "회사명")
|
||||
value: string; // 항목 값 (예: "당사 주식회사") 또는 기본값
|
||||
fieldName?: string; // 쿼리 필드명 (바인딩용)
|
||||
}>;
|
||||
labelWidth?: number; // 라벨 컬럼 너비 (px)
|
||||
showCardBorder?: boolean; // 카드 테두리 표시 여부
|
||||
showCardTitle?: boolean; // 카드 제목 표시 여부
|
||||
titleFontSize?: number; // 제목 폰트 크기
|
||||
labelFontSize?: number; // 라벨 폰트 크기
|
||||
valueFontSize?: number; // 값 폰트 크기
|
||||
titleColor?: string; // 제목 색상
|
||||
labelColor?: string; // 라벨 색상
|
||||
valueColor?: string; // 값 색상
|
||||
// 계산 컴포넌트 전용
|
||||
calcItems?: Array<{
|
||||
label: string; // 항목 라벨 (예: "공급가액")
|
||||
value: number | string; // 항목 값 또는 기본값
|
||||
operator: "+" | "-" | "x" | "÷"; // 연산자
|
||||
fieldName?: string; // 쿼리 필드명 (바인딩용)
|
||||
}>;
|
||||
resultLabel?: string; // 결과 라벨 (예: "합계 금액")
|
||||
resultColor?: string; // 결과 색상
|
||||
resultFontSize?: number; // 결과 폰트 크기
|
||||
showCalcBorder?: boolean; // 테두리 표시 여부
|
||||
numberFormat?: "none" | "comma" | "currency"; // 숫자 포맷 (없음, 천단위, 원화)
|
||||
currencySuffix?: string; // 통화 접미사 (예: "원")
|
||||
}
|
||||
|
||||
// 리포트 상세
|
||||
|
|
|
|||
|
|
@ -66,6 +66,10 @@ export interface TableRegistration {
|
|||
onGroupChange: (groups: string[]) => void;
|
||||
onColumnVisibilityChange: (columns: ColumnVisibility[]) => void;
|
||||
onGroupSumChange?: (config: GroupSumConfig | null) => void; // 그룹별 합산 설정 변경
|
||||
onFrozenColumnCountChange?: (count: number) => void; // 틀고정 컬럼 수 변경
|
||||
|
||||
// 현재 설정 값 (읽기 전용)
|
||||
frozenColumnCount?: number; // 현재 틀고정 컬럼 수
|
||||
|
||||
// 데이터 조회 함수 (선택 타입 필터용)
|
||||
getColumnUniqueValues?: (columnName: string) => Promise<Array<{ label: string; value: string }>>;
|
||||
|
|
|
|||
|
|
@ -1686,3 +1686,5 @@ const 출고등록_설정: ScreenSplitPanel = {
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -533,3 +533,5 @@ const { data: config } = await getScreenSplitPanel(screenId);
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -520,3 +520,5 @@ function ScreenViewPage() {
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue