테스트 위젯 원본 승격 전 세이브
This commit is contained in:
parent
fb73ee2878
commit
81458549af
|
|
@ -0,0 +1,335 @@
|
|||
# 컬럼 매핑 기능 사용 가이드
|
||||
|
||||
## 📋 개요
|
||||
|
||||
**컬럼 매핑**은 여러 데이터 소스의 서로 다른 컬럼명을 통일된 이름으로 변환하여 데이터를 통합할 수 있게 해주는 기능입니다.
|
||||
|
||||
## 🎯 사용 시나리오
|
||||
|
||||
### 시나리오 1: 여러 데이터베이스 통합
|
||||
|
||||
```
|
||||
데이터 소스 1 (PostgreSQL):
|
||||
SELECT name, amount, created_at FROM orders
|
||||
|
||||
데이터 소스 2 (MySQL):
|
||||
SELECT product_name, total, order_date FROM sales
|
||||
|
||||
데이터 소스 3 (Oracle):
|
||||
SELECT item, price, timestamp FROM transactions
|
||||
```
|
||||
|
||||
**문제**: 각 데이터베이스의 컬럼명이 달라서 통합이 어렵습니다.
|
||||
|
||||
**해결**: 컬럼 매핑으로 통일!
|
||||
|
||||
```
|
||||
데이터 소스 1 매핑:
|
||||
name → product
|
||||
amount → value
|
||||
created_at → date
|
||||
|
||||
데이터 소스 2 매핑:
|
||||
product_name → product
|
||||
total → value
|
||||
order_date → date
|
||||
|
||||
데이터 소스 3 매핑:
|
||||
item → product
|
||||
price → value
|
||||
timestamp → date
|
||||
```
|
||||
|
||||
**결과**: 모든 데이터가 `product`, `value`, `date` 컬럼으로 통합됩니다!
|
||||
|
||||
---
|
||||
|
||||
## 🔧 사용 방법
|
||||
|
||||
### 1️⃣ 데이터 소스 추가
|
||||
|
||||
대시보드 편집 모드에서 위젯의 "데이터 소스 관리" 섹션으로 이동합니다.
|
||||
|
||||
### 2️⃣ 쿼리/API 테스트
|
||||
|
||||
- **Database**: SQL 쿼리 입력 후 "쿼리 테스트" 클릭
|
||||
- **REST API**: API 설정 후 "API 테스트" 클릭
|
||||
|
||||
### 3️⃣ 컬럼 매핑 설정
|
||||
|
||||
테스트 성공 후 **"🔄 컬럼 매핑 (선택사항)"** 섹션이 나타납니다.
|
||||
|
||||
#### 매핑 추가:
|
||||
1. 드롭다운에서 원본 컬럼 선택
|
||||
2. 표시 이름 입력 (예: `name` → `product`)
|
||||
3. 자동으로 매핑 추가됨
|
||||
|
||||
#### 매핑 수정:
|
||||
- 오른쪽 입력 필드에서 표시 이름 변경
|
||||
|
||||
#### 매핑 삭제:
|
||||
- 각 매핑 행의 ❌ 버튼 클릭
|
||||
- 또는 "초기화" 버튼으로 전체 삭제
|
||||
|
||||
### 4️⃣ 적용 및 저장
|
||||
|
||||
1. "적용" 버튼 클릭
|
||||
2. 대시보드 저장
|
||||
|
||||
---
|
||||
|
||||
## 📊 지원 위젯
|
||||
|
||||
컬럼 매핑은 다음 **모든 테스트 위젯**에서 사용 가능합니다:
|
||||
|
||||
- ✅ **MapTestWidgetV2** (지도 위젯)
|
||||
- ✅ **통계 카드 (CustomMetricTestWidget)** (메트릭 위젯)
|
||||
- ✅ **ListTestWidget** (리스트 위젯)
|
||||
- ✅ **RiskAlertTestWidget** (알림 위젯)
|
||||
- ✅ **ChartTestWidget** (차트 위젯)
|
||||
|
||||
---
|
||||
|
||||
## 💡 실전 예시
|
||||
|
||||
### 예시 1: 주문 데이터 통합
|
||||
|
||||
**데이터 소스 1 (내부 DB)**
|
||||
```sql
|
||||
SELECT
|
||||
customer_name,
|
||||
order_amount,
|
||||
order_date
|
||||
FROM orders
|
||||
```
|
||||
|
||||
**컬럼 매핑:**
|
||||
- `customer_name` → `name`
|
||||
- `order_amount` → `amount`
|
||||
- `order_date` → `date`
|
||||
|
||||
---
|
||||
|
||||
**데이터 소스 2 (외부 API)**
|
||||
|
||||
API 응답:
|
||||
```json
|
||||
[
|
||||
{ "clientName": "홍길동", "totalPrice": 50000, "timestamp": "2025-01-01" }
|
||||
]
|
||||
```
|
||||
|
||||
**컬럼 매핑:**
|
||||
- `clientName` → `name`
|
||||
- `totalPrice` → `amount`
|
||||
- `timestamp` → `date`
|
||||
|
||||
---
|
||||
|
||||
**결과 (통합된 데이터):**
|
||||
```json
|
||||
[
|
||||
{ "name": "홍길동", "amount": 50000, "date": "2025-01-01", "_source": "내부 DB" },
|
||||
{ "name": "홍길동", "amount": 50000, "date": "2025-01-01", "_source": "외부 API" }
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 예시 2: 지도 위젯 - 위치 데이터 통합
|
||||
|
||||
**데이터 소스 1 (기상청 API)**
|
||||
```json
|
||||
[
|
||||
{ "location": "서울", "lat": 37.5665, "lon": 126.9780, "temp": 15 }
|
||||
]
|
||||
```
|
||||
|
||||
**컬럼 매핑:**
|
||||
- `lat` → `latitude`
|
||||
- `lon` → `longitude`
|
||||
- `location` → `name`
|
||||
|
||||
---
|
||||
|
||||
**데이터 소스 2 (교통정보 DB)**
|
||||
```sql
|
||||
SELECT
|
||||
address,
|
||||
y_coord AS latitude,
|
||||
x_coord AS longitude,
|
||||
status
|
||||
FROM traffic_info
|
||||
```
|
||||
|
||||
**컬럼 매핑:**
|
||||
- `address` → `name`
|
||||
- (latitude, longitude는 이미 올바른 이름)
|
||||
|
||||
---
|
||||
|
||||
**결과**: 모든 데이터가 `name`, `latitude`, `longitude`로 통일되어 지도에 표시됩니다!
|
||||
|
||||
---
|
||||
|
||||
## 🔍 SQL Alias vs 컬럼 매핑
|
||||
|
||||
### SQL Alias (방법 1)
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
name AS product,
|
||||
amount AS value,
|
||||
created_at AS date
|
||||
FROM orders
|
||||
```
|
||||
|
||||
**장점:**
|
||||
- SQL 쿼리에서 직접 처리
|
||||
- 백엔드에서 이미 변환됨
|
||||
|
||||
**단점:**
|
||||
- SQL 지식 필요
|
||||
- REST API에는 사용 불가
|
||||
|
||||
---
|
||||
|
||||
### 컬럼 매핑 (방법 2)
|
||||
|
||||
UI에서 클릭만으로 설정:
|
||||
- `name` → `product`
|
||||
- `amount` → `value`
|
||||
- `created_at` → `date`
|
||||
|
||||
**장점:**
|
||||
- SQL 지식 불필요
|
||||
- REST API에도 사용 가능
|
||||
- 언제든지 수정 가능
|
||||
- 실시간 미리보기
|
||||
|
||||
**단점:**
|
||||
- 프론트엔드에서 처리 (약간의 오버헤드)
|
||||
|
||||
---
|
||||
|
||||
## ✨ 권장 사항
|
||||
|
||||
### 언제 SQL Alias를 사용할까?
|
||||
- SQL에 익숙한 경우
|
||||
- 백엔드에서 처리하고 싶은 경우
|
||||
- 복잡한 변환 로직이 필요한 경우
|
||||
|
||||
### 언제 컬럼 매핑을 사용할까?
|
||||
- SQL을 모르는 경우
|
||||
- REST API 데이터를 다룰 때
|
||||
- 빠르게 테스트하고 싶을 때
|
||||
- 여러 데이터 소스를 통합할 때
|
||||
|
||||
### 두 가지 모두 사용 가능!
|
||||
- SQL Alias로 일차 변환
|
||||
- 컬럼 매핑으로 추가 변환
|
||||
- 예: `SELECT name AS product_name` → 컬럼 매핑: `product_name` → `product`
|
||||
|
||||
---
|
||||
|
||||
## 🚨 주의사항
|
||||
|
||||
### 1. 매핑하지 않은 컬럼은 원본 이름 유지
|
||||
```
|
||||
원본: { name: "A", amount: 100, status: "active" }
|
||||
매핑: { name: "product" }
|
||||
결과: { product: "A", amount: 100, status: "active" }
|
||||
```
|
||||
|
||||
### 2. 중복 컬럼명 주의
|
||||
```
|
||||
원본: { name: "A", product: "B" }
|
||||
매핑: { name: "product" }
|
||||
결과: { product: "A" } // 기존 product 컬럼이 덮어씌워짐!
|
||||
```
|
||||
|
||||
### 3. 대소문자 구분
|
||||
- PostgreSQL: 소문자 권장 (`user_name`)
|
||||
- JavaScript: 카멜케이스 권장 (`userName`)
|
||||
- 매핑으로 통일 가능!
|
||||
|
||||
---
|
||||
|
||||
## 🔄 데이터 흐름
|
||||
|
||||
```
|
||||
1. 데이터 소스에서 원본 데이터 로드
|
||||
↓
|
||||
2. 컬럼 매핑 적용 (applyColumnMapping)
|
||||
↓
|
||||
3. 통일된 컬럼명으로 변환된 데이터
|
||||
↓
|
||||
4. 위젯에서 표시/처리
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 기술 세부사항
|
||||
|
||||
### 유틸리티 함수
|
||||
|
||||
**파일**: `frontend/lib/utils/columnMapping.ts`
|
||||
|
||||
#### `applyColumnMapping(data, columnMapping)`
|
||||
- 데이터 배열에 컬럼 매핑 적용
|
||||
- 매핑이 없으면 원본 그대로 반환
|
||||
|
||||
#### `mergeDataSources(dataSets)`
|
||||
- 여러 데이터 소스를 병합
|
||||
- 각 데이터 소스의 매핑을 자동 적용
|
||||
- `_source` 필드로 출처 표시
|
||||
|
||||
---
|
||||
|
||||
## 🎓 학습 자료
|
||||
|
||||
### 관련 파일
|
||||
- 타입 정의: `frontend/components/admin/dashboard/types.ts`
|
||||
- UI 컴포넌트: `frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx`
|
||||
- UI 컴포넌트: `frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx`
|
||||
- 유틸리티: `frontend/lib/utils/columnMapping.ts`
|
||||
|
||||
### 위젯 구현 예시
|
||||
- 지도: `frontend/components/dashboard/widgets/MapTestWidgetV2.tsx`
|
||||
- 통계 카드: `frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx`
|
||||
- 리스트: `frontend/components/dashboard/widgets/ListTestWidget.tsx`
|
||||
- 알림: `frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx`
|
||||
- 차트: `frontend/components/dashboard/widgets/ChartTestWidget.tsx`
|
||||
|
||||
---
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
### Q1: 컬럼 매핑이 저장되나요?
|
||||
**A**: 네! 대시보드 저장 시 함께 저장됩니다.
|
||||
|
||||
### Q2: 매핑 후 원본 컬럼명으로 되돌릴 수 있나요?
|
||||
**A**: 네! 해당 매핑을 삭제하면 원본 이름으로 돌아갑니다.
|
||||
|
||||
### Q3: REST API와 Database를 동시에 매핑할 수 있나요?
|
||||
**A**: 네! 각 데이터 소스마다 독립적으로 매핑할 수 있습니다.
|
||||
|
||||
### Q4: 성능에 영향이 있나요?
|
||||
**A**: 매우 적습니다. 단순 객체 키 변환이므로 빠릅니다.
|
||||
|
||||
### Q5: 컬럼 타입이 변경되나요?
|
||||
**A**: 아니요! 컬럼 이름만 변경되고, 값과 타입은 그대로 유지됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 🎉 마무리
|
||||
|
||||
컬럼 매핑 기능을 사용하면:
|
||||
- ✅ 여러 데이터 소스를 쉽게 통합
|
||||
- ✅ SQL 지식 없이도 데이터 변환
|
||||
- ✅ REST API와 Database 모두 지원
|
||||
- ✅ 실시간으로 결과 확인
|
||||
- ✅ 언제든지 수정 가능
|
||||
|
||||
**지금 바로 사용해보세요!** 🚀
|
||||
|
||||
|
|
@ -0,0 +1,286 @@
|
|||
# 테스트 위젯 누락 기능 분석 보고서
|
||||
|
||||
**작성일**: 2025-10-28
|
||||
**목적**: 원본 위젯과 테스트 위젯 간의 기능 차이를 분석하여 누락된 기능을 파악
|
||||
|
||||
---
|
||||
|
||||
## 📊 위젯 비교 매트릭스
|
||||
|
||||
| 원본 위젯 | 테스트 위젯 | 상태 | 누락된 기능 |
|
||||
|-----------|-------------|------|-------------|
|
||||
| CustomMetricWidget | 통계 카드 (CustomMetricTestWidget) | ✅ **완료** | ~~Group By Mode~~ (추가 완료) |
|
||||
| RiskAlertWidget | RiskAlertTestWidget | ⚠️ **검토 필요** | 새 알림 애니메이션 (불필요) |
|
||||
| ChartWidget | ChartTestWidget | 🔍 **분석 중** | TBD |
|
||||
| ListWidget | ListTestWidget | 🔍 **분석 중** | TBD |
|
||||
| MapSummaryWidget | MapTestWidgetV2 | 🔍 **분석 중** | TBD |
|
||||
| MapTestWidget | (주석 처리됨) | ⏸️ **비활성** | N/A |
|
||||
| StatusSummaryWidget | (주석 처리됨) | ⏸️ **비활성** | N/A |
|
||||
|
||||
---
|
||||
|
||||
## 1️⃣ CustomMetricWidget vs 통계 카드 (CustomMetricTestWidget)
|
||||
|
||||
### ✅ 상태: **완료**
|
||||
|
||||
### 원본 기능
|
||||
- 단일 데이터 소스 (Database 또는 REST API)
|
||||
- 그룹별 카드 모드 (`groupByMode`)
|
||||
- 일반 메트릭 카드
|
||||
- 자동 새로고침 (30초)
|
||||
|
||||
### 테스트 버전 기능
|
||||
- ✅ **다중 데이터 소스** (REST API + Database 혼합)
|
||||
- ✅ **그룹별 카드 모드** (원본에서 복사 완료)
|
||||
- ✅ **일반 메트릭 카드**
|
||||
- ✅ **자동 새로고침** (설정 가능)
|
||||
- ✅ **수동 새로고침 버튼**
|
||||
- ✅ **마지막 새로고침 시간 표시**
|
||||
- ✅ **상세 정보 모달** (클릭 시 원본 데이터 표시)
|
||||
- ✅ **컬럼 매핑 지원**
|
||||
|
||||
### 🎯 결론
|
||||
**테스트 버전이 원본보다 기능이 많습니다.** 누락된 기능 없음.
|
||||
|
||||
---
|
||||
|
||||
## 2️⃣ RiskAlertWidget vs RiskAlertTestWidget
|
||||
|
||||
### ⚠️ 상태: **검토 필요**
|
||||
|
||||
### 원본 기능
|
||||
- 백엔드 캐시 API 호출 (`/risk-alerts`)
|
||||
- 강제 새로고침 API (`/risk-alerts/refresh`)
|
||||
- **새 알림 애니메이션** (`newAlertIds` 상태)
|
||||
- 새로운 알림 감지
|
||||
- 3초간 애니메이션 표시
|
||||
- 자동으로 애니메이션 제거
|
||||
- 자동 새로고침 (1분)
|
||||
- 알림 타입별 필터링
|
||||
|
||||
### 테스트 버전 기능
|
||||
- ✅ **다중 데이터 소스** (REST API + Database 혼합)
|
||||
- ✅ **알림 타입별 필터링**
|
||||
- ✅ **자동 새로고침** (설정 가능)
|
||||
- ✅ **수동 새로고침 버튼**
|
||||
- ✅ **마지막 새로고침 시간 표시**
|
||||
- ✅ **XML/CSV 데이터 파싱**
|
||||
- ✅ **컬럼 매핑 지원**
|
||||
- ❌ **새 알림 애니메이션** (사용자 요청으로 제외)
|
||||
|
||||
### 🎯 결론
|
||||
**새 알림 애니메이션은 사용자 요청으로 불필요하다고 판단됨.** 다른 누락 기능 없음.
|
||||
|
||||
---
|
||||
|
||||
## 3️⃣ ChartWidget vs ChartTestWidget
|
||||
|
||||
### ✅ 상태: **완료**
|
||||
|
||||
### 원본 기능
|
||||
**❌ 원본 ChartWidget 파일이 존재하지 않습니다!**
|
||||
|
||||
ChartTestWidget은 처음부터 **신규 개발**된 위젯입니다.
|
||||
|
||||
### 테스트 버전 기능
|
||||
- ✅ **다중 데이터 소스** (REST API + Database 혼합)
|
||||
- ✅ **차트 타입**: 라인, 바, 파이, 도넛, 영역
|
||||
- ✅ **혼합 차트** (ComposedChart)
|
||||
- 각 데이터 소스별로 다른 차트 타입 지정 가능
|
||||
- 바 + 라인 + 영역 동시 표시
|
||||
- ✅ **데이터 병합 모드** (`mergeMode`)
|
||||
- 여러 데이터 소스를 하나의 라인/바로 병합
|
||||
- ✅ **자동 새로고침** (설정 가능)
|
||||
- ✅ **수동 새로고침 버튼**
|
||||
- ✅ **마지막 새로고침 시간 표시**
|
||||
- ✅ **컬럼 매핑 지원**
|
||||
|
||||
### 🎯 결론
|
||||
**원본이 없으므로 비교 불필요.** ChartTestWidget은 완전히 새로운 위젯입니다.
|
||||
|
||||
---
|
||||
|
||||
## 4️⃣ ListWidget vs ListTestWidget
|
||||
|
||||
### ✅ 상태: **완료**
|
||||
|
||||
### 원본 기능
|
||||
**❌ 원본 ListWidget 파일이 존재하지 않습니다!**
|
||||
|
||||
ListTestWidget은 처음부터 **신규 개발**된 위젯입니다.
|
||||
|
||||
**참고**: `ListSummaryWidget`이라는 유사한 위젯이 있으나, 현재 **주석 처리**되어 있습니다.
|
||||
|
||||
### 테스트 버전 기능
|
||||
- ✅ **다중 데이터 소스** (REST API + Database 혼합)
|
||||
- ✅ **테이블/카드 뷰 전환**
|
||||
- ✅ **페이지네이션**
|
||||
- ✅ **컬럼 설정** (자동/수동)
|
||||
- ✅ **자동 새로고침** (설정 가능)
|
||||
- ✅ **수동 새로고침 버튼**
|
||||
- ✅ **마지막 새로고침 시간 표시**
|
||||
- ✅ **컬럼 매핑 지원**
|
||||
|
||||
### 🎯 결론
|
||||
**원본이 없으므로 비교 불필요.** ListTestWidget은 완전히 새로운 위젯입니다.
|
||||
|
||||
---
|
||||
|
||||
## 5️⃣ MapSummaryWidget vs MapTestWidgetV2
|
||||
|
||||
### ✅ 상태: **완료**
|
||||
|
||||
### 원본 기능 (MapSummaryWidget)
|
||||
- 단일 데이터 소스 (Database 쿼리)
|
||||
- 마커 표시
|
||||
- VWorld 타일맵 (고정)
|
||||
- **날씨 정보 통합**
|
||||
- 주요 도시 날씨 API 연동
|
||||
- 마커별 날씨 캐싱
|
||||
- **기상특보 표시** (`showWeatherAlerts`)
|
||||
- 육지 기상특보 (GeoJSON 레이어)
|
||||
- 해상 기상특보 (폴리곤)
|
||||
- 하드코딩된 해상 구역 좌표
|
||||
- 자동 새로고침 (30초)
|
||||
- 테이블명 한글 번역
|
||||
|
||||
### 테스트 버전 기능 (MapTestWidgetV2)
|
||||
- ✅ **다중 데이터 소스** (REST API + Database 혼합)
|
||||
- ✅ **마커 표시**
|
||||
- ✅ **폴리곤 표시** (GeoJSON)
|
||||
- ✅ **VWorld 타일맵** (설정 가능)
|
||||
- ✅ **데이터 소스별 색상 설정**
|
||||
- ✅ **자동 새로고침** (설정 가능)
|
||||
- ✅ **수동 새로고침 버튼**
|
||||
- ✅ **마지막 새로고침 시간 표시**
|
||||
- ✅ **컬럼 매핑 지원**
|
||||
- ✅ **XML/CSV 데이터 파싱**
|
||||
- ✅ **지역 코드/이름 → 좌표 변환**
|
||||
- ❌ **날씨 정보 통합** (누락)
|
||||
- ❌ **기상특보 표시** (누락)
|
||||
|
||||
### 🎯 결론
|
||||
**MapTestWidgetV2에 누락된 기능**:
|
||||
1. 날씨 API 통합 (주요 도시 날씨)
|
||||
2. 기상특보 표시 (육지/해상)
|
||||
|
||||
**단, 기상특보는 REST API 데이터 소스로 대체 가능하므로 중요도가 낮습니다.**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 주요 발견 사항
|
||||
|
||||
### 1. 테스트 위젯의 공통 강화 기능
|
||||
|
||||
모든 테스트 위젯은 원본 대비 다음 기능이 **추가**되었습니다:
|
||||
|
||||
- ✅ **다중 데이터 소스 지원**
|
||||
- REST API 다중 연결
|
||||
- Database 다중 연결
|
||||
- REST API + Database 혼합
|
||||
- ✅ **컬럼 매핑**
|
||||
- 서로 다른 데이터 소스의 컬럼명 통일
|
||||
- ✅ **자동 새로고침 간격 설정**
|
||||
- 데이터 소스별 개별 설정
|
||||
- ✅ **수동 새로고침 버튼**
|
||||
- ✅ **마지막 새로고침 시간 표시**
|
||||
- ✅ **XML/CSV 파싱** (Map, RiskAlert)
|
||||
|
||||
### 2. 원본에만 있는 기능 (누락 가능성)
|
||||
|
||||
현재까지 확인된 원본 전용 기능:
|
||||
|
||||
1. **통계 카드 (CustomMetricWidget)**
|
||||
- ~~Group By Mode~~ → **테스트 버전에 추가 완료** ✅
|
||||
|
||||
2. **RiskAlertWidget**
|
||||
- 새 알림 애니메이션 → **사용자 요청으로 제외** ⚠️
|
||||
|
||||
3. **기타 위젯**
|
||||
- 추가 분석 필요 🔍
|
||||
|
||||
### 3. 테스트 위젯 전용 기능
|
||||
|
||||
테스트 버전에만 있는 고급 기능:
|
||||
|
||||
- **ChartTestWidget**: 혼합 차트 (ComposedChart), 데이터 병합 모드
|
||||
- **MapTestWidgetV2**: 폴리곤 표시, 데이터 소스별 색상
|
||||
- **통계 카드 (CustomMetricTestWidget)**: 상세 정보 모달 (원본 데이터 표시)
|
||||
|
||||
---
|
||||
|
||||
## 📋 다음 단계
|
||||
|
||||
### 즉시 수행
|
||||
- [ ] ChartWidget 원본 파일 확인
|
||||
- [ ] ListWidget 원본 파일 확인 (존재 여부)
|
||||
- [ ] MapSummaryWidget 원본 파일 확인
|
||||
|
||||
### 검토 필요
|
||||
- [ ] 사용자에게 새 알림 애니메이션 필요 여부 재확인
|
||||
- [ ] 원본 위젯의 숨겨진 기능 파악
|
||||
|
||||
### 장기 계획
|
||||
- [ ] 테스트 위젯을 원본으로 승격 고려
|
||||
- [ ] 원본 위젯 deprecated 처리 고려
|
||||
|
||||
---
|
||||
|
||||
## 📊 통계
|
||||
|
||||
- **분석 완료**: 5/5 (100%) ✅
|
||||
- **누락 기능 발견**: 3개
|
||||
1. ~~Group By Mode~~ → **해결 완료** ✅
|
||||
2. 날씨 API 통합 (MapTestWidgetV2) → **낮은 우선순위** ⚠️
|
||||
3. 기상특보 표시 (MapTestWidgetV2) → **REST API로 대체 가능** ⚠️
|
||||
- **원본이 없는 위젯**: 2개 (ChartTestWidget, ListTestWidget)
|
||||
- **테스트 버전 추가 기능**: 10개 이상
|
||||
- **전체 평가**: **테스트 버전이 원본보다 기능적으로 우수함** 🏆
|
||||
|
||||
---
|
||||
|
||||
## 🎉 최종 결론
|
||||
|
||||
### ✅ 분석 완료
|
||||
|
||||
모든 테스트 위젯과 원본 위젯의 비교 분석이 완료되었습니다.
|
||||
|
||||
### 🔍 주요 발견
|
||||
|
||||
1. **통계 카드 (CustomMetricTestWidget)**: 원본의 모든 기능 포함 + 다중 데이터 소스 + 상세 모달
|
||||
2. **RiskAlertTestWidget**: 원본의 핵심 기능 포함 + 다중 데이터 소스 (새 알림 애니메이션은 불필요)
|
||||
3. **ChartTestWidget**: 원본 없음 (신규 개발)
|
||||
4. **ListTestWidget**: 원본 없음 (신규 개발)
|
||||
5. **MapTestWidgetV2**: 원본 대비 날씨 API 누락 (REST API로 대체 가능)
|
||||
|
||||
### 📈 테스트 위젯의 우수성
|
||||
|
||||
테스트 위젯은 다음과 같은 **공통 강화 기능**을 제공합니다:
|
||||
|
||||
- ✅ 다중 데이터 소스 (REST API + Database 혼합)
|
||||
- ✅ 컬럼 매핑 (데이터 통합)
|
||||
- ✅ 자동 새로고침 간격 설정
|
||||
- ✅ 수동 새로고침 버튼
|
||||
- ✅ 마지막 새로고침 시간 표시
|
||||
- ✅ XML/CSV 파싱 (Map, RiskAlert)
|
||||
|
||||
### 🎯 권장 사항
|
||||
|
||||
1. **통계 카드 (CustomMetricTestWidget)**: 원본 대체 가능 ✅
|
||||
2. **RiskAlertTestWidget**: 원본 대체 가능 ✅
|
||||
3. **ChartTestWidget**: 이미 프로덕션 준비 완료 ✅
|
||||
4. **ListTestWidget**: 이미 프로덕션 준비 완료 ✅
|
||||
5. **MapTestWidgetV2**: 날씨 기능이 필요하지 않다면 원본 대체 가능 ⚠️
|
||||
|
||||
### 🚀 다음 단계
|
||||
|
||||
- [ ] 테스트 위젯을 원본으로 승격 고려
|
||||
- [ ] 원본 위젯 deprecated 처리 고려
|
||||
- [ ] MapTestWidgetV2에 날씨 API 추가 여부 결정 (선택사항)
|
||||
|
||||
---
|
||||
|
||||
**보고서 작성 완료일**: 2025-10-28
|
||||
**작성자**: AI Assistant
|
||||
**상태**: ✅ 완료
|
||||
|
||||
|
|
@ -908,7 +908,7 @@ export function CanvasElement({
|
|||
<ListTestWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "custom-metric-test" ? (
|
||||
// 🧪 테스트용 커스텀 메트릭 위젯 (다중 데이터 소스)
|
||||
// 🧪 통계 카드 (다중 데이터 소스)
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<CustomMetricTestWidget element={element} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -186,8 +186,8 @@ export function DashboardTopMenu({
|
|||
<SelectItem value="map-test-v2">🧪 지도 테스트 V2</SelectItem>
|
||||
<SelectItem value="chart-test">🧪 차트 테스트</SelectItem>
|
||||
<SelectItem value="list-test">🧪 리스트 테스트</SelectItem>
|
||||
<SelectItem value="custom-metric-test">🧪 커스텀 메트릭 테스트</SelectItem>
|
||||
<SelectItem value="status-summary-test">🧪 상태 요약 테스트</SelectItem>
|
||||
<SelectItem value="custom-metric-test">통계 카드</SelectItem>
|
||||
{/* <SelectItem value="status-summary-test">🧪 상태 요약 테스트</SelectItem> */}
|
||||
<SelectItem value="risk-alert-test">🧪 리스크/알림 테스트</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
|
|
@ -197,7 +197,7 @@ export function DashboardTopMenu({
|
|||
<SelectItem value="yard-management-3d">야드 관리 3D</SelectItem>
|
||||
{/* <SelectItem value="transport-stats">커스텀 통계 카드</SelectItem> */}
|
||||
<SelectItem value="map-summary">커스텀 지도 카드</SelectItem>
|
||||
<SelectItem value="map-test">🧪 지도 테스트 (REST API)</SelectItem>
|
||||
{/* <SelectItem value="map-test">🧪 지도 테스트 (REST API)</SelectItem> */}
|
||||
{/* <SelectItem value="status-summary">커스텀 상태 카드</SelectItem> */}
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { QueryEditor } from "./QueryEditor";
|
|||
import { ChartConfigPanel } from "./ChartConfigPanel";
|
||||
import { VehicleMapConfigPanel } from "./VehicleMapConfigPanel";
|
||||
import { MapTestConfigPanel } from "./MapTestConfigPanel";
|
||||
import { MultiChartConfigPanel } from "./MultiChartConfigPanel";
|
||||
import { DatabaseConfig } from "./data-sources/DatabaseConfig";
|
||||
import { ApiConfig } from "./data-sources/ApiConfig";
|
||||
import MultiDataSourceConfig from "./data-sources/MultiDataSourceConfig";
|
||||
|
|
@ -41,16 +42,41 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
const [customTitle, setCustomTitle] = useState<string>("");
|
||||
const [showHeader, setShowHeader] = useState<boolean>(true);
|
||||
|
||||
// 멀티 데이터 소스의 테스트 결과 저장 (ChartTestWidget용)
|
||||
const [testResults, setTestResults] = useState<Map<string, { columns: string[]; rows: Record<string, unknown>[] }>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
// 사이드바가 열릴 때 초기화
|
||||
useEffect(() => {
|
||||
if (isOpen && element) {
|
||||
console.log("🔄 ElementConfigSidebar 초기화 - element.id:", element.id);
|
||||
console.log("🔄 element.dataSources:", element.dataSources);
|
||||
console.log("🔄 element.chartConfig?.dataSources:", element.chartConfig?.dataSources);
|
||||
|
||||
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 });
|
||||
|
||||
// dataSources는 element.dataSources 또는 chartConfig.dataSources에서 로드
|
||||
setDataSources(element.dataSources || element.chartConfig?.dataSources || []);
|
||||
// ⚠️ 중요: 없으면 반드시 빈 배열로 초기화
|
||||
const initialDataSources = element.dataSources || element.chartConfig?.dataSources || [];
|
||||
console.log("🔄 초기화된 dataSources:", initialDataSources);
|
||||
setDataSources(initialDataSources);
|
||||
|
||||
setChartConfig(element.chartConfig || {});
|
||||
setQueryResult(null);
|
||||
setTestResults(new Map()); // 테스트 결과도 초기화
|
||||
setCustomTitle(element.customTitle || "");
|
||||
setShowHeader(element.showHeader !== false);
|
||||
} else if (!isOpen) {
|
||||
// 사이드바가 닫힐 때 모든 상태 초기화
|
||||
console.log("🧹 ElementConfigSidebar 닫힘 - 상태 초기화");
|
||||
setDataSource({ type: "database", connectionType: "current", refreshInterval: 0 });
|
||||
setDataSources([]);
|
||||
setChartConfig({});
|
||||
setQueryResult(null);
|
||||
setTestResults(new Map());
|
||||
setCustomTitle("");
|
||||
setShowHeader(true);
|
||||
}
|
||||
}, [isOpen, element]);
|
||||
|
||||
|
|
@ -321,7 +347,27 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
{isMultiDataSourceWidget && (
|
||||
<>
|
||||
<div className="rounded-lg bg-white p-3 shadow-sm">
|
||||
<MultiDataSourceConfig dataSources={dataSources} onChange={setDataSources} />
|
||||
<MultiDataSourceConfig
|
||||
dataSources={dataSources}
|
||||
onChange={setDataSources}
|
||||
onTestResult={(result, dataSourceId) => {
|
||||
// API 테스트 결과를 queryResult로 설정 (차트 설정용)
|
||||
setQueryResult({
|
||||
...result,
|
||||
totalRows: result.rows.length,
|
||||
executionTime: 0,
|
||||
});
|
||||
console.log("📊 API 테스트 결과 수신:", result, "데이터 소스 ID:", dataSourceId);
|
||||
|
||||
// ChartTestWidget용: 각 데이터 소스의 테스트 결과 저장
|
||||
setTestResults((prev) => {
|
||||
const updated = new Map(prev);
|
||||
updated.set(dataSourceId, result);
|
||||
console.log("📊 테스트 결과 저장:", dataSourceId, result);
|
||||
return updated;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 지도 테스트 V2: 타일맵 URL 설정 */}
|
||||
|
|
@ -354,6 +400,40 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
</details>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 차트 테스트: 차트 설정 */}
|
||||
{element.subtype === "chart-test" && (
|
||||
<div className="rounded-lg bg-white shadow-sm">
|
||||
<details className="group" open>
|
||||
<summary className="flex cursor-pointer items-center justify-between p-3 hover:bg-gray-50">
|
||||
<div>
|
||||
<div className="text-xs font-semibold tracking-wide text-gray-500 uppercase">차트 설정</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-[10px]">
|
||||
{testResults.size > 0
|
||||
? `${testResults.size}개 데이터 소스 • X축, Y축, 차트 타입 설정`
|
||||
: "먼저 데이터 소스를 추가하고 API 테스트를 실행하세요"}
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className="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="border-t p-3">
|
||||
<MultiChartConfigPanel
|
||||
config={chartConfig}
|
||||
dataSources={dataSources}
|
||||
testResults={testResults}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,327 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ChartConfig, ChartDataSource } from "./types";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
interface MultiChartConfigPanelProps {
|
||||
config: ChartConfig;
|
||||
dataSources: ChartDataSource[];
|
||||
testResults: Map<string, { columns: string[]; rows: Record<string, unknown>[] }>; // 각 데이터 소스의 테스트 결과
|
||||
onConfigChange: (config: ChartConfig) => void;
|
||||
}
|
||||
|
||||
export function MultiChartConfigPanel({
|
||||
config,
|
||||
dataSources,
|
||||
testResults,
|
||||
onConfigChange,
|
||||
}: MultiChartConfigPanelProps) {
|
||||
const [chartType, setChartType] = useState<string>(config.chartType || "line");
|
||||
const [mergeMode, setMergeMode] = useState<boolean>(config.mergeMode || false);
|
||||
const [dataSourceConfigs, setDataSourceConfigs] = useState<
|
||||
Array<{
|
||||
dataSourceId: string;
|
||||
xAxis: string;
|
||||
yAxis: string[];
|
||||
label?: string;
|
||||
}>
|
||||
>(config.dataSourceConfigs || []);
|
||||
|
||||
// 데이터 소스별 사용 가능한 컬럼
|
||||
const getColumnsForDataSource = (dataSourceId: string): string[] => {
|
||||
const result = testResults.get(dataSourceId);
|
||||
return result?.columns || [];
|
||||
};
|
||||
|
||||
// 데이터 소스별 숫자 컬럼
|
||||
const getNumericColumnsForDataSource = (dataSourceId: string): string[] => {
|
||||
const result = testResults.get(dataSourceId);
|
||||
if (!result || !result.rows || result.rows.length === 0) return [];
|
||||
|
||||
const firstRow = result.rows[0];
|
||||
return Object.keys(firstRow).filter((key) => {
|
||||
const value = firstRow[key];
|
||||
return typeof value === "number" || !isNaN(Number(value));
|
||||
});
|
||||
};
|
||||
|
||||
// 차트 타입 변경
|
||||
const handleChartTypeChange = (type: string) => {
|
||||
setChartType(type);
|
||||
onConfigChange({
|
||||
...config,
|
||||
chartType: type,
|
||||
mergeMode,
|
||||
dataSourceConfigs,
|
||||
});
|
||||
};
|
||||
|
||||
// 병합 모드 변경
|
||||
const handleMergeModeChange = (checked: boolean) => {
|
||||
setMergeMode(checked);
|
||||
onConfigChange({
|
||||
...config,
|
||||
chartType,
|
||||
mergeMode: checked,
|
||||
dataSourceConfigs,
|
||||
});
|
||||
};
|
||||
|
||||
// 데이터 소스 설정 추가
|
||||
const handleAddDataSourceConfig = (dataSourceId: string) => {
|
||||
const columns = getColumnsForDataSource(dataSourceId);
|
||||
const numericColumns = getNumericColumnsForDataSource(dataSourceId);
|
||||
|
||||
const newConfig = {
|
||||
dataSourceId,
|
||||
xAxis: columns[0] || "",
|
||||
yAxis: numericColumns.length > 0 ? [numericColumns[0]] : [],
|
||||
label: dataSources.find((ds) => ds.id === dataSourceId)?.name || "",
|
||||
};
|
||||
|
||||
const updated = [...dataSourceConfigs, newConfig];
|
||||
setDataSourceConfigs(updated);
|
||||
onConfigChange({
|
||||
...config,
|
||||
chartType,
|
||||
mergeMode,
|
||||
dataSourceConfigs: updated,
|
||||
});
|
||||
};
|
||||
|
||||
// 데이터 소스 설정 삭제
|
||||
const handleRemoveDataSourceConfig = (dataSourceId: string) => {
|
||||
const updated = dataSourceConfigs.filter((c) => c.dataSourceId !== dataSourceId);
|
||||
setDataSourceConfigs(updated);
|
||||
onConfigChange({
|
||||
...config,
|
||||
chartType,
|
||||
mergeMode,
|
||||
dataSourceConfigs: updated,
|
||||
});
|
||||
};
|
||||
|
||||
// X축 변경
|
||||
const handleXAxisChange = (dataSourceId: string, xAxis: string) => {
|
||||
const updated = dataSourceConfigs.map((c) => (c.dataSourceId === dataSourceId ? { ...c, xAxis } : c));
|
||||
setDataSourceConfigs(updated);
|
||||
onConfigChange({
|
||||
...config,
|
||||
chartType,
|
||||
mergeMode,
|
||||
dataSourceConfigs: updated,
|
||||
});
|
||||
};
|
||||
|
||||
// Y축 변경
|
||||
const handleYAxisChange = (dataSourceId: string, yAxis: string) => {
|
||||
const updated = dataSourceConfigs.map((c) => (c.dataSourceId === dataSourceId ? { ...c, yAxis: [yAxis] } : c));
|
||||
setDataSourceConfigs(updated);
|
||||
onConfigChange({
|
||||
...config,
|
||||
chartType,
|
||||
mergeMode,
|
||||
dataSourceConfigs: updated,
|
||||
});
|
||||
};
|
||||
|
||||
// 🆕 개별 차트 타입 변경
|
||||
const handleIndividualChartTypeChange = (dataSourceId: string, chartType: "bar" | "line" | "area") => {
|
||||
const updated = dataSourceConfigs.map((c) => (c.dataSourceId === dataSourceId ? { ...c, chartType } : c));
|
||||
setDataSourceConfigs(updated);
|
||||
onConfigChange({
|
||||
...config,
|
||||
chartType: "mixed", // 혼합 모드로 설정
|
||||
mergeMode,
|
||||
dataSourceConfigs: updated,
|
||||
});
|
||||
};
|
||||
|
||||
// 설정되지 않은 데이터 소스 (테스트 완료된 것만)
|
||||
const availableDataSources = dataSources.filter(
|
||||
(ds) => testResults.has(ds.id!) && !dataSourceConfigs.some((c) => c.dataSourceId === ds.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 차트 타입 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">차트 타입</Label>
|
||||
<Select value={chartType} onValueChange={handleChartTypeChange}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="차트 타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="line">라인 차트</SelectItem>
|
||||
<SelectItem value="bar">바 차트</SelectItem>
|
||||
<SelectItem value="area">영역 차트</SelectItem>
|
||||
<SelectItem value="pie">파이 차트</SelectItem>
|
||||
<SelectItem value="donut">도넛 차트</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 데이터 병합 모드 */}
|
||||
{dataSourceConfigs.length > 1 && (
|
||||
<div className="bg-muted/50 flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-xs font-medium">데이터 병합 모드</Label>
|
||||
<p className="text-muted-foreground text-[10px]">여러 데이터 소스를 하나의 라인/바로 합쳐서 표시</p>
|
||||
</div>
|
||||
<Switch checked={mergeMode} onCheckedChange={handleMergeModeChange} aria-label="데이터 병합 모드" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 소스별 설정 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">데이터 소스별 축 설정</Label>
|
||||
{availableDataSources.length > 0 && (
|
||||
<Select onValueChange={handleAddDataSourceConfig}>
|
||||
<SelectTrigger className="h-7 w-32 text-xs">
|
||||
<SelectValue placeholder="추가" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableDataSources.map((ds) => (
|
||||
<SelectItem key={ds.id} value={ds.id!} className="text-xs">
|
||||
{ds.name || ds.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dataSourceConfigs.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-4 text-center">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
데이터 소스를 추가하고 API 테스트를 실행한 후<br />위 드롭다운에서 차트에 표시할 데이터를 선택하세요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
dataSourceConfigs.map((dsConfig) => {
|
||||
const dataSource = dataSources.find((ds) => ds.id === dsConfig.dataSourceId);
|
||||
const columns = getColumnsForDataSource(dsConfig.dataSourceId);
|
||||
const numericColumns = getNumericColumnsForDataSource(dsConfig.dataSourceId);
|
||||
|
||||
return (
|
||||
<div key={dsConfig.dataSourceId} className="bg-muted/50 space-y-3 rounded-lg border p-3">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h5 className="text-xs font-semibold">{dataSource?.name || dsConfig.dataSourceId}</h5>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveDataSourceConfig(dsConfig.dataSourceId)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* X축 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">X축 (카테고리/시간)</Label>
|
||||
<Select
|
||||
value={dsConfig.xAxis}
|
||||
onValueChange={(value) => handleXAxisChange(dsConfig.dataSourceId, value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="X축 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Y축 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Y축 (값)</Label>
|
||||
<Select
|
||||
value={dsConfig.yAxis[0] || ""}
|
||||
onValueChange={(value) => handleYAxisChange(dsConfig.dataSourceId, value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Y축 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{numericColumns.map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 🆕 개별 차트 타입 (병합 모드가 아닐 때만) */}
|
||||
{!mergeMode && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">차트 타입</Label>
|
||||
<Select
|
||||
value={dsConfig.chartType || "line"}
|
||||
onValueChange={(value) =>
|
||||
handleIndividualChartTypeChange(dsConfig.dataSourceId, value as "bar" | "line" | "area")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="차트 타입" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="bar" className="text-xs">
|
||||
📊 바 차트
|
||||
</SelectItem>
|
||||
<SelectItem value="line" className="text-xs">
|
||||
📈 라인 차트
|
||||
</SelectItem>
|
||||
<SelectItem value="area" className="text-xs">
|
||||
📉 영역 차트
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 안내 메시지 */}
|
||||
{dataSourceConfigs.length > 0 && (
|
||||
<div className="rounded-lg bg-blue-50 p-3">
|
||||
<p className="text-xs text-blue-900">
|
||||
{mergeMode ? (
|
||||
<>
|
||||
🔗 {dataSourceConfigs.length}개의 데이터 소스가 하나의 라인/바로 병합되어 표시됩니다.
|
||||
<br />
|
||||
<span className="text-[10px]">
|
||||
⚠️ 중요: 첫 번째 데이터 소스의 X축/Y축 컬럼명이 기준이 됩니다.
|
||||
<br />
|
||||
다른 데이터 소스에 동일한 컬럼명이 없으면 해당 데이터는 표시되지 않습니다.
|
||||
<br />
|
||||
💡 컬럼명이 다르면 "컬럼 매핑" 기능을 사용하여 통일하세요.
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
💡 {dataSourceConfigs.length}개의 데이터 소스가 하나의 차트에 표시됩니다.
|
||||
<br />각 데이터 소스마다 다른 차트 타입(바/라인/영역)을 선택할 수 있습니다.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -557,6 +557,55 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* 지도 색상 설정 (MapTestWidgetV2 전용) */}
|
||||
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
|
||||
<h5 className="text-xs font-semibold">🎨 지도 색상 선택</h5>
|
||||
|
||||
{/* 색상 팔레트 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">색상</Label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{[
|
||||
{ name: "파랑", marker: "#3b82f6", polygon: "#3b82f6" },
|
||||
{ name: "빨강", marker: "#ef4444", polygon: "#ef4444" },
|
||||
{ name: "초록", marker: "#10b981", polygon: "#10b981" },
|
||||
{ name: "노랑", marker: "#f59e0b", polygon: "#f59e0b" },
|
||||
{ name: "보라", marker: "#8b5cf6", polygon: "#8b5cf6" },
|
||||
{ name: "주황", marker: "#f97316", polygon: "#f97316" },
|
||||
{ name: "청록", marker: "#06b6d4", polygon: "#06b6d4" },
|
||||
{ name: "분홍", marker: "#ec4899", polygon: "#ec4899" },
|
||||
].map((color) => {
|
||||
const isSelected = dataSource.markerColor === color.marker;
|
||||
return (
|
||||
<button
|
||||
key={color.name}
|
||||
type="button"
|
||||
onClick={() => onChange({
|
||||
markerColor: color.marker,
|
||||
polygonColor: color.polygon,
|
||||
polygonOpacity: 0.5,
|
||||
})}
|
||||
className={`flex h-16 flex-col items-center justify-center gap-1 rounded-md border-2 transition-all hover:scale-105 ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/10 shadow-md"
|
||||
: "border-border bg-background hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="h-6 w-6 rounded-full border-2 border-white shadow-sm"
|
||||
style={{ backgroundColor: color.marker }}
|
||||
/>
|
||||
<span className="text-[10px] font-medium">{color.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
선택한 색상이 마커와 폴리곤에 모두 적용됩니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테스트 버튼 */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<Button
|
||||
|
|
@ -745,6 +794,103 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컬럼 매핑 (API 테스트 성공 후에만 표시) */}
|
||||
{testResult?.success && availableColumns.length > 0 && (
|
||||
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h5 className="text-xs font-semibold">🔄 컬럼 매핑 (선택사항)</h5>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||
다른 데이터 소스와 통합할 때 컬럼명을 통일할 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
{dataSource.columnMapping && Object.keys(dataSource.columnMapping).length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onChange({ columnMapping: {} })}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 매핑 목록 */}
|
||||
{dataSource.columnMapping && Object.keys(dataSource.columnMapping).length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(dataSource.columnMapping).map(([original, mapped]) => (
|
||||
<div key={original} className="flex items-center gap-2">
|
||||
{/* 원본 컬럼 (읽기 전용) */}
|
||||
<Input
|
||||
value={original}
|
||||
disabled
|
||||
className="h-8 flex-1 text-xs bg-muted"
|
||||
/>
|
||||
|
||||
{/* 화살표 */}
|
||||
<span className="text-muted-foreground text-xs">→</span>
|
||||
|
||||
{/* 표시 이름 (편집 가능) */}
|
||||
<Input
|
||||
value={mapped}
|
||||
onChange={(e) => {
|
||||
const newMapping = { ...dataSource.columnMapping };
|
||||
newMapping[original] = e.target.value;
|
||||
onChange({ columnMapping: newMapping });
|
||||
}}
|
||||
placeholder="표시 이름"
|
||||
className="h-8 flex-1 text-xs"
|
||||
/>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newMapping = { ...dataSource.columnMapping };
|
||||
delete newMapping[original];
|
||||
onChange({ columnMapping: newMapping });
|
||||
}}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<XCircle className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 매핑 추가 */}
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(col) => {
|
||||
const newMapping = { ...dataSource.columnMapping } || {};
|
||||
newMapping[col] = col; // 기본값은 원본과 동일
|
||||
onChange({ columnMapping: newMapping });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="컬럼 선택하여 매핑 추가" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableColumns
|
||||
.filter(col => !dataSource.columnMapping || !dataSource.columnMapping[col])
|
||||
.map(col => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
💡 매핑하지 않은 컬럼은 원본 이름 그대로 사용됩니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,11 +20,13 @@ import MultiDatabaseConfig from "./MultiDatabaseConfig";
|
|||
interface MultiDataSourceConfigProps {
|
||||
dataSources: ChartDataSource[];
|
||||
onChange: (dataSources: ChartDataSource[]) => void;
|
||||
onTestResult?: (result: { columns: string[]; rows: any[] }, dataSourceId: string) => void;
|
||||
}
|
||||
|
||||
export default function MultiDataSourceConfig({
|
||||
dataSources = [],
|
||||
onChange,
|
||||
onTestResult,
|
||||
}: MultiDataSourceConfigProps) {
|
||||
const [activeTab, setActiveTab] = useState<string>(
|
||||
dataSources.length > 0 ? dataSources[0].id || "0" : "new"
|
||||
|
|
@ -258,12 +260,24 @@ export default function MultiDataSourceConfig({
|
|||
onTestResult={(data) => {
|
||||
setPreviewData(data);
|
||||
setShowPreview(true);
|
||||
// 부모로 테스트 결과 전달 (차트 설정용)
|
||||
if (onTestResult && data.length > 0 && ds.id) {
|
||||
const columns = Object.keys(data[0]);
|
||||
onTestResult({ columns, rows: data }, ds.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<MultiDatabaseConfig
|
||||
dataSource={ds}
|
||||
onChange={(updates) => handleUpdateDataSource(ds.id!, updates)}
|
||||
onTestResult={(data) => {
|
||||
// 부모로 테스트 결과 전달 (차트 설정용)
|
||||
if (onTestResult && data.length > 0 && ds.id) {
|
||||
const columns = Object.keys(data[0]);
|
||||
onTestResult({ columns, rows: data }, ds.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { Loader2, CheckCircle, XCircle } from "lucide-react";
|
|||
interface MultiDatabaseConfigProps {
|
||||
dataSource: ChartDataSource;
|
||||
onChange: (updates: Partial<ChartDataSource>) => void;
|
||||
onTestResult?: (data: any[]) => void;
|
||||
}
|
||||
|
||||
interface ExternalConnection {
|
||||
|
|
@ -21,7 +22,7 @@ interface ExternalConnection {
|
|||
type: string;
|
||||
}
|
||||
|
||||
export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatabaseConfigProps) {
|
||||
export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult }: MultiDatabaseConfigProps) {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string; rowCount?: number } | null>(null);
|
||||
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
||||
|
|
@ -122,6 +123,11 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
|
|||
message: "쿼리 실행 성공",
|
||||
rowCount,
|
||||
});
|
||||
|
||||
// 부모로 테스트 결과 전달 (차트 설정용)
|
||||
if (onTestResult && rows && rows.length > 0) {
|
||||
onTestResult(rows);
|
||||
}
|
||||
} else {
|
||||
setTestResult({ success: false, message: result.message || "쿼리 실행 실패" });
|
||||
}
|
||||
|
|
@ -166,6 +172,11 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
|
|||
message: "쿼리 실행 성공",
|
||||
rowCount: result.rowCount || 0,
|
||||
});
|
||||
|
||||
// 부모로 테스트 결과 전달 (차트 설정용)
|
||||
if (onTestResult && result.rows && result.rows.length > 0) {
|
||||
onTestResult(result.rows);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
setTestResult({ success: false, message: error.message || "네트워크 오류" });
|
||||
|
|
@ -240,9 +251,61 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
|
|||
|
||||
{/* SQL 쿼리 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor={`query-\${dataSource.id}`} className="text-xs">
|
||||
SQL 쿼리 *
|
||||
</Label>
|
||||
<Select onValueChange={(value) => {
|
||||
const samples = {
|
||||
users: `SELECT
|
||||
dept_name as 부서명,
|
||||
COUNT(*) as 회원수
|
||||
FROM user_info
|
||||
WHERE dept_name IS NOT NULL
|
||||
GROUP BY dept_name
|
||||
ORDER BY 회원수 DESC`,
|
||||
dept: `SELECT
|
||||
dept_code as 부서코드,
|
||||
dept_name as 부서명,
|
||||
location_name as 위치,
|
||||
TO_CHAR(regdate, 'YYYY-MM-DD') as 등록일
|
||||
FROM dept_info
|
||||
ORDER BY dept_code`,
|
||||
usersByDate: `SELECT
|
||||
DATE_TRUNC('month', regdate)::date as 월,
|
||||
COUNT(*) as 신규사용자수
|
||||
FROM user_info
|
||||
WHERE regdate >= CURRENT_DATE - INTERVAL '12 months'
|
||||
GROUP BY DATE_TRUNC('month', regdate)
|
||||
ORDER BY 월`,
|
||||
usersByPosition: `SELECT
|
||||
position_name as 직급,
|
||||
COUNT(*) as 인원수
|
||||
FROM user_info
|
||||
WHERE position_name IS NOT NULL
|
||||
GROUP BY position_name
|
||||
ORDER BY 인원수 DESC`,
|
||||
deptHierarchy: `SELECT
|
||||
COALESCE(parent_dept_code, '최상위') as 상위부서코드,
|
||||
COUNT(*) as 하위부서수
|
||||
FROM dept_info
|
||||
GROUP BY parent_dept_code
|
||||
ORDER BY 하위부서수 DESC`,
|
||||
};
|
||||
onChange({ query: samples[value as keyof typeof samples] || "" });
|
||||
}}>
|
||||
<SelectTrigger className="h-7 w-32 text-xs">
|
||||
<SelectValue placeholder="샘플 쿼리" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="users" className="text-xs">부서별 회원수</SelectItem>
|
||||
<SelectItem value="dept" className="text-xs">부서 목록</SelectItem>
|
||||
<SelectItem value="usersByDate" className="text-xs">월별 신규사용자</SelectItem>
|
||||
<SelectItem value="usersByPosition" className="text-xs">직급별 인원수</SelectItem>
|
||||
<SelectItem value="deptHierarchy" className="text-xs">부서 계층구조</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Textarea
|
||||
id={`query-\${dataSource.id}`}
|
||||
value={dataSource.query || ""}
|
||||
|
|
@ -251,7 +314,7 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
|
|||
className="min-h-[120px] font-mono text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
SELECT 쿼리만 허용됩니다
|
||||
SELECT 쿼리만 허용됩니다. 샘플 쿼리를 선택하여 빠르게 시작할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -283,6 +346,55 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* 지도 색상 설정 (MapTestWidgetV2 전용) */}
|
||||
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
|
||||
<h5 className="text-xs font-semibold">🎨 지도 색상 선택</h5>
|
||||
|
||||
{/* 색상 팔레트 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">색상</Label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{[
|
||||
{ name: "파랑", marker: "#3b82f6", polygon: "#3b82f6" },
|
||||
{ name: "빨강", marker: "#ef4444", polygon: "#ef4444" },
|
||||
{ name: "초록", marker: "#10b981", polygon: "#10b981" },
|
||||
{ name: "노랑", marker: "#f59e0b", polygon: "#f59e0b" },
|
||||
{ name: "보라", marker: "#8b5cf6", polygon: "#8b5cf6" },
|
||||
{ name: "주황", marker: "#f97316", polygon: "#f97316" },
|
||||
{ name: "청록", marker: "#06b6d4", polygon: "#06b6d4" },
|
||||
{ name: "분홍", marker: "#ec4899", polygon: "#ec4899" },
|
||||
].map((color) => {
|
||||
const isSelected = dataSource.markerColor === color.marker;
|
||||
return (
|
||||
<button
|
||||
key={color.name}
|
||||
type="button"
|
||||
onClick={() => onChange({
|
||||
markerColor: color.marker,
|
||||
polygonColor: color.polygon,
|
||||
polygonOpacity: 0.5,
|
||||
})}
|
||||
className={`flex h-16 flex-col items-center justify-center gap-1 rounded-md border-2 transition-all hover:scale-105 ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/10 shadow-md"
|
||||
: "border-border bg-background hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="h-6 w-6 rounded-full border-2 border-white shadow-sm"
|
||||
style={{ backgroundColor: color.marker }}
|
||||
/>
|
||||
<span className="text-[10px] font-medium">{color.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
선택한 색상이 마커와 폴리곤에 모두 적용됩니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테스트 버튼 */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<Button
|
||||
|
|
@ -476,6 +588,103 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컬럼 매핑 (쿼리 테스트 성공 후에만 표시) */}
|
||||
{testResult?.success && availableColumns.length > 0 && (
|
||||
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h5 className="text-xs font-semibold">🔄 컬럼 매핑 (선택사항)</h5>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||
다른 데이터 소스와 통합할 때 컬럼명을 통일할 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
{dataSource.columnMapping && Object.keys(dataSource.columnMapping).length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onChange({ columnMapping: {} })}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 매핑 목록 */}
|
||||
{dataSource.columnMapping && Object.keys(dataSource.columnMapping).length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(dataSource.columnMapping).map(([original, mapped]) => (
|
||||
<div key={original} className="flex items-center gap-2">
|
||||
{/* 원본 컬럼 (읽기 전용) */}
|
||||
<Input
|
||||
value={original}
|
||||
disabled
|
||||
className="h-8 flex-1 text-xs bg-muted"
|
||||
/>
|
||||
|
||||
{/* 화살표 */}
|
||||
<span className="text-muted-foreground text-xs">→</span>
|
||||
|
||||
{/* 표시 이름 (편집 가능) */}
|
||||
<Input
|
||||
value={mapped}
|
||||
onChange={(e) => {
|
||||
const newMapping = { ...dataSource.columnMapping };
|
||||
newMapping[original] = e.target.value;
|
||||
onChange({ columnMapping: newMapping });
|
||||
}}
|
||||
placeholder="표시 이름"
|
||||
className="h-8 flex-1 text-xs"
|
||||
/>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newMapping = { ...dataSource.columnMapping };
|
||||
delete newMapping[original];
|
||||
onChange({ columnMapping: newMapping });
|
||||
}}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<XCircle className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 매핑 추가 */}
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(col) => {
|
||||
const newMapping = { ...dataSource.columnMapping } || {};
|
||||
newMapping[col] = col; // 기본값은 원본과 동일
|
||||
onChange({ columnMapping: newMapping });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="컬럼 선택하여 매핑 추가" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableColumns
|
||||
.filter(col => !dataSource.columnMapping || !dataSource.columnMapping[col])
|
||||
.map(col => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
💡 매핑하지 않은 컬럼은 원본 이름 그대로 사용됩니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,12 +23,12 @@ export type ElementSubtype =
|
|||
| "vehicle-list" // (구버전 - 호환용)
|
||||
| "vehicle-map" // (구버전 - 호환용)
|
||||
| "map-summary" // 범용 지도 카드 (통합)
|
||||
| "map-test" // 🧪 지도 테스트 위젯 (REST API 지원)
|
||||
// | "map-test" // 🧪 지도 테스트 위젯 (REST API 지원) - V2로 대체
|
||||
| "map-test-v2" // 🧪 지도 테스트 V2 (다중 데이터 소스)
|
||||
| "chart-test" // 🧪 차트 테스트 (다중 데이터 소스)
|
||||
| "list-test" // 🧪 리스트 테스트 (다중 데이터 소스)
|
||||
| "custom-metric-test" // 🧪 커스텀 메트릭 테스트 (다중 데이터 소스)
|
||||
| "status-summary-test" // 🧪 상태 요약 테스트 (다중 데이터 소스)
|
||||
| "custom-metric-test" // 🧪 통계 카드 (다중 데이터 소스)
|
||||
// | "status-summary-test" // 🧪 상태 요약 테스트 (CustomMetricTest로 대체 가능)
|
||||
| "risk-alert-test" // 🧪 리스크/알림 테스트 (다중 데이터 소스)
|
||||
| "delivery-status"
|
||||
| "status-summary" // 범용 상태 카드 (통합)
|
||||
|
|
@ -155,6 +155,14 @@ export interface ChartDataSource {
|
|||
lastError?: string; // 마지막 오류 메시지
|
||||
mapDisplayType?: "auto" | "marker" | "polygon"; // 지도 표시 방식 (auto: 자동, marker: 마커, polygon: 영역)
|
||||
|
||||
// 지도 색상 설정 (MapTestWidgetV2용)
|
||||
markerColor?: string; // 마커 색상 (예: "#ff0000")
|
||||
polygonColor?: string; // 폴리곤 색상 (예: "#0000ff")
|
||||
polygonOpacity?: number; // 폴리곤 투명도 (0.0 ~ 1.0, 기본값: 0.5)
|
||||
|
||||
// 컬럼 매핑 (다중 데이터 소스 통합용)
|
||||
columnMapping?: Record<string, string>; // { 원본컬럼: 표시이름 } (예: { "name": "product" })
|
||||
|
||||
// 메트릭 설정 (CustomMetricTestWidget용)
|
||||
selectedColumns?: string[]; // 표시할 컬럼 선택 (빈 배열이면 전체 표시)
|
||||
}
|
||||
|
|
@ -163,7 +171,18 @@ export interface ChartConfig {
|
|||
// 다중 데이터 소스 (테스트 위젯용)
|
||||
dataSources?: ChartDataSource[]; // 여러 데이터 소스 (REST API + Database 혼합 가능)
|
||||
|
||||
// 축 매핑
|
||||
// 멀티 차트 설정 (ChartTestWidget용)
|
||||
chartType?: string; // 차트 타입 (line, bar, pie, etc.)
|
||||
mergeMode?: boolean; // 데이터 병합 모드 (여러 데이터 소스를 하나의 라인/바로 합침)
|
||||
dataSourceConfigs?: Array<{
|
||||
dataSourceId: string; // 데이터 소스 ID
|
||||
xAxis: string; // X축 필드명
|
||||
yAxis: string[]; // Y축 필드명 배열
|
||||
label?: string; // 데이터 소스 라벨
|
||||
chartType?: "bar" | "line" | "area"; // 🆕 각 데이터 소스별 차트 타입 (바/라인/영역 혼합 가능)
|
||||
}>;
|
||||
|
||||
// 축 매핑 (단일 데이터 소스용)
|
||||
xAxis?: string; // X축 필드명
|
||||
yAxis?: string | string[]; // Y축 필드명 (다중 가능)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import React, { useEffect, useState, useCallback, useMemo } from "react";
|
|||
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { applyColumnMapping } from "@/lib/utils/columnMapping";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
|
|
@ -18,6 +19,8 @@ import {
|
|||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
ComposedChart, // 🆕 바/라인/영역 혼합 차트
|
||||
Area, // 🆕 영역 차트
|
||||
} from "recharts";
|
||||
|
||||
interface ChartTestWidgetProps {
|
||||
|
|
@ -70,7 +73,7 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
|||
console.error(`❌ 데이터 소스 "\${source.name || source.id}" 로딩 실패:`, err);
|
||||
return [];
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// 성공한 데이터만 병합
|
||||
|
|
@ -155,7 +158,10 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
|||
}
|
||||
}
|
||||
|
||||
return Array.isArray(apiData) ? apiData : [apiData];
|
||||
const rows = Array.isArray(apiData) ? apiData : [apiData];
|
||||
|
||||
// 컬럼 매핑 적용
|
||||
return applyColumnMapping(rows, source.columnMapping);
|
||||
};
|
||||
|
||||
// Database 데이터 로딩
|
||||
|
|
@ -164,27 +170,51 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
|||
throw new Error("SQL 쿼리가 없습니다.");
|
||||
}
|
||||
|
||||
const response = await fetch("/api/dashboards/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
connectionType: source.connectionType || "current",
|
||||
externalConnectionId: source.externalConnectionId,
|
||||
query: source.query,
|
||||
}),
|
||||
});
|
||||
let result;
|
||||
if (source.connectionType === "external" && source.externalConnectionId) {
|
||||
// 외부 DB (ExternalDbConnectionAPI 사용)
|
||||
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
||||
result = await ExternalDbConnectionAPI.executeQuery(parseInt(source.externalConnectionId), source.query);
|
||||
} else {
|
||||
// 현재 DB (dashboardApi.executeQuery 사용)
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`데이터베이스 쿼리 실패: \${response.status}`);
|
||||
try {
|
||||
const queryResult = await dashboardApi.executeQuery(source.query);
|
||||
result = {
|
||||
success: true,
|
||||
rows: queryResult.rows || [],
|
||||
};
|
||||
} catch (err: any) {
|
||||
console.error("❌ 내부 DB 쿼리 실패:", err);
|
||||
throw new Error(err.message || "쿼리 실패");
|
||||
}
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "쿼리 실패");
|
||||
}
|
||||
|
||||
return result.data || [];
|
||||
const rows = result.rows || result.data || [];
|
||||
|
||||
console.log("💾 내부 DB 쿼리 결과:", {
|
||||
hasRows: !!rows,
|
||||
rowCount: rows.length,
|
||||
hasColumns: rows.length > 0 && Object.keys(rows[0]).length > 0,
|
||||
columnCount: rows.length > 0 ? Object.keys(rows[0]).length : 0,
|
||||
firstRow: rows[0],
|
||||
});
|
||||
|
||||
// 컬럼 매핑 적용
|
||||
const mappedRows = applyColumnMapping(rows, source.columnMapping);
|
||||
|
||||
console.log("✅ 매핑 후:", {
|
||||
columns: mappedRows.length > 0 ? Object.keys(mappedRows[0]) : [],
|
||||
rowCount: mappedRows.length,
|
||||
firstMappedRow: mappedRows[0],
|
||||
});
|
||||
|
||||
return mappedRows;
|
||||
};
|
||||
|
||||
// 초기 로드
|
||||
|
|
@ -218,32 +248,90 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
|||
};
|
||||
}, [dataSources, loadMultipleDataSources]);
|
||||
|
||||
const chartType = element?.subtype || "line";
|
||||
const chartConfig = element?.chartConfig || {};
|
||||
const chartType = chartConfig.chartType || "line";
|
||||
const mergeMode = chartConfig.mergeMode || false;
|
||||
const dataSourceConfigs = chartConfig.dataSourceConfigs || [];
|
||||
|
||||
// 멀티 데이터 소스 차트 렌더링
|
||||
const renderChart = () => {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">데이터가 없습니다</p>
|
||||
<p className="text-muted-foreground text-sm">데이터가 없습니다</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const xAxis = chartConfig.xAxis || Object.keys(data[0])[0];
|
||||
const yAxis = chartConfig.yAxis || Object.keys(data[0])[1];
|
||||
if (dataSourceConfigs.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
차트 설정에서 데이터 소스를 추가하고
|
||||
<br />
|
||||
X축, Y축을 설정해주세요
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 병합 모드: 여러 데이터 소스를 하나의 라인/바로 합침
|
||||
if (mergeMode && dataSourceConfigs.length > 1) {
|
||||
const chartData: any[] = [];
|
||||
const allXValues = new Set<string>();
|
||||
|
||||
// 첫 번째 데이터 소스의 설정을 기준으로 사용
|
||||
const baseConfig = dataSourceConfigs[0];
|
||||
const xAxisField = baseConfig.xAxis;
|
||||
const yAxisField = baseConfig.yAxis[0];
|
||||
|
||||
// 모든 데이터 소스에서 데이터 수집 (X축 값 기준)
|
||||
dataSourceConfigs.forEach((dsConfig) => {
|
||||
const sourceName = dataSources.find((ds) => ds.id === dsConfig.dataSourceId)?.name;
|
||||
const sourceData = data.filter((item) => item._source === sourceName);
|
||||
|
||||
sourceData.forEach((item) => {
|
||||
const xValue = item[xAxisField];
|
||||
if (xValue !== undefined) {
|
||||
allXValues.add(String(xValue));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// X축 값별로 Y축 값 합산
|
||||
allXValues.forEach((xValue) => {
|
||||
const dataPoint: any = { _xValue: xValue };
|
||||
let totalYValue = 0;
|
||||
|
||||
dataSourceConfigs.forEach((dsConfig) => {
|
||||
const sourceName = dataSources.find((ds) => ds.id === dsConfig.dataSourceId)?.name;
|
||||
const sourceData = data.filter((item) => item._source === sourceName);
|
||||
const matchingItem = sourceData.find((item) => String(item[xAxisField]) === xValue);
|
||||
|
||||
if (matchingItem && yAxisField) {
|
||||
const yValue = parseFloat(matchingItem[yAxisField]) || 0;
|
||||
totalYValue += yValue;
|
||||
}
|
||||
});
|
||||
|
||||
dataPoint[yAxisField] = totalYValue;
|
||||
chartData.push(dataPoint);
|
||||
});
|
||||
|
||||
console.log("🔗 병합 모드 차트 데이터:", chartData);
|
||||
|
||||
// 병합 모드 차트 렌더링
|
||||
switch (chartType) {
|
||||
case "line":
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey={xAxis} />
|
||||
<XAxis dataKey="_xValue" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey={yAxis} stroke="#3b82f6" strokeWidth={2} />
|
||||
<Line type="monotone" dataKey={yAxisField} name={yAxisField} stroke={COLORS[0]} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
|
@ -251,65 +339,251 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
|||
case "bar":
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey={xAxis} />
|
||||
<XAxis dataKey="_xValue" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey={yAxis} fill="#3b82f6" />
|
||||
<Bar dataKey={yAxisField} name={yAxisField} fill={COLORS[0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
||||
case "pie":
|
||||
case "area":
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey={yAxis}
|
||||
nameKey={xAxis}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={80}
|
||||
label
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={`cell-\${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="_xValue" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={yAxisField}
|
||||
name={yAxisField}
|
||||
stroke={COLORS[0]}
|
||||
fill={COLORS[0]}
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
지원하지 않는 차트 타입: {chartType}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">병합 모드는 라인, 바, 영역 차트만 지원합니다</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 일반 모드: 각 데이터 소스를 별도의 라인/바로 표시
|
||||
const chartData: any[] = [];
|
||||
const allXValues = new Set<string>();
|
||||
|
||||
// 1단계: 모든 X축 값 수집
|
||||
dataSourceConfigs.forEach((dsConfig) => {
|
||||
const sourceData = data.filter((item) => {
|
||||
const sourceName = dataSources.find((ds) => ds.id === dsConfig.dataSourceId)?.name;
|
||||
return item._source === sourceName;
|
||||
});
|
||||
|
||||
sourceData.forEach((item) => {
|
||||
const xValue = item[dsConfig.xAxis];
|
||||
if (xValue !== undefined) {
|
||||
allXValues.add(String(xValue));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 2단계: X축 값별로 데이터 병합
|
||||
allXValues.forEach((xValue) => {
|
||||
const dataPoint: any = { _xValue: xValue };
|
||||
|
||||
dataSourceConfigs.forEach((dsConfig, index) => {
|
||||
const sourceName = dataSources.find((ds) => ds.id === dsConfig.dataSourceId)?.name || `소스 ${index + 1}`;
|
||||
const sourceData = data.filter((item) => item._source === sourceName);
|
||||
const matchingItem = sourceData.find((item) => String(item[dsConfig.xAxis]) === xValue);
|
||||
|
||||
if (matchingItem && dsConfig.yAxis.length > 0) {
|
||||
const yField = dsConfig.yAxis[0];
|
||||
dataPoint[`${sourceName}_${yField}`] = matchingItem[yField];
|
||||
}
|
||||
});
|
||||
|
||||
chartData.push(dataPoint);
|
||||
});
|
||||
|
||||
console.log("📊 일반 모드 차트 데이터:", chartData);
|
||||
console.log("📊 데이터 소스 설정:", dataSourceConfigs);
|
||||
|
||||
// 🆕 혼합 차트 타입 감지 (각 데이터 소스마다 다른 차트 타입이 설정된 경우)
|
||||
const isMixedChart = dataSourceConfigs.some((dsConfig) => dsConfig.chartType);
|
||||
const effectiveChartType = isMixedChart ? "mixed" : chartType;
|
||||
|
||||
// 차트 타입별 렌더링
|
||||
switch (effectiveChartType) {
|
||||
case "mixed":
|
||||
case "line":
|
||||
case "bar":
|
||||
case "area":
|
||||
// 🆕 ComposedChart 사용 (바/라인/영역 혼합 가능)
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="_xValue" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
{dataSourceConfigs.map((dsConfig, index) => {
|
||||
const sourceName =
|
||||
dataSources.find((ds) => ds.id === dsConfig.dataSourceId)?.name || `소스 ${index + 1}`;
|
||||
const yField = dsConfig.yAxis[0];
|
||||
const dataKey = `${sourceName}_${yField}`;
|
||||
const label = dsConfig.label || sourceName;
|
||||
const color = COLORS[index % COLORS.length];
|
||||
|
||||
// 개별 차트 타입 또는 전역 차트 타입 사용
|
||||
const individualChartType = dsConfig.chartType || chartType;
|
||||
|
||||
// 차트 타입에 따라 다른 컴포넌트 렌더링
|
||||
switch (individualChartType) {
|
||||
case "bar":
|
||||
return <Bar key={dsConfig.dataSourceId} dataKey={dataKey} name={label} fill={color} />;
|
||||
case "area":
|
||||
return (
|
||||
<Area
|
||||
key={dsConfig.dataSourceId}
|
||||
type="monotone"
|
||||
dataKey={dataKey}
|
||||
name={label}
|
||||
stroke={color}
|
||||
fill={color}
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
);
|
||||
case "line":
|
||||
default:
|
||||
return (
|
||||
<Line
|
||||
key={dsConfig.dataSourceId}
|
||||
type="monotone"
|
||||
dataKey={dataKey}
|
||||
name={label}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
||||
case "pie":
|
||||
case "donut":
|
||||
// 파이 차트는 첫 번째 데이터 소스만 사용
|
||||
if (dataSourceConfigs.length > 0) {
|
||||
const firstConfig = dataSourceConfigs[0];
|
||||
const sourceName = dataSources.find((ds) => ds.id === firstConfig.dataSourceId)?.name;
|
||||
|
||||
// 해당 데이터 소스의 데이터만 필터링
|
||||
const sourceData = data.filter((item) => item._source === sourceName);
|
||||
|
||||
console.log("🍩 도넛/파이 차트 데이터:", {
|
||||
sourceName,
|
||||
totalData: data.length,
|
||||
filteredData: sourceData.length,
|
||||
firstConfig,
|
||||
sampleItem: sourceData[0],
|
||||
});
|
||||
|
||||
// 파이 차트용 데이터 변환
|
||||
const pieData = sourceData.map((item) => ({
|
||||
name: String(item[firstConfig.xAxis] || "Unknown"),
|
||||
value: Number(item[firstConfig.yAxis[0]]) || 0,
|
||||
}));
|
||||
|
||||
console.log("🍩 변환된 파이 데이터:", pieData);
|
||||
console.log("🍩 첫 번째 데이터:", pieData[0]);
|
||||
console.log("🍩 데이터 타입 체크:", {
|
||||
firstValue: pieData[0]?.value,
|
||||
valueType: typeof pieData[0]?.value,
|
||||
isNumber: typeof pieData[0]?.value === "number",
|
||||
});
|
||||
|
||||
if (pieData.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">파이 차트에 표시할 데이터가 없습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// value가 모두 0인지 체크
|
||||
const totalValue = pieData.reduce((sum, item) => sum + (item.value || 0), 0);
|
||||
if (totalValue === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">모든 값이 0입니다. Y축 필드를 확인해주세요.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius="70%"
|
||||
innerRadius={chartType === "donut" ? "45%" : 0}
|
||||
label={(entry) => `${entry.name}: ${entry.value}`}
|
||||
labelLine={true}
|
||||
fill="#8884d8"
|
||||
>
|
||||
{pieData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend verticalAlign="bottom" height={36} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">파이 차트를 표시하려면 데이터 소스를 설정하세요.</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">지원하지 않는 차트 타입: {chartType}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-background">
|
||||
<div className="bg-background flex h-full w-full flex-col">
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{element?.customTitle || "차트 테스트 (다중 데이터 소스)"}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<h3 className="text-lg font-semibold">{element?.customTitle || "차트"}</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{dataSources?.length || 0}개 데이터 소스 • {data.length}개 데이터
|
||||
{lastRefreshTime && (
|
||||
<span className="ml-2">
|
||||
• {lastRefreshTime.toLocaleTimeString("ko-KR")}
|
||||
</span>
|
||||
)}
|
||||
{lastRefreshTime && <span className="ml-2">• {lastRefreshTime.toLocaleTimeString("ko-KR")}</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -330,13 +604,12 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
|||
<div className="flex-1 p-4">
|
||||
{error ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<p className="text-destructive text-sm">{error}</p>
|
||||
</div>
|
||||
) : !(element?.dataSources || element?.chartConfig?.dataSources) || (element?.dataSources || element?.chartConfig?.dataSources)?.length === 0 ? (
|
||||
) : !(element?.dataSources || element?.chartConfig?.dataSources) ||
|
||||
(element?.dataSources || element?.chartConfig?.dataSources)?.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
데이터 소스를 연결해주세요
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">데이터 소스를 연결해주세요</p>
|
||||
</div>
|
||||
) : (
|
||||
renderChart()
|
||||
|
|
@ -344,9 +617,7 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
|||
</div>
|
||||
|
||||
{data.length > 0 && (
|
||||
<div className="border-t p-2 text-xs text-muted-foreground">
|
||||
총 {data.length}개 데이터 표시 중
|
||||
</div>
|
||||
<div className="text-muted-foreground border-t p-2 text-xs">총 {data.length}개 데이터 표시 중</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|||
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { applyColumnMapping } from "@/lib/utils/columnMapping";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
|
||||
interface CustomMetricTestWidgetProps {
|
||||
element: DashboardElement;
|
||||
|
|
@ -45,7 +48,7 @@ const colorMap = {
|
|||
};
|
||||
|
||||
/**
|
||||
* 커스텀 메트릭 테스트 위젯 (다중 데이터 소스 지원)
|
||||
* 통계 카드 위젯 (다중 데이터 소스 지원)
|
||||
* - 여러 REST API 연결 가능
|
||||
* - 여러 Database 연결 가능
|
||||
* - REST API + Database 혼합 가능
|
||||
|
|
@ -53,9 +56,12 @@ const colorMap = {
|
|||
*/
|
||||
export default function CustomMetricTestWidget({ element }: CustomMetricTestWidgetProps) {
|
||||
const [metrics, setMetrics] = useState<any[]>([]);
|
||||
const [groupedCards, setGroupedCards] = useState<Array<{ label: string; value: number }>>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
||||
const [selectedMetric, setSelectedMetric] = useState<any | null>(null);
|
||||
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||
|
||||
console.log("🧪 CustomMetricTestWidget 렌더링!", element);
|
||||
|
||||
|
|
@ -63,18 +69,94 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
return element?.dataSources || element?.chartConfig?.dataSources;
|
||||
}, [element?.dataSources, element?.chartConfig?.dataSources]);
|
||||
|
||||
// 🆕 그룹별 카드 모드 체크
|
||||
const isGroupByMode = element?.customMetricConfig?.groupByMode || false;
|
||||
|
||||
// 메트릭 설정 (없으면 기본값 사용) - useMemo로 메모이제이션
|
||||
const metricConfig = useMemo(() => {
|
||||
return element?.customMetricConfig?.metrics || [
|
||||
return (
|
||||
element?.customMetricConfig?.metrics || [
|
||||
{
|
||||
label: "총 개수",
|
||||
field: "id",
|
||||
aggregation: "count",
|
||||
color: "indigo",
|
||||
},
|
||||
];
|
||||
]
|
||||
);
|
||||
}, [element?.customMetricConfig?.metrics]);
|
||||
|
||||
// 🆕 그룹별 카드 데이터 로드 (원본에서 복사)
|
||||
const loadGroupByData = useCallback(async () => {
|
||||
const groupByDS = element?.customMetricConfig?.groupByDataSource;
|
||||
if (!groupByDS) return;
|
||||
|
||||
const dataSourceType = groupByDS.type;
|
||||
|
||||
// Database 타입
|
||||
if (dataSourceType === "database") {
|
||||
if (!groupByDS.query) return;
|
||||
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
const result = await dashboardApi.executeQuery(groupByDS.query);
|
||||
|
||||
if (result.success && result.data?.rows) {
|
||||
const rows = result.data.rows;
|
||||
if (rows.length > 0) {
|
||||
const columns = result.data.columns || Object.keys(rows[0]);
|
||||
const labelColumn = columns[0];
|
||||
const valueColumn = columns[1];
|
||||
|
||||
const cards = rows.map((row: any) => ({
|
||||
label: String(row[labelColumn] || ""),
|
||||
value: parseFloat(row[valueColumn]) || 0,
|
||||
}));
|
||||
|
||||
setGroupedCards(cards);
|
||||
}
|
||||
}
|
||||
}
|
||||
// API 타입
|
||||
else if (dataSourceType === "api") {
|
||||
if (!groupByDS.endpoint) return;
|
||||
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
const result = await dashboardApi.fetchExternalApi({
|
||||
method: "GET",
|
||||
url: groupByDS.endpoint,
|
||||
headers: (groupByDS as any).headers || {},
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
let rows: any[] = [];
|
||||
if (Array.isArray(result.data)) {
|
||||
rows = result.data;
|
||||
} else if (result.data.results && Array.isArray(result.data.results)) {
|
||||
rows = result.data.results;
|
||||
} else if (result.data.items && Array.isArray(result.data.items)) {
|
||||
rows = result.data.items;
|
||||
} else if (result.data.data && Array.isArray(result.data.data)) {
|
||||
rows = result.data.data;
|
||||
} else {
|
||||
rows = [result.data];
|
||||
}
|
||||
|
||||
if (rows.length > 0) {
|
||||
const columns = Object.keys(rows[0]);
|
||||
const labelColumn = columns[0];
|
||||
const valueColumn = columns[1];
|
||||
|
||||
const cards = rows.map((row: any) => ({
|
||||
label: String(row[labelColumn] || ""),
|
||||
value: parseFloat(row[valueColumn]) || 0,
|
||||
}));
|
||||
|
||||
setGroupedCards(cards);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [element?.customMetricConfig?.groupByDataSource]);
|
||||
|
||||
// 다중 데이터 소스 로딩
|
||||
const loadMultipleDataSources = useCallback(async () => {
|
||||
const dataSources = element?.dataSources || element?.chartConfig?.dataSources;
|
||||
|
|
@ -117,7 +199,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
rows: [],
|
||||
};
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
console.log(`✅ 총 ${results.length}개의 데이터 소스 로딩 완료`);
|
||||
|
|
@ -141,21 +223,21 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
const columns = Object.keys(firstRow);
|
||||
|
||||
// 숫자 컬럼 찾기
|
||||
const numericColumns = columns.filter(col => {
|
||||
const numericColumns = columns.filter((col) => {
|
||||
const value = firstRow[col];
|
||||
return typeof value === 'number' || !isNaN(Number(value));
|
||||
return typeof value === "number" || !isNaN(Number(value));
|
||||
});
|
||||
|
||||
// 문자열 컬럼 찾기
|
||||
const stringColumns = columns.filter(col => {
|
||||
const stringColumns = columns.filter((col) => {
|
||||
const value = firstRow[col];
|
||||
return typeof value === 'string' || !numericColumns.includes(col);
|
||||
return typeof value === "string" || !numericColumns.includes(col);
|
||||
});
|
||||
|
||||
console.log(`📊 [${sourceName}] 컬럼 분석:`, {
|
||||
전체: columns,
|
||||
숫자: numericColumns,
|
||||
문자열: stringColumns
|
||||
문자열: stringColumns,
|
||||
});
|
||||
|
||||
// 숫자 컬럼이 있으면 집계된 데이터로 판단
|
||||
|
|
@ -180,6 +262,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
aggregation: "custom",
|
||||
color: colors[allMetrics.length % colors.length],
|
||||
sourceName: sourceName,
|
||||
rawData: rows, // 원본 데이터 저장
|
||||
});
|
||||
});
|
||||
} else {
|
||||
|
|
@ -188,7 +271,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
|
||||
// 데이터 소스에서 선택된 컬럼 가져오기
|
||||
const dataSourceConfig = (element?.dataSources || element?.chartConfig?.dataSources)?.find(
|
||||
ds => ds.name === sourceName || ds.id === result.value.sourceIndex.toString()
|
||||
(ds) => ds.name === sourceName || ds.id === result.value.sourceIndex.toString(),
|
||||
);
|
||||
const selectedColumns = dataSourceConfig?.selectedColumns || [];
|
||||
|
||||
|
|
@ -205,7 +288,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
}
|
||||
|
||||
// 해당 컬럼의 고유값 개수 계산
|
||||
const uniqueValues = new Set(rows.map(row => row[col]));
|
||||
const uniqueValues = new Set(rows.map((row) => row[col]));
|
||||
const uniqueCount = uniqueValues.size;
|
||||
|
||||
console.log(` [${sourceName}] ${col}: ${uniqueCount}개 고유값`);
|
||||
|
|
@ -217,6 +300,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
aggregation: "distinct",
|
||||
color: colors[allMetrics.length % colors.length],
|
||||
sourceName: sourceName,
|
||||
rawData: rows, // 원본 데이터 저장
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -228,6 +312,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
aggregation: "count",
|
||||
color: colors[allMetrics.length % colors.length],
|
||||
sourceName: sourceName,
|
||||
rawData: rows, // 원본 데이터 저장
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
|
@ -239,7 +324,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
|
||||
// 데이터 소스에서 선택된 컬럼 가져오기
|
||||
const dataSourceConfig = (element?.dataSources || element?.chartConfig?.dataSources)?.find(
|
||||
ds => ds.name === sourceName || ds.id === result.value.sourceIndex.toString()
|
||||
(ds) => ds.name === sourceName || ds.id === result.value.sourceIndex.toString(),
|
||||
);
|
||||
const selectedColumns = dataSourceConfig?.selectedColumns || [];
|
||||
|
||||
|
|
@ -256,7 +341,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
return;
|
||||
}
|
||||
|
||||
const uniqueValues = new Set(rows.map(row => row[col]));
|
||||
const uniqueValues = new Set(rows.map((row) => row[col]));
|
||||
const uniqueCount = uniqueValues.size;
|
||||
|
||||
console.log(` [${sourceName}] ${col}: ${uniqueCount}개 고유값`);
|
||||
|
|
@ -268,6 +353,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
aggregation: "distinct",
|
||||
color: colors[allMetrics.length % colors.length],
|
||||
sourceName: sourceName,
|
||||
rawData: rows, // 원본 데이터 저장
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -279,6 +365,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
aggregation: "count",
|
||||
color: colors[allMetrics.length % colors.length],
|
||||
sourceName: sourceName,
|
||||
rawData: rows, // 원본 데이터 저장
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -293,11 +380,40 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
}
|
||||
}, [element?.dataSources, element?.chartConfig?.dataSources, metricConfig]);
|
||||
|
||||
// 🆕 통합 데이터 로딩 (그룹별 카드 + 일반 메트릭)
|
||||
const loadAllData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 그룹별 카드 데이터 로드
|
||||
if (isGroupByMode && element?.customMetricConfig?.groupByDataSource) {
|
||||
await loadGroupByData();
|
||||
}
|
||||
|
||||
// 일반 메트릭 데이터 로드
|
||||
if (dataSources && dataSources.length > 0) {
|
||||
await loadMultipleDataSources();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("데이터 로드 실패:", err);
|
||||
setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [
|
||||
isGroupByMode,
|
||||
element?.customMetricConfig?.groupByDataSource,
|
||||
dataSources,
|
||||
loadGroupByData,
|
||||
loadMultipleDataSources,
|
||||
]);
|
||||
|
||||
// 수동 새로고침 핸들러
|
||||
const handleManualRefresh = useCallback(() => {
|
||||
console.log("🔄 수동 새로고침 버튼 클릭");
|
||||
loadMultipleDataSources();
|
||||
}, [loadMultipleDataSources]);
|
||||
loadAllData();
|
||||
}, [loadAllData]);
|
||||
|
||||
// XML 데이터 파싱
|
||||
const parseXmlData = (xmlText: string): any[] => {
|
||||
|
|
@ -344,7 +460,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
const lines = text.trim().split("\n");
|
||||
if (lines.length === 0) return [];
|
||||
|
||||
const headers = lines[0].split(",").map(h => h.trim());
|
||||
const headers = lines[0].split(",").map((h) => h.trim());
|
||||
const result: any[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
|
|
@ -455,7 +571,10 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
}
|
||||
}
|
||||
|
||||
return Array.isArray(processedData) ? processedData : [processedData];
|
||||
const rows = Array.isArray(processedData) ? processedData : [processedData];
|
||||
|
||||
// 컬럼 매핑 적용
|
||||
return applyColumnMapping(rows, source.columnMapping);
|
||||
};
|
||||
|
||||
// Database 데이터 로딩
|
||||
|
|
@ -464,6 +583,8 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
throw new Error("SQL 쿼리가 없습니다.");
|
||||
}
|
||||
|
||||
let rows: any[] = [];
|
||||
|
||||
if (source.connectionType === "external" && source.externalConnectionId) {
|
||||
// 외부 DB
|
||||
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
||||
|
|
@ -480,24 +601,27 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
rows: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
return resultData.rows;
|
||||
rows = resultData.rows;
|
||||
} else {
|
||||
// 현재 DB
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
const result = await dashboardApi.executeQuery(source.query);
|
||||
|
||||
return result.rows;
|
||||
rows = result.rows;
|
||||
}
|
||||
|
||||
// 컬럼 매핑 적용
|
||||
return applyColumnMapping(rows, source.columnMapping);
|
||||
};
|
||||
|
||||
// 초기 로드
|
||||
// 초기 로드 (🆕 loadAllData 사용)
|
||||
useEffect(() => {
|
||||
if (dataSources && dataSources.length > 0 && metricConfig.length > 0) {
|
||||
loadMultipleDataSources();
|
||||
if ((dataSources && dataSources.length > 0) || (isGroupByMode && element?.customMetricConfig?.groupByDataSource)) {
|
||||
loadAllData();
|
||||
}
|
||||
}, [dataSources, loadMultipleDataSources, metricConfig]);
|
||||
}, [dataSources, isGroupByMode, element?.customMetricConfig?.groupByDataSource, loadAllData]);
|
||||
|
||||
// 자동 새로고침
|
||||
// 자동 새로고침 (🆕 loadAllData 사용)
|
||||
useEffect(() => {
|
||||
if (!dataSources || dataSources.length === 0) return;
|
||||
|
||||
|
|
@ -512,107 +636,206 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
|
||||
const intervalId = setInterval(() => {
|
||||
console.log("🔄 자동 새로고침 실행");
|
||||
loadMultipleDataSources();
|
||||
loadAllData();
|
||||
}, minInterval * 1000);
|
||||
|
||||
return () => {
|
||||
console.log("⏹️ 자동 새로고침 정리");
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [dataSources, loadMultipleDataSources]);
|
||||
}, [dataSources, loadAllData]);
|
||||
|
||||
// 메트릭 카드 렌더링
|
||||
const renderMetricCard = (metric: any, index: number) => {
|
||||
const color = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray;
|
||||
// renderMetricCard 함수 제거 - 인라인으로 렌더링
|
||||
|
||||
// 로딩 상태 (원본 스타일)
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
|
||||
<div className="text-center">
|
||||
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
<p className="mt-2 text-sm text-gray-500">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태 (원본 스타일)
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-red-600">⚠️ {error}</p>
|
||||
<button
|
||||
onClick={handleManualRefresh}
|
||||
className="mt-2 rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터 소스 없음 (원본 스타일)
|
||||
if (!(element?.dataSources || element?.chartConfig?.dataSources) && !isGroupByMode) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
|
||||
<p className="text-sm text-gray-500">데이터 소스를 연결해주세요</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 메트릭 설정 없음 (원본 스타일)
|
||||
if (metricConfig.length === 0 && !isGroupByMode) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
|
||||
<p className="text-sm text-gray-500">메트릭을 설정해주세요</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 메인 렌더링 (원본 스타일 - 심플하게)
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
|
||||
{/* 콘텐츠 영역 - 스크롤 없이 자동으로 크기 조정 (원본과 동일) */}
|
||||
<div className="grid h-full w-full gap-2" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))" }}>
|
||||
{/* 그룹별 카드 (활성화 시) */}
|
||||
{isGroupByMode &&
|
||||
groupedCards.map((card, index) => {
|
||||
// 색상 순환 (6가지 색상)
|
||||
const colorKeys = Object.keys(colorMap) as Array<keyof typeof colorMap>;
|
||||
const colorKey = colorKeys[index % colorKeys.length];
|
||||
const colors = colorMap[colorKey];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`group-${index}`}
|
||||
className={`flex flex-col items-center justify-center rounded-lg border ${colors.bg} ${colors.border} p-2`}
|
||||
>
|
||||
<div className="text-[10px] text-gray-600">{card.label}</div>
|
||||
<div className={`mt-0.5 text-xl font-bold ${colors.text}`}>{card.value.toLocaleString()}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 일반 지표 카드 (항상 표시) */}
|
||||
{metrics.map((metric, index) => {
|
||||
const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray;
|
||||
const formattedValue = metric.value.toLocaleString(undefined, {
|
||||
minimumFractionDigits: metric.decimals || 0,
|
||||
maximumFractionDigits: metric.decimals || 0,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`rounded-lg border ${color.border} ${color.bg} p-4 shadow-sm transition-all hover:shadow-md`}
|
||||
key={`metric-${index}`}
|
||||
onClick={() => {
|
||||
setSelectedMetric(metric);
|
||||
setIsDetailOpen(true);
|
||||
}}
|
||||
className={`flex cursor-pointer flex-col items-center justify-center rounded-lg border ${colors.bg} ${colors.border} p-2 transition-all hover:shadow-md`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">{metric.label}</p>
|
||||
<p className={`mt-1 text-2xl font-bold ${color.text}`}>
|
||||
<div className="text-[10px] text-gray-600">{metric.label}</div>
|
||||
<div className={`mt-0.5 text-xl font-bold ${colors.text}`}>
|
||||
{formattedValue}
|
||||
{metric.unit && <span className="ml-1 text-sm">{metric.unit}</span>}
|
||||
</p>
|
||||
</div>
|
||||
{metric.unit && <span className="ml-0.5 text-sm">{metric.unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
})}
|
||||
</div>
|
||||
|
||||
// 메트릭 개수에 따라 그리드 컬럼 동적 결정
|
||||
const getGridCols = () => {
|
||||
const count = metrics.length;
|
||||
if (count === 0) return "grid-cols-1";
|
||||
if (count === 1) return "grid-cols-1";
|
||||
if (count <= 4) return "grid-cols-1 sm:grid-cols-2";
|
||||
return "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3";
|
||||
};
|
||||
{/* 상세 정보 모달 */}
|
||||
<Dialog open={isDetailOpen} onOpenChange={setIsDetailOpen}>
|
||||
<DialogContent className="max-h-[90vh] max-w-[95vw] overflow-y-auto sm:max-w-[800px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">{selectedMetric?.label || "메트릭 상세"}</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
데이터 소스: {selectedMetric?.sourceName} • 총 {selectedMetric?.rawData?.length || 0}개 항목
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col rounded-lg border bg-card shadow-sm">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div className="space-y-4">
|
||||
{/* 메트릭 요약 */}
|
||||
<div className="bg-muted/50 rounded-lg border p-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{element?.customTitle || "커스텀 메트릭 (다중 데이터 소스)"}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dataSources?.length || 0}개 데이터 소스 • {metrics.length}개 메트릭
|
||||
{lastRefreshTime && (
|
||||
<span className="ml-2">
|
||||
• {lastRefreshTime.toLocaleTimeString("ko-KR")}
|
||||
</span>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">계산 방법</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{selectedMetric?.aggregation === "count" && "전체 데이터 개수"}
|
||||
{selectedMetric?.aggregation === "distinct" && `"${selectedMetric?.field}" 컬럼의 고유값 개수`}
|
||||
{selectedMetric?.aggregation === "custom" && `"${selectedMetric?.field}" 컬럼의 값`}
|
||||
{selectedMetric?.aggregation === "sum" && `"${selectedMetric?.field}" 컬럼의 합계`}
|
||||
{selectedMetric?.aggregation === "avg" && `"${selectedMetric?.field}" 컬럼의 평균`}
|
||||
{selectedMetric?.aggregation === "min" && `"${selectedMetric?.field}" 컬럼의 최소값`}
|
||||
{selectedMetric?.aggregation === "max" && `"${selectedMetric?.field}" 컬럼의 최대값`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleManualRefresh}
|
||||
disabled={loading}
|
||||
className="h-8 gap-2 text-xs"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">계산 결과</p>
|
||||
<p className="text-primary text-lg font-bold">
|
||||
{selectedMetric?.value?.toLocaleString()}
|
||||
{selectedMetric?.unit && ` ${selectedMetric.unit}`}
|
||||
{selectedMetric?.aggregation === "distinct" && "개"}
|
||||
{selectedMetric?.aggregation === "count" && "개"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">전체 데이터 개수</p>
|
||||
<p className="text-lg font-bold">{selectedMetric?.rawData?.length || 0}개</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컨텐츠 */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{error ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
{/* 원본 데이터 테이블 */}
|
||||
{selectedMetric?.rawData && selectedMetric.rawData.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-semibold">원본 데이터 (최대 100개)</h4>
|
||||
<div className="rounded-lg border">
|
||||
<div className="max-h-96 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
{Object.keys(selectedMetric.rawData[0]).map((col) => (
|
||||
<TableHead key={col} className="text-xs font-semibold">
|
||||
{col}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedMetric.rawData.slice(0, 100).map((row: any, idx: number) => (
|
||||
<TableRow key={idx}>
|
||||
{Object.keys(selectedMetric.rawData[0]).map((col) => (
|
||||
<TableCell key={col} className="text-xs">
|
||||
{String(row[col])}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : !(element?.dataSources || element?.chartConfig?.dataSources) || (element?.dataSources || element?.chartConfig?.dataSources)?.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
데이터 소스를 연결해주세요
|
||||
</div>
|
||||
{selectedMetric.rawData.length > 100 && (
|
||||
<p className="text-muted-foreground mt-2 text-center text-xs">
|
||||
총 {selectedMetric.rawData.length}개 중 100개만 표시됩니다
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : metricConfig.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
메트릭을 설정해주세요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`grid gap-4 ${getGridCols()}`}>
|
||||
{metrics.map((metric, index) => renderMetricCard(metric, index))}
|
||||
)}
|
||||
|
||||
{/* 데이터 없음 */}
|
||||
{(!selectedMetric?.rawData || selectedMetric.rawData.length === 0) && (
|
||||
<div className="bg-muted/30 flex h-32 items-center justify-center rounded-lg border">
|
||||
<p className="text-muted-foreground text-sm">표시할 데이터가 없습니다</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { applyColumnMapping } from "@/lib/utils/columnMapping";
|
||||
|
||||
interface ListTestWidgetProps {
|
||||
element: DashboardElement;
|
||||
|
|
@ -32,12 +33,18 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
||||
|
||||
console.log("🧪 ListTestWidget 렌더링!", element);
|
||||
// console.log("🧪 ListTestWidget 렌더링!", element);
|
||||
|
||||
const dataSources = useMemo(() => {
|
||||
return element?.dataSources || element?.chartConfig?.dataSources;
|
||||
}, [element?.dataSources, element?.chartConfig?.dataSources]);
|
||||
|
||||
// console.log("📊 dataSources 확인:", {
|
||||
// hasDataSources: !!dataSources,
|
||||
// dataSourcesLength: dataSources?.length || 0,
|
||||
// dataSources: dataSources,
|
||||
// });
|
||||
|
||||
const config = element.listConfig || {
|
||||
columnMode: "auto",
|
||||
viewMode: "table",
|
||||
|
|
@ -52,8 +59,6 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
|
||||
// 다중 데이터 소스 로딩
|
||||
const loadMultipleDataSources = useCallback(async () => {
|
||||
const dataSources = element?.dataSources || element?.chartConfig?.dataSources;
|
||||
|
||||
if (!dataSources || dataSources.length === 0) {
|
||||
console.log("⚠️ 데이터 소스가 없습니다.");
|
||||
return;
|
||||
|
|
@ -127,7 +132,7 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [element?.dataSources, element?.chartConfig?.dataSources]);
|
||||
}, [dataSources]);
|
||||
|
||||
// 수동 새로고침 핸들러
|
||||
const handleManualRefresh = useCallback(() => {
|
||||
|
|
@ -195,7 +200,11 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
}
|
||||
}
|
||||
|
||||
const rows = Array.isArray(processedData) ? processedData : [processedData];
|
||||
let rows = Array.isArray(processedData) ? processedData : [processedData];
|
||||
|
||||
// 컬럼 매핑 적용
|
||||
rows = applyColumnMapping(rows, source.columnMapping);
|
||||
|
||||
const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
|
||||
|
||||
return { columns, rows };
|
||||
|
|
@ -224,18 +233,41 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
rows: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
// 컬럼 매핑 적용
|
||||
const mappedRows = applyColumnMapping(resultData.rows, source.columnMapping);
|
||||
const columns = mappedRows.length > 0 ? Object.keys(mappedRows[0]) : resultData.columns;
|
||||
|
||||
return {
|
||||
columns: resultData.columns,
|
||||
rows: resultData.rows,
|
||||
columns,
|
||||
rows: mappedRows,
|
||||
};
|
||||
} else {
|
||||
// 현재 DB
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
const result = await dashboardApi.executeQuery(source.query);
|
||||
|
||||
// console.log("💾 내부 DB 쿼리 결과:", {
|
||||
// hasRows: !!result.rows,
|
||||
// rowCount: result.rows?.length || 0,
|
||||
// hasColumns: !!result.columns,
|
||||
// columnCount: result.columns?.length || 0,
|
||||
// firstRow: result.rows?.[0],
|
||||
// resultKeys: Object.keys(result),
|
||||
// });
|
||||
|
||||
// 컬럼 매핑 적용
|
||||
const mappedRows = applyColumnMapping(result.rows, source.columnMapping);
|
||||
const columns = mappedRows.length > 0 ? Object.keys(mappedRows[0]) : result.columns;
|
||||
|
||||
// console.log("✅ 매핑 후:", {
|
||||
// columns,
|
||||
// rowCount: mappedRows.length,
|
||||
// firstMappedRow: mappedRows[0],
|
||||
// });
|
||||
|
||||
return {
|
||||
columns: result.columns,
|
||||
rows: result.rows,
|
||||
columns,
|
||||
rows: mappedRows,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -330,7 +362,7 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{element?.customTitle || "리스트 테스트 (다중 데이터 소스)"}
|
||||
{element?.customTitle || "리스트"}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dataSources?.length || 0}개 데이터 소스 • {data?.totalRows || 0}개 행
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import dynamic from "next/dynamic";
|
|||
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { applyColumnMapping } from "@/lib/utils/columnMapping";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
// Leaflet 아이콘 경로 설정 (엑박 방지)
|
||||
|
|
@ -43,6 +44,7 @@ interface MarkerData {
|
|||
status?: string;
|
||||
description?: string;
|
||||
source?: string; // 어느 데이터 소스에서 왔는지
|
||||
color?: string; // 마커 색상
|
||||
}
|
||||
|
||||
interface PolygonData {
|
||||
|
|
@ -53,6 +55,7 @@ interface PolygonData {
|
|||
description?: string;
|
||||
source?: string;
|
||||
color?: string;
|
||||
opacity?: number; // 투명도 (0.0 ~ 1.0)
|
||||
}
|
||||
|
||||
export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||
|
|
@ -215,7 +218,9 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
const parsedData = parseTextData(data.text);
|
||||
if (parsedData.length > 0) {
|
||||
console.log(`✅ CSV 파싱 성공: ${parsedData.length}개 행`);
|
||||
return convertToMapData(parsedData, source.name || source.id || "API", source.mapDisplayType);
|
||||
// 컬럼 매핑 적용
|
||||
const mappedData = applyColumnMapping(parsedData, source.columnMapping);
|
||||
return convertToMapData(mappedData, source.name || source.id || "API", source.mapDisplayType, source);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -229,8 +234,11 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
|
||||
const rows = Array.isArray(data) ? data : [data];
|
||||
|
||||
// 마커와 폴리곤으로 변환 (mapDisplayType 전달)
|
||||
return convertToMapData(rows, source.name || source.id || "API", source.mapDisplayType);
|
||||
// 컬럼 매핑 적용
|
||||
const mappedRows = applyColumnMapping(rows, source.columnMapping);
|
||||
|
||||
// 마커와 폴리곤으로 변환 (mapDisplayType + dataSource 전달)
|
||||
return convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source);
|
||||
};
|
||||
|
||||
// Database 데이터 로딩
|
||||
|
|
@ -268,8 +276,11 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
rows = result.rows;
|
||||
}
|
||||
|
||||
// 마커와 폴리곤으로 변환 (mapDisplayType 전달)
|
||||
return convertToMapData(rows, source.name || source.id || "Database", source.mapDisplayType);
|
||||
// 컬럼 매핑 적용
|
||||
const mappedRows = applyColumnMapping(rows, source.columnMapping);
|
||||
|
||||
// 마커와 폴리곤으로 변환 (mapDisplayType + dataSource 전달)
|
||||
return convertToMapData(mappedRows, source.name || source.id || "Database", source.mapDisplayType, source);
|
||||
};
|
||||
|
||||
// XML 데이터 파싱 (UTIC API 등)
|
||||
|
|
@ -365,9 +376,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
};
|
||||
|
||||
// 데이터를 마커와 폴리곤으로 변환
|
||||
const convertToMapData = (rows: any[], sourceName: string, mapDisplayType?: "auto" | "marker" | "polygon"): { markers: MarkerData[]; polygons: PolygonData[] } => {
|
||||
const convertToMapData = (
|
||||
rows: any[],
|
||||
sourceName: string,
|
||||
mapDisplayType?: "auto" | "marker" | "polygon",
|
||||
dataSource?: ChartDataSource
|
||||
): { markers: MarkerData[]; polygons: PolygonData[] } => {
|
||||
console.log(`🔄 ${sourceName} 데이터 변환 시작:`, rows.length, "개 행");
|
||||
console.log(` 📌 mapDisplayType:`, mapDisplayType, `(타입: ${typeof mapDisplayType})`);
|
||||
console.log(` 🎨 마커 색상:`, dataSource?.markerColor, `폴리곤 색상:`, dataSource?.polygonColor);
|
||||
|
||||
if (rows.length === 0) return { markers: [], polygons: [] };
|
||||
|
||||
|
|
@ -383,8 +400,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
const parsedData = parseTextData(row.text);
|
||||
console.log(` ✅ CSV 파싱 결과: ${parsedData.length}개 행`);
|
||||
|
||||
// 파싱된 데이터를 재귀적으로 변환
|
||||
const result = convertToMapData(parsedData, sourceName, mapDisplayType);
|
||||
// 파싱된 데이터를 재귀적으로 변환 (색상 정보 전달)
|
||||
const result = convertToMapData(parsedData, sourceName, mapDisplayType, dataSource);
|
||||
markers.push(...result.markers);
|
||||
polygons.push(...result.polygons);
|
||||
return; // 이 행은 처리 완료
|
||||
|
|
@ -404,7 +421,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
status: row.status || row.level,
|
||||
description: row.description || JSON.stringify(row, null, 2),
|
||||
source: sourceName,
|
||||
color: getColorByStatus(row.status || row.level),
|
||||
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
|
||||
});
|
||||
return; // 폴리곤으로 처리했으므로 마커로는 추가하지 않음
|
||||
}
|
||||
|
|
@ -421,7 +438,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
status: row.status || row.level,
|
||||
description: row.description || `${row.type || ''} ${row.level || ''}`.trim() || JSON.stringify(row, null, 2),
|
||||
source: sourceName,
|
||||
color: getColorByStatus(row.status || row.level),
|
||||
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
|
||||
});
|
||||
return; // 폴리곤으로 처리했으므로 마커로는 추가하지 않음
|
||||
}
|
||||
|
|
@ -466,7 +483,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
status: row.status || row.level,
|
||||
description: row.description || JSON.stringify(row, null, 2),
|
||||
source: sourceName,
|
||||
color: getColorByStatus(row.status || row.level),
|
||||
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
|
||||
});
|
||||
} else {
|
||||
console.log(` ⚠️ 강제 폴리곤 모드지만 지역명 없음 - 스킵`);
|
||||
|
|
@ -487,6 +504,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
status: row.status || row.level,
|
||||
description: row.description || JSON.stringify(row, null, 2),
|
||||
source: sourceName,
|
||||
color: dataSource?.markerColor || "#3b82f6", // 사용자 지정 색상 또는 기본 파랑
|
||||
});
|
||||
} else {
|
||||
// 위도/경도가 없는 육지 지역 → 폴리곤으로 추가 (GeoJSON 매칭용)
|
||||
|
|
@ -500,7 +518,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
status: row.status || row.level,
|
||||
description: row.description || JSON.stringify(row, null, 2),
|
||||
source: sourceName,
|
||||
color: getColorByStatus(row.status || row.level),
|
||||
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
|
||||
});
|
||||
} else {
|
||||
console.log(` ⚠️ 위도/경도 없고 지역명도 없음 - 스킵`);
|
||||
|
|
@ -803,7 +821,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{element?.customTitle || "지도 테스트 V2 (다중 데이터 소스)"}
|
||||
{element?.customTitle || "지도"}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{element?.dataSources?.length || 0}개 데이터 소스 연결됨
|
||||
|
|
@ -989,10 +1007,37 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
))}
|
||||
|
||||
{/* 마커 렌더링 */}
|
||||
{markers.map((marker) => (
|
||||
{markers.map((marker) => {
|
||||
// 커스텀 색상 아이콘 생성
|
||||
let customIcon;
|
||||
if (typeof window !== "undefined") {
|
||||
const L = require("leaflet");
|
||||
customIcon = L.divIcon({
|
||||
className: "custom-marker",
|
||||
html: `
|
||||
<div style="
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background-color: ${marker.color || "#3b82f6"};
|
||||
border: 3px solid white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: translate(-50%, -50%);
|
||||
"></div>
|
||||
`,
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={marker.id}
|
||||
position={[marker.lat, marker.lng]}
|
||||
icon={customIcon}
|
||||
>
|
||||
<Popup maxWidth={350}>
|
||||
<div className="min-w-[250px] max-w-[350px]">
|
||||
|
|
@ -1071,7 +1116,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</MapContainer>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -466,7 +466,7 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
<div className="flex h-full items-center justify-center p-3">
|
||||
<div className="max-w-xs space-y-2 text-center">
|
||||
<div className="text-3xl">🚨</div>
|
||||
<h3 className="text-sm font-bold text-gray-900">🧪 리스크/알림 테스트 위젯</h3>
|
||||
<h3 className="text-sm font-bold text-gray-900">리스크/알림</h3>
|
||||
<div className="space-y-1.5 text-xs text-gray-600">
|
||||
<p className="font-medium">다중 데이터 소스 지원</p>
|
||||
<ul className="space-y-0.5 text-left">
|
||||
|
|
@ -491,7 +491,7 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
<div className="flex items-center justify-between border-b bg-white/80 p-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">
|
||||
{element?.customTitle || "리스크/알림 테스트"}
|
||||
{element?.customTitle || "리스크/알림"}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dataSources?.length || 0}개 데이터 소스 • {alerts.length}개 알림
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ async function apiRequest<T>(
|
|||
const API_BASE_URL = getApiBaseUrl();
|
||||
|
||||
const config: RequestInit = {
|
||||
credentials: "include", // ⭐ 세션 쿠키 전송 필수
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* 컬럼 매핑 유틸리티
|
||||
* 다중 데이터 소스 통합 시 컬럼명을 통일하기 위한 함수
|
||||
*/
|
||||
|
||||
/**
|
||||
* 데이터에 컬럼 매핑 적용
|
||||
* @param data 원본 데이터 배열
|
||||
* @param columnMapping 컬럼 매핑 객체 { 원본컬럼: 표시이름 }
|
||||
* @returns 매핑이 적용된 데이터 배열
|
||||
*
|
||||
* @example
|
||||
* const data = [{ name: "상품A", amount: 1000 }];
|
||||
* const mapping = { name: "product", amount: "value" };
|
||||
* const result = applyColumnMapping(data, mapping);
|
||||
* // result: [{ product: "상품A", value: 1000 }]
|
||||
*/
|
||||
export function applyColumnMapping(
|
||||
data: any[],
|
||||
columnMapping?: Record<string, string>
|
||||
): any[] {
|
||||
// 매핑이 없거나 빈 객체면 원본 그대로 반환
|
||||
if (!columnMapping || Object.keys(columnMapping).length === 0) {
|
||||
return data;
|
||||
}
|
||||
|
||||
console.log("🔄 컬럼 매핑 적용 중...", {
|
||||
rowCount: data.length,
|
||||
mappingCount: Object.keys(columnMapping).length,
|
||||
mapping: columnMapping,
|
||||
});
|
||||
|
||||
// 각 행에 매핑 적용
|
||||
const mappedData = data.map((row) => {
|
||||
const mappedRow: any = {};
|
||||
|
||||
// 모든 컬럼 순회
|
||||
Object.keys(row).forEach((originalCol) => {
|
||||
// 매핑이 있으면 매핑된 이름 사용, 없으면 원본 이름 사용
|
||||
const mappedCol = columnMapping[originalCol] || originalCol;
|
||||
mappedRow[mappedCol] = row[originalCol];
|
||||
});
|
||||
|
||||
return mappedRow;
|
||||
});
|
||||
|
||||
console.log("✅ 컬럼 매핑 완료", {
|
||||
originalColumns: Object.keys(data[0] || {}),
|
||||
mappedColumns: Object.keys(mappedData[0] || {}),
|
||||
});
|
||||
|
||||
return mappedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 데이터 소스의 데이터를 병합
|
||||
* 각 데이터 소스의 컬럼 매핑을 적용한 후 병합
|
||||
*
|
||||
* @param dataSets 데이터셋 배열 [{ data, columnMapping, source }]
|
||||
* @returns 병합된 데이터 배열
|
||||
*
|
||||
* @example
|
||||
* const dataSets = [
|
||||
* {
|
||||
* data: [{ name: "A", amount: 100 }],
|
||||
* columnMapping: { name: "product", amount: "value" },
|
||||
* source: "DB1"
|
||||
* },
|
||||
* {
|
||||
* data: [{ product_name: "B", total: 200 }],
|
||||
* columnMapping: { product_name: "product", total: "value" },
|
||||
* source: "DB2"
|
||||
* }
|
||||
* ];
|
||||
* const result = mergeDataSources(dataSets);
|
||||
* // result: [
|
||||
* // { product: "A", value: 100, _source: "DB1" },
|
||||
* // { product: "B", value: 200, _source: "DB2" }
|
||||
* // ]
|
||||
*/
|
||||
export function mergeDataSources(
|
||||
dataSets: Array<{
|
||||
data: any[];
|
||||
columnMapping?: Record<string, string>;
|
||||
source?: string;
|
||||
}>
|
||||
): any[] {
|
||||
console.log(`🔗 ${dataSets.length}개의 데이터 소스 병합 중...`);
|
||||
|
||||
const mergedData: any[] = [];
|
||||
|
||||
dataSets.forEach(({ data, columnMapping, source }) => {
|
||||
// 각 데이터셋에 컬럼 매핑 적용
|
||||
const mappedData = applyColumnMapping(data, columnMapping);
|
||||
|
||||
// 소스 정보 추가
|
||||
const dataWithSource = mappedData.map((row) => ({
|
||||
...row,
|
||||
_source: source || "unknown", // 어느 데이터 소스에서 왔는지 표시
|
||||
}));
|
||||
|
||||
mergedData.push(...dataWithSource);
|
||||
});
|
||||
|
||||
console.log(`✅ 데이터 병합 완료: 총 ${mergedData.length}개 행`);
|
||||
|
||||
return mergedData;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue