테스트 위젯 원본 승격 전 세이브
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} />
|
<ListTestWidget element={element} />
|
||||||
</div>
|
</div>
|
||||||
) : element.type === "widget" && element.subtype === "custom-metric-test" ? (
|
) : element.type === "widget" && element.subtype === "custom-metric-test" ? (
|
||||||
// 🧪 테스트용 커스텀 메트릭 위젯 (다중 데이터 소스)
|
// 🧪 통계 카드 (다중 데이터 소스)
|
||||||
<div className="widget-interactive-area h-full w-full">
|
<div className="widget-interactive-area h-full w-full">
|
||||||
<CustomMetricTestWidget element={element} />
|
<CustomMetricTestWidget element={element} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -181,15 +181,15 @@ export function DashboardTopMenu({
|
||||||
<SelectValue placeholder="위젯 추가" />
|
<SelectValue placeholder="위젯 추가" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="z-[99999]">
|
<SelectContent className="z-[99999]">
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectLabel>🧪 테스트 위젯 (다중 데이터 소스)</SelectLabel>
|
<SelectLabel>🧪 테스트 위젯 (다중 데이터 소스)</SelectLabel>
|
||||||
<SelectItem value="map-test-v2">🧪 지도 테스트 V2</SelectItem>
|
<SelectItem value="map-test-v2">🧪 지도 테스트 V2</SelectItem>
|
||||||
<SelectItem value="chart-test">🧪 차트 테스트</SelectItem>
|
<SelectItem value="chart-test">🧪 차트 테스트</SelectItem>
|
||||||
<SelectItem value="list-test">🧪 리스트 테스트</SelectItem>
|
<SelectItem value="list-test">🧪 리스트 테스트</SelectItem>
|
||||||
<SelectItem value="custom-metric-test">🧪 커스텀 메트릭 테스트</SelectItem>
|
<SelectItem value="custom-metric-test">통계 카드</SelectItem>
|
||||||
<SelectItem value="status-summary-test">🧪 상태 요약 테스트</SelectItem>
|
{/* <SelectItem value="status-summary-test">🧪 상태 요약 테스트</SelectItem> */}
|
||||||
<SelectItem value="risk-alert-test">🧪 리스크/알림 테스트</SelectItem>
|
<SelectItem value="risk-alert-test">🧪 리스크/알림 테스트</SelectItem>
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectLabel>데이터 위젯</SelectLabel>
|
<SelectLabel>데이터 위젯</SelectLabel>
|
||||||
<SelectItem value="list">리스트 위젯</SelectItem>
|
<SelectItem value="list">리스트 위젯</SelectItem>
|
||||||
|
|
@ -197,7 +197,7 @@ export function DashboardTopMenu({
|
||||||
<SelectItem value="yard-management-3d">야드 관리 3D</SelectItem>
|
<SelectItem value="yard-management-3d">야드 관리 3D</SelectItem>
|
||||||
{/* <SelectItem value="transport-stats">커스텀 통계 카드</SelectItem> */}
|
{/* <SelectItem value="transport-stats">커스텀 통계 카드</SelectItem> */}
|
||||||
<SelectItem value="map-summary">커스텀 지도 카드</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> */}
|
{/* <SelectItem value="status-summary">커스텀 상태 카드</SelectItem> */}
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { QueryEditor } from "./QueryEditor";
|
||||||
import { ChartConfigPanel } from "./ChartConfigPanel";
|
import { ChartConfigPanel } from "./ChartConfigPanel";
|
||||||
import { VehicleMapConfigPanel } from "./VehicleMapConfigPanel";
|
import { VehicleMapConfigPanel } from "./VehicleMapConfigPanel";
|
||||||
import { MapTestConfigPanel } from "./MapTestConfigPanel";
|
import { MapTestConfigPanel } from "./MapTestConfigPanel";
|
||||||
|
import { MultiChartConfigPanel } from "./MultiChartConfigPanel";
|
||||||
import { DatabaseConfig } from "./data-sources/DatabaseConfig";
|
import { DatabaseConfig } from "./data-sources/DatabaseConfig";
|
||||||
import { ApiConfig } from "./data-sources/ApiConfig";
|
import { ApiConfig } from "./data-sources/ApiConfig";
|
||||||
import MultiDataSourceConfig from "./data-sources/MultiDataSourceConfig";
|
import MultiDataSourceConfig from "./data-sources/MultiDataSourceConfig";
|
||||||
|
|
@ -41,16 +42,41 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
||||||
const [customTitle, setCustomTitle] = useState<string>("");
|
const [customTitle, setCustomTitle] = useState<string>("");
|
||||||
const [showHeader, setShowHeader] = useState<boolean>(true);
|
const [showHeader, setShowHeader] = useState<boolean>(true);
|
||||||
|
|
||||||
|
// 멀티 데이터 소스의 테스트 결과 저장 (ChartTestWidget용)
|
||||||
|
const [testResults, setTestResults] = useState<Map<string, { columns: string[]; rows: Record<string, unknown>[] }>>(
|
||||||
|
new Map(),
|
||||||
|
);
|
||||||
|
|
||||||
// 사이드바가 열릴 때 초기화
|
// 사이드바가 열릴 때 초기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && element) {
|
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 });
|
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 });
|
||||||
|
|
||||||
// dataSources는 element.dataSources 또는 chartConfig.dataSources에서 로드
|
// 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 || {});
|
setChartConfig(element.chartConfig || {});
|
||||||
setQueryResult(null);
|
setQueryResult(null);
|
||||||
|
setTestResults(new Map()); // 테스트 결과도 초기화
|
||||||
setCustomTitle(element.customTitle || "");
|
setCustomTitle(element.customTitle || "");
|
||||||
setShowHeader(element.showHeader !== false);
|
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]);
|
}, [isOpen, element]);
|
||||||
|
|
||||||
|
|
@ -321,7 +347,27 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
||||||
{isMultiDataSourceWidget && (
|
{isMultiDataSourceWidget && (
|
||||||
<>
|
<>
|
||||||
<div className="rounded-lg bg-white p-3 shadow-sm">
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* 지도 테스트 V2: 타일맵 URL 설정 */}
|
{/* 지도 테스트 V2: 타일맵 URL 설정 */}
|
||||||
|
|
@ -354,6 +400,40 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</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>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="space-y-2 border-t pt-4">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -745,6 +794,103 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,13 @@ import MultiDatabaseConfig from "./MultiDatabaseConfig";
|
||||||
interface MultiDataSourceConfigProps {
|
interface MultiDataSourceConfigProps {
|
||||||
dataSources: ChartDataSource[];
|
dataSources: ChartDataSource[];
|
||||||
onChange: (dataSources: ChartDataSource[]) => void;
|
onChange: (dataSources: ChartDataSource[]) => void;
|
||||||
|
onTestResult?: (result: { columns: string[]; rows: any[] }, dataSourceId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MultiDataSourceConfig({
|
export default function MultiDataSourceConfig({
|
||||||
dataSources = [],
|
dataSources = [],
|
||||||
onChange,
|
onChange,
|
||||||
|
onTestResult,
|
||||||
}: MultiDataSourceConfigProps) {
|
}: MultiDataSourceConfigProps) {
|
||||||
const [activeTab, setActiveTab] = useState<string>(
|
const [activeTab, setActiveTab] = useState<string>(
|
||||||
dataSources.length > 0 ? dataSources[0].id || "0" : "new"
|
dataSources.length > 0 ? dataSources[0].id || "0" : "new"
|
||||||
|
|
@ -258,12 +260,24 @@ export default function MultiDataSourceConfig({
|
||||||
onTestResult={(data) => {
|
onTestResult={(data) => {
|
||||||
setPreviewData(data);
|
setPreviewData(data);
|
||||||
setShowPreview(true);
|
setShowPreview(true);
|
||||||
|
// 부모로 테스트 결과 전달 (차트 설정용)
|
||||||
|
if (onTestResult && data.length > 0 && ds.id) {
|
||||||
|
const columns = Object.keys(data[0]);
|
||||||
|
onTestResult({ columns, rows: data }, ds.id);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<MultiDatabaseConfig
|
<MultiDatabaseConfig
|
||||||
dataSource={ds}
|
dataSource={ds}
|
||||||
onChange={(updates) => handleUpdateDataSource(ds.id!, updates)}
|
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>
|
</TabsContent>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { Loader2, CheckCircle, XCircle } from "lucide-react";
|
||||||
interface MultiDatabaseConfigProps {
|
interface MultiDatabaseConfigProps {
|
||||||
dataSource: ChartDataSource;
|
dataSource: ChartDataSource;
|
||||||
onChange: (updates: Partial<ChartDataSource>) => void;
|
onChange: (updates: Partial<ChartDataSource>) => void;
|
||||||
|
onTestResult?: (data: any[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExternalConnection {
|
interface ExternalConnection {
|
||||||
|
|
@ -21,7 +22,7 @@ interface ExternalConnection {
|
||||||
type: string;
|
type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatabaseConfigProps) {
|
export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult }: MultiDatabaseConfigProps) {
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string; rowCount?: number } | null>(null);
|
const [testResult, setTestResult] = useState<{ success: boolean; message: string; rowCount?: number } | null>(null);
|
||||||
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
||||||
|
|
@ -122,6 +123,11 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
|
||||||
message: "쿼리 실행 성공",
|
message: "쿼리 실행 성공",
|
||||||
rowCount,
|
rowCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 부모로 테스트 결과 전달 (차트 설정용)
|
||||||
|
if (onTestResult && rows && rows.length > 0) {
|
||||||
|
onTestResult(rows);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setTestResult({ success: false, message: result.message || "쿼리 실행 실패" });
|
setTestResult({ success: false, message: result.message || "쿼리 실행 실패" });
|
||||||
}
|
}
|
||||||
|
|
@ -166,6 +172,11 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
|
||||||
message: "쿼리 실행 성공",
|
message: "쿼리 실행 성공",
|
||||||
rowCount: result.rowCount || 0,
|
rowCount: result.rowCount || 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 부모로 테스트 결과 전달 (차트 설정용)
|
||||||
|
if (onTestResult && result.rows && result.rows.length > 0) {
|
||||||
|
onTestResult(result.rows);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setTestResult({ success: false, message: error.message || "네트워크 오류" });
|
setTestResult({ success: false, message: error.message || "네트워크 오류" });
|
||||||
|
|
@ -240,9 +251,61 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
|
||||||
|
|
||||||
{/* SQL 쿼리 */}
|
{/* SQL 쿼리 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`query-\${dataSource.id}`} className="text-xs">
|
<div className="flex items-center justify-between">
|
||||||
SQL 쿼리 *
|
<Label htmlFor={`query-\${dataSource.id}`} className="text-xs">
|
||||||
</Label>
|
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
|
<Textarea
|
||||||
id={`query-\${dataSource.id}`}
|
id={`query-\${dataSource.id}`}
|
||||||
value={dataSource.query || ""}
|
value={dataSource.query || ""}
|
||||||
|
|
@ -251,7 +314,7 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
|
||||||
className="min-h-[120px] font-mono text-xs"
|
className="min-h-[120px] font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
SELECT 쿼리만 허용됩니다
|
SELECT 쿼리만 허용됩니다. 샘플 쿼리를 선택하여 빠르게 시작할 수 있습니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -283,6 +346,55 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="space-y-2 border-t pt-4">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -476,6 +588,103 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,12 +23,12 @@ export type ElementSubtype =
|
||||||
| "vehicle-list" // (구버전 - 호환용)
|
| "vehicle-list" // (구버전 - 호환용)
|
||||||
| "vehicle-map" // (구버전 - 호환용)
|
| "vehicle-map" // (구버전 - 호환용)
|
||||||
| "map-summary" // 범용 지도 카드 (통합)
|
| "map-summary" // 범용 지도 카드 (통합)
|
||||||
| "map-test" // 🧪 지도 테스트 위젯 (REST API 지원)
|
// | "map-test" // 🧪 지도 테스트 위젯 (REST API 지원) - V2로 대체
|
||||||
| "map-test-v2" // 🧪 지도 테스트 V2 (다중 데이터 소스)
|
| "map-test-v2" // 🧪 지도 테스트 V2 (다중 데이터 소스)
|
||||||
| "chart-test" // 🧪 차트 테스트 (다중 데이터 소스)
|
| "chart-test" // 🧪 차트 테스트 (다중 데이터 소스)
|
||||||
| "list-test" // 🧪 리스트 테스트 (다중 데이터 소스)
|
| "list-test" // 🧪 리스트 테스트 (다중 데이터 소스)
|
||||||
| "custom-metric-test" // 🧪 커스텀 메트릭 테스트 (다중 데이터 소스)
|
| "custom-metric-test" // 🧪 통계 카드 (다중 데이터 소스)
|
||||||
| "status-summary-test" // 🧪 상태 요약 테스트 (다중 데이터 소스)
|
// | "status-summary-test" // 🧪 상태 요약 테스트 (CustomMetricTest로 대체 가능)
|
||||||
| "risk-alert-test" // 🧪 리스크/알림 테스트 (다중 데이터 소스)
|
| "risk-alert-test" // 🧪 리스크/알림 테스트 (다중 데이터 소스)
|
||||||
| "delivery-status"
|
| "delivery-status"
|
||||||
| "status-summary" // 범용 상태 카드 (통합)
|
| "status-summary" // 범용 상태 카드 (통합)
|
||||||
|
|
@ -155,6 +155,14 @@ export interface ChartDataSource {
|
||||||
lastError?: string; // 마지막 오류 메시지
|
lastError?: string; // 마지막 오류 메시지
|
||||||
mapDisplayType?: "auto" | "marker" | "polygon"; // 지도 표시 방식 (auto: 자동, marker: 마커, polygon: 영역)
|
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용)
|
// 메트릭 설정 (CustomMetricTestWidget용)
|
||||||
selectedColumns?: string[]; // 표시할 컬럼 선택 (빈 배열이면 전체 표시)
|
selectedColumns?: string[]; // 표시할 컬럼 선택 (빈 배열이면 전체 표시)
|
||||||
}
|
}
|
||||||
|
|
@ -163,7 +171,18 @@ export interface ChartConfig {
|
||||||
// 다중 데이터 소스 (테스트 위젯용)
|
// 다중 데이터 소스 (테스트 위젯용)
|
||||||
dataSources?: ChartDataSource[]; // 여러 데이터 소스 (REST API + Database 혼합 가능)
|
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축 필드명
|
xAxis?: string; // X축 필드명
|
||||||
yAxis?: string | string[]; // Y축 필드명 (다중 가능)
|
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 { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Loader2, RefreshCw } from "lucide-react";
|
import { Loader2, RefreshCw } from "lucide-react";
|
||||||
|
import { applyColumnMapping } from "@/lib/utils/columnMapping";
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
Line,
|
Line,
|
||||||
|
|
@ -18,6 +19,8 @@ import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
|
ComposedChart, // 🆕 바/라인/영역 혼합 차트
|
||||||
|
Area, // 🆕 영역 차트
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
|
||||||
interface ChartTestWidgetProps {
|
interface ChartTestWidgetProps {
|
||||||
|
|
@ -70,7 +73,7 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
||||||
console.error(`❌ 데이터 소스 "\${source.name || source.id}" 로딩 실패:`, err);
|
console.error(`❌ 데이터 소스 "\${source.name || source.id}" 로딩 실패:`, err);
|
||||||
return [];
|
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 데이터 로딩
|
// Database 데이터 로딩
|
||||||
|
|
@ -164,27 +170,51 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
||||||
throw new Error("SQL 쿼리가 없습니다.");
|
throw new Error("SQL 쿼리가 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch("/api/dashboards/query", {
|
let result;
|
||||||
method: "POST",
|
if (source.connectionType === "external" && source.externalConnectionId) {
|
||||||
headers: { "Content-Type": "application/json" },
|
// 외부 DB (ExternalDbConnectionAPI 사용)
|
||||||
credentials: "include",
|
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
||||||
body: JSON.stringify({
|
result = await ExternalDbConnectionAPI.executeQuery(parseInt(source.externalConnectionId), source.query);
|
||||||
connectionType: source.connectionType || "current",
|
} else {
|
||||||
externalConnectionId: source.externalConnectionId,
|
// 현재 DB (dashboardApi.executeQuery 사용)
|
||||||
query: source.query,
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
try {
|
||||||
throw new Error(`데이터베이스 쿼리 실패: \${response.status}`);
|
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) {
|
if (!result.success) {
|
||||||
throw new Error(result.message || "쿼리 실패");
|
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,98 +248,342 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
||||||
};
|
};
|
||||||
}, [dataSources, loadMultipleDataSources]);
|
}, [dataSources, loadMultipleDataSources]);
|
||||||
|
|
||||||
const chartType = element?.subtype || "line";
|
|
||||||
const chartConfig = element?.chartConfig || {};
|
const chartConfig = element?.chartConfig || {};
|
||||||
|
const chartType = chartConfig.chartType || "line";
|
||||||
|
const mergeMode = chartConfig.mergeMode || false;
|
||||||
|
const dataSourceConfigs = chartConfig.dataSourceConfigs || [];
|
||||||
|
|
||||||
|
// 멀티 데이터 소스 차트 렌더링
|
||||||
const renderChart = () => {
|
const renderChart = () => {
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const xAxis = chartConfig.xAxis || Object.keys(data[0])[0];
|
if (dataSourceConfigs.length === 0) {
|
||||||
const yAxis = chartConfig.yAxis || Object.keys(data[0])[1];
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
차트 설정에서 데이터 소스를 추가하고
|
||||||
|
<br />
|
||||||
|
X축, Y축을 설정해주세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
switch (chartType) {
|
// 병합 모드: 여러 데이터 소스를 하나의 라인/바로 합침
|
||||||
|
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={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="_xValue" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Line type="monotone" dataKey={yAxisField} name={yAxisField} stroke={COLORS[0]} strokeWidth={2} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "bar":
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="_xValue" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Bar dataKey={yAxisField} name={yAxisField} fill={COLORS[0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "area":
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="_xValue" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<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-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 "line":
|
||||||
return (
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<LineChart data={data}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis dataKey={xAxis} />
|
|
||||||
<YAxis />
|
|
||||||
<Tooltip />
|
|
||||||
<Legend />
|
|
||||||
<Line type="monotone" dataKey={yAxis} stroke="#3b82f6" strokeWidth={2} />
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "bar":
|
case "bar":
|
||||||
|
case "area":
|
||||||
|
// 🆕 ComposedChart 사용 (바/라인/영역 혼합 가능)
|
||||||
return (
|
return (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={data}>
|
<ComposedChart data={chartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
<XAxis dataKey={xAxis} />
|
<XAxis dataKey="_xValue" />
|
||||||
<YAxis />
|
<YAxis />
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Legend />
|
<Legend />
|
||||||
<Bar dataKey={yAxis} fill="#3b82f6" />
|
{dataSourceConfigs.map((dsConfig, index) => {
|
||||||
</BarChart>
|
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>
|
</ResponsiveContainer>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "pie":
|
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 (
|
return (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<div className="flex h-full items-center justify-center">
|
||||||
<PieChart>
|
<p className="text-muted-foreground text-sm">파이 차트를 표시하려면 데이터 소스를 설정하세요.</p>
|
||||||
<Pie
|
</div>
|
||||||
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>
|
|
||||||
<Tooltip />
|
|
||||||
<Legend />
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">지원하지 않는 차트 타입: {chartType}</p>
|
||||||
지원하지 않는 차트 타입: {chartType}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 className="flex items-center justify-between border-b p-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold">
|
<h3 className="text-lg font-semibold">{element?.customTitle || "차트"}</h3>
|
||||||
{element?.customTitle || "차트 테스트 (다중 데이터 소스)"}
|
<p className="text-muted-foreground text-xs">
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{dataSources?.length || 0}개 데이터 소스 • {data.length}개 데이터
|
{dataSources?.length || 0}개 데이터 소스 • {data.length}개 데이터
|
||||||
{lastRefreshTime && (
|
{lastRefreshTime && <span className="ml-2">• {lastRefreshTime.toLocaleTimeString("ko-KR")}</span>}
|
||||||
<span className="ml-2">
|
|
||||||
• {lastRefreshTime.toLocaleTimeString("ko-KR")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -330,13 +604,12 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
||||||
<div className="flex-1 p-4">
|
<div className="flex-1 p-4">
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="flex h-full items-center justify-center">
|
<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>
|
</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">
|
<div className="flex h-full items-center justify-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">데이터 소스를 연결해주세요</p>
|
||||||
데이터 소스를 연결해주세요
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
renderChart()
|
renderChart()
|
||||||
|
|
@ -344,9 +617,7 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.length > 0 && (
|
{data.length > 0 && (
|
||||||
<div className="border-t p-2 text-xs text-muted-foreground">
|
<div className="text-muted-foreground border-t p-2 text-xs">총 {data.length}개 데이터 표시 중</div>
|
||||||
총 {data.length}개 데이터 표시 중
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Loader2, RefreshCw } from "lucide-react";
|
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 {
|
interface CustomMetricTestWidgetProps {
|
||||||
element: DashboardElement;
|
element: DashboardElement;
|
||||||
|
|
@ -45,7 +48,7 @@ const colorMap = {
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 커스텀 메트릭 테스트 위젯 (다중 데이터 소스 지원)
|
* 통계 카드 위젯 (다중 데이터 소스 지원)
|
||||||
* - 여러 REST API 연결 가능
|
* - 여러 REST API 연결 가능
|
||||||
* - 여러 Database 연결 가능
|
* - 여러 Database 연결 가능
|
||||||
* - REST API + Database 혼합 가능
|
* - REST API + Database 혼합 가능
|
||||||
|
|
@ -53,9 +56,12 @@ const colorMap = {
|
||||||
*/
|
*/
|
||||||
export default function CustomMetricTestWidget({ element }: CustomMetricTestWidgetProps) {
|
export default function CustomMetricTestWidget({ element }: CustomMetricTestWidgetProps) {
|
||||||
const [metrics, setMetrics] = useState<any[]>([]);
|
const [metrics, setMetrics] = useState<any[]>([]);
|
||||||
|
const [groupedCards, setGroupedCards] = useState<Array<{ label: string; value: number }>>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [lastRefreshTime, setLastRefreshTime] = useState<Date | 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);
|
console.log("🧪 CustomMetricTestWidget 렌더링!", element);
|
||||||
|
|
||||||
|
|
@ -63,18 +69,94 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
return element?.dataSources || element?.chartConfig?.dataSources;
|
return element?.dataSources || element?.chartConfig?.dataSources;
|
||||||
}, [element?.dataSources, element?.chartConfig?.dataSources]);
|
}, [element?.dataSources, element?.chartConfig?.dataSources]);
|
||||||
|
|
||||||
|
// 🆕 그룹별 카드 모드 체크
|
||||||
|
const isGroupByMode = element?.customMetricConfig?.groupByMode || false;
|
||||||
|
|
||||||
// 메트릭 설정 (없으면 기본값 사용) - useMemo로 메모이제이션
|
// 메트릭 설정 (없으면 기본값 사용) - useMemo로 메모이제이션
|
||||||
const metricConfig = useMemo(() => {
|
const metricConfig = useMemo(() => {
|
||||||
return element?.customMetricConfig?.metrics || [
|
return (
|
||||||
{
|
element?.customMetricConfig?.metrics || [
|
||||||
label: "총 개수",
|
{
|
||||||
field: "id",
|
label: "총 개수",
|
||||||
aggregation: "count",
|
field: "id",
|
||||||
color: "indigo",
|
aggregation: "count",
|
||||||
},
|
color: "indigo",
|
||||||
];
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
}, [element?.customMetricConfig?.metrics]);
|
}, [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 loadMultipleDataSources = useCallback(async () => {
|
||||||
const dataSources = element?.dataSources || element?.chartConfig?.dataSources;
|
const dataSources = element?.dataSources || element?.chartConfig?.dataSources;
|
||||||
|
|
@ -117,7 +199,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
rows: [],
|
rows: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`✅ 총 ${results.length}개의 데이터 소스 로딩 완료`);
|
console.log(`✅ 총 ${results.length}개의 데이터 소스 로딩 완료`);
|
||||||
|
|
@ -141,21 +223,21 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
const columns = Object.keys(firstRow);
|
const columns = Object.keys(firstRow);
|
||||||
|
|
||||||
// 숫자 컬럼 찾기
|
// 숫자 컬럼 찾기
|
||||||
const numericColumns = columns.filter(col => {
|
const numericColumns = columns.filter((col) => {
|
||||||
const value = firstRow[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];
|
const value = firstRow[col];
|
||||||
return typeof value === 'string' || !numericColumns.includes(col);
|
return typeof value === "string" || !numericColumns.includes(col);
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`📊 [${sourceName}] 컬럼 분석:`, {
|
console.log(`📊 [${sourceName}] 컬럼 분석:`, {
|
||||||
전체: columns,
|
전체: columns,
|
||||||
숫자: numericColumns,
|
숫자: numericColumns,
|
||||||
문자열: stringColumns
|
문자열: stringColumns,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 숫자 컬럼이 있으면 집계된 데이터로 판단
|
// 숫자 컬럼이 있으면 집계된 데이터로 판단
|
||||||
|
|
@ -180,6 +262,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
aggregation: "custom",
|
aggregation: "custom",
|
||||||
color: colors[allMetrics.length % colors.length],
|
color: colors[allMetrics.length % colors.length],
|
||||||
sourceName: sourceName,
|
sourceName: sourceName,
|
||||||
|
rawData: rows, // 원본 데이터 저장
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -188,7 +271,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
|
|
||||||
// 데이터 소스에서 선택된 컬럼 가져오기
|
// 데이터 소스에서 선택된 컬럼 가져오기
|
||||||
const dataSourceConfig = (element?.dataSources || element?.chartConfig?.dataSources)?.find(
|
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 || [];
|
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;
|
const uniqueCount = uniqueValues.size;
|
||||||
|
|
||||||
console.log(` [${sourceName}] ${col}: ${uniqueCount}개 고유값`);
|
console.log(` [${sourceName}] ${col}: ${uniqueCount}개 고유값`);
|
||||||
|
|
@ -217,6 +300,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
aggregation: "distinct",
|
aggregation: "distinct",
|
||||||
color: colors[allMetrics.length % colors.length],
|
color: colors[allMetrics.length % colors.length],
|
||||||
sourceName: sourceName,
|
sourceName: sourceName,
|
||||||
|
rawData: rows, // 원본 데이터 저장
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -228,6 +312,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
aggregation: "count",
|
aggregation: "count",
|
||||||
color: colors[allMetrics.length % colors.length],
|
color: colors[allMetrics.length % colors.length],
|
||||||
sourceName: sourceName,
|
sourceName: sourceName,
|
||||||
|
rawData: rows, // 원본 데이터 저장
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -239,7 +324,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
|
|
||||||
// 데이터 소스에서 선택된 컬럼 가져오기
|
// 데이터 소스에서 선택된 컬럼 가져오기
|
||||||
const dataSourceConfig = (element?.dataSources || element?.chartConfig?.dataSources)?.find(
|
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 || [];
|
const selectedColumns = dataSourceConfig?.selectedColumns || [];
|
||||||
|
|
||||||
|
|
@ -256,7 +341,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueValues = new Set(rows.map(row => row[col]));
|
const uniqueValues = new Set(rows.map((row) => row[col]));
|
||||||
const uniqueCount = uniqueValues.size;
|
const uniqueCount = uniqueValues.size;
|
||||||
|
|
||||||
console.log(` [${sourceName}] ${col}: ${uniqueCount}개 고유값`);
|
console.log(` [${sourceName}] ${col}: ${uniqueCount}개 고유값`);
|
||||||
|
|
@ -268,6 +353,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
aggregation: "distinct",
|
aggregation: "distinct",
|
||||||
color: colors[allMetrics.length % colors.length],
|
color: colors[allMetrics.length % colors.length],
|
||||||
sourceName: sourceName,
|
sourceName: sourceName,
|
||||||
|
rawData: rows, // 원본 데이터 저장
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -279,6 +365,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
aggregation: "count",
|
aggregation: "count",
|
||||||
color: colors[allMetrics.length % colors.length],
|
color: colors[allMetrics.length % colors.length],
|
||||||
sourceName: sourceName,
|
sourceName: sourceName,
|
||||||
|
rawData: rows, // 원본 데이터 저장
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -293,11 +380,40 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
}
|
}
|
||||||
}, [element?.dataSources, element?.chartConfig?.dataSources, metricConfig]);
|
}, [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(() => {
|
const handleManualRefresh = useCallback(() => {
|
||||||
console.log("🔄 수동 새로고침 버튼 클릭");
|
console.log("🔄 수동 새로고침 버튼 클릭");
|
||||||
loadMultipleDataSources();
|
loadAllData();
|
||||||
}, [loadMultipleDataSources]);
|
}, [loadAllData]);
|
||||||
|
|
||||||
// XML 데이터 파싱
|
// XML 데이터 파싱
|
||||||
const parseXmlData = (xmlText: string): any[] => {
|
const parseXmlData = (xmlText: string): any[] => {
|
||||||
|
|
@ -344,7 +460,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
const lines = text.trim().split("\n");
|
const lines = text.trim().split("\n");
|
||||||
if (lines.length === 0) return [];
|
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[] = [];
|
const result: any[] = [];
|
||||||
|
|
||||||
for (let i = 1; i < lines.length; i++) {
|
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 데이터 로딩
|
// Database 데이터 로딩
|
||||||
|
|
@ -464,6 +583,8 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
throw new Error("SQL 쿼리가 없습니다.");
|
throw new Error("SQL 쿼리가 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let rows: any[] = [];
|
||||||
|
|
||||||
if (source.connectionType === "external" && source.externalConnectionId) {
|
if (source.connectionType === "external" && source.externalConnectionId) {
|
||||||
// 외부 DB
|
// 외부 DB
|
||||||
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
||||||
|
|
@ -480,24 +601,27 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
rows: Record<string, unknown>[];
|
rows: Record<string, unknown>[];
|
||||||
};
|
};
|
||||||
|
|
||||||
return resultData.rows;
|
rows = resultData.rows;
|
||||||
} else {
|
} else {
|
||||||
// 현재 DB
|
// 현재 DB
|
||||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||||
const result = await dashboardApi.executeQuery(source.query);
|
const result = await dashboardApi.executeQuery(source.query);
|
||||||
|
|
||||||
return result.rows;
|
rows = result.rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 컬럼 매핑 적용
|
||||||
|
return applyColumnMapping(rows, source.columnMapping);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 초기 로드
|
// 초기 로드 (🆕 loadAllData 사용)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dataSources && dataSources.length > 0 && metricConfig.length > 0) {
|
if ((dataSources && dataSources.length > 0) || (isGroupByMode && element?.customMetricConfig?.groupByDataSource)) {
|
||||||
loadMultipleDataSources();
|
loadAllData();
|
||||||
}
|
}
|
||||||
}, [dataSources, loadMultipleDataSources, metricConfig]);
|
}, [dataSources, isGroupByMode, element?.customMetricConfig?.groupByDataSource, loadAllData]);
|
||||||
|
|
||||||
// 자동 새로고침
|
// 자동 새로고침 (🆕 loadAllData 사용)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!dataSources || dataSources.length === 0) return;
|
if (!dataSources || dataSources.length === 0) return;
|
||||||
|
|
||||||
|
|
@ -512,107 +636,206 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
|
|
||||||
const intervalId = setInterval(() => {
|
const intervalId = setInterval(() => {
|
||||||
console.log("🔄 자동 새로고침 실행");
|
console.log("🔄 자동 새로고침 실행");
|
||||||
loadMultipleDataSources();
|
loadAllData();
|
||||||
}, minInterval * 1000);
|
}, minInterval * 1000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
console.log("⏹️ 자동 새로고침 정리");
|
console.log("⏹️ 자동 새로고침 정리");
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
};
|
};
|
||||||
}, [dataSources, loadMultipleDataSources]);
|
}, [dataSources, loadAllData]);
|
||||||
|
|
||||||
// 메트릭 카드 렌더링
|
// renderMetricCard 함수 제거 - 인라인으로 렌더링
|
||||||
const renderMetricCard = (metric: any, index: number) => {
|
|
||||||
const color = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray;
|
|
||||||
const formattedValue = metric.value.toLocaleString(undefined, {
|
|
||||||
minimumFractionDigits: metric.decimals || 0,
|
|
||||||
maximumFractionDigits: metric.decimals || 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
// 로딩 상태 (원본 스타일)
|
||||||
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
|
||||||
key={index}
|
<div className="text-center">
|
||||||
className={`rounded-lg border ${color.border} ${color.bg} p-4 shadow-sm transition-all hover:shadow-md`}
|
<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 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}`}>
|
|
||||||
{formattedValue}
|
|
||||||
{metric.unit && <span className="ml-1 text-sm">{metric.unit}</span>}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
// 메트릭 개수에 따라 그리드 컬럼 동적 결정
|
// 에러 상태 (원본 스타일)
|
||||||
const getGridCols = () => {
|
if (error) {
|
||||||
const count = metrics.length;
|
return (
|
||||||
if (count === 0) return "grid-cols-1";
|
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
|
||||||
if (count === 1) return "grid-cols-1";
|
<div className="text-center">
|
||||||
if (count <= 4) return "grid-cols-1 sm:grid-cols-2";
|
<p className="text-sm text-red-600">⚠️ {error}</p>
|
||||||
return "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3";
|
<button
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleManualRefresh}
|
onClick={handleManualRefresh}
|
||||||
disabled={loading}
|
className="mt-2 rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
|
||||||
className="h-8 gap-2 text-xs"
|
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
|
다시 시도
|
||||||
새로고침
|
</button>
|
||||||
</Button>
|
|
||||||
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{/* 컨텐츠 */}
|
// 데이터 소스 없음 (원본 스타일)
|
||||||
<div className="flex-1 overflow-auto p-4">
|
if (!(element?.dataSources || element?.chartConfig?.dataSources) && !isGroupByMode) {
|
||||||
{error ? (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
<p className="text-sm text-gray-500">데이터 소스를 연결해주세요</p>
|
||||||
</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">
|
|
||||||
데이터 소스를 연결해주세요
|
|
||||||
</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))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</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: 0,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
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="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-0.5 text-sm">{metric.unit}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상세 정보 모달 */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 메트릭 요약 */}
|
||||||
|
<div className="bg-muted/50 rounded-lg border p-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<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="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>
|
||||||
|
|
||||||
|
{/* 원본 데이터 테이블 */}
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
{selectedMetric.rawData.length > 100 && (
|
||||||
|
<p className="text-muted-foreground mt-2 text-center text-xs">
|
||||||
|
총 {selectedMetric.rawData.length}개 중 100개만 표시됩니다
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 데이터 없음 */}
|
||||||
|
{(!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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Loader2, RefreshCw } from "lucide-react";
|
import { Loader2, RefreshCw } from "lucide-react";
|
||||||
|
import { applyColumnMapping } from "@/lib/utils/columnMapping";
|
||||||
|
|
||||||
interface ListTestWidgetProps {
|
interface ListTestWidgetProps {
|
||||||
element: DashboardElement;
|
element: DashboardElement;
|
||||||
|
|
@ -32,12 +33,18 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
||||||
|
|
||||||
console.log("🧪 ListTestWidget 렌더링!", element);
|
// console.log("🧪 ListTestWidget 렌더링!", element);
|
||||||
|
|
||||||
const dataSources = useMemo(() => {
|
const dataSources = useMemo(() => {
|
||||||
return element?.dataSources || element?.chartConfig?.dataSources;
|
return element?.dataSources || element?.chartConfig?.dataSources;
|
||||||
}, [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 || {
|
const config = element.listConfig || {
|
||||||
columnMode: "auto",
|
columnMode: "auto",
|
||||||
viewMode: "table",
|
viewMode: "table",
|
||||||
|
|
@ -52,8 +59,6 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
|
|
||||||
// 다중 데이터 소스 로딩
|
// 다중 데이터 소스 로딩
|
||||||
const loadMultipleDataSources = useCallback(async () => {
|
const loadMultipleDataSources = useCallback(async () => {
|
||||||
const dataSources = element?.dataSources || element?.chartConfig?.dataSources;
|
|
||||||
|
|
||||||
if (!dataSources || dataSources.length === 0) {
|
if (!dataSources || dataSources.length === 0) {
|
||||||
console.log("⚠️ 데이터 소스가 없습니다.");
|
console.log("⚠️ 데이터 소스가 없습니다.");
|
||||||
return;
|
return;
|
||||||
|
|
@ -127,7 +132,7 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [element?.dataSources, element?.chartConfig?.dataSources]);
|
}, [dataSources]);
|
||||||
|
|
||||||
// 수동 새로고침 핸들러
|
// 수동 새로고침 핸들러
|
||||||
const handleManualRefresh = useCallback(() => {
|
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]) : [];
|
const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
|
||||||
|
|
||||||
return { columns, rows };
|
return { columns, rows };
|
||||||
|
|
@ -224,18 +233,41 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
rows: Record<string, unknown>[];
|
rows: Record<string, unknown>[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 컬럼 매핑 적용
|
||||||
|
const mappedRows = applyColumnMapping(resultData.rows, source.columnMapping);
|
||||||
|
const columns = mappedRows.length > 0 ? Object.keys(mappedRows[0]) : resultData.columns;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
columns: resultData.columns,
|
columns,
|
||||||
rows: resultData.rows,
|
rows: mappedRows,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// 현재 DB
|
// 현재 DB
|
||||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||||
const result = await dashboardApi.executeQuery(source.query);
|
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 {
|
return {
|
||||||
columns: result.columns,
|
columns,
|
||||||
rows: result.rows,
|
rows: mappedRows,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -330,7 +362,7 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
<div className="flex items-center justify-between border-b p-4">
|
<div className="flex items-center justify-between border-b p-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold">
|
<h3 className="text-lg font-semibold">
|
||||||
{element?.customTitle || "리스트 테스트 (다중 데이터 소스)"}
|
{element?.customTitle || "리스트"}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{dataSources?.length || 0}개 데이터 소스 • {data?.totalRows || 0}개 행
|
{dataSources?.length || 0}개 데이터 소스 • {data?.totalRows || 0}개 행
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import dynamic from "next/dynamic";
|
||||||
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Loader2, RefreshCw } from "lucide-react";
|
import { Loader2, RefreshCw } from "lucide-react";
|
||||||
|
import { applyColumnMapping } from "@/lib/utils/columnMapping";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
// Leaflet 아이콘 경로 설정 (엑박 방지)
|
// Leaflet 아이콘 경로 설정 (엑박 방지)
|
||||||
|
|
@ -43,6 +44,7 @@ interface MarkerData {
|
||||||
status?: string;
|
status?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
source?: string; // 어느 데이터 소스에서 왔는지
|
source?: string; // 어느 데이터 소스에서 왔는지
|
||||||
|
color?: string; // 마커 색상
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PolygonData {
|
interface PolygonData {
|
||||||
|
|
@ -53,6 +55,7 @@ interface PolygonData {
|
||||||
description?: string;
|
description?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
opacity?: number; // 투명도 (0.0 ~ 1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
|
|
@ -215,7 +218,9 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
const parsedData = parseTextData(data.text);
|
const parsedData = parseTextData(data.text);
|
||||||
if (parsedData.length > 0) {
|
if (parsedData.length > 0) {
|
||||||
console.log(`✅ CSV 파싱 성공: ${parsedData.length}개 행`);
|
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];
|
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 데이터 로딩
|
// Database 데이터 로딩
|
||||||
|
|
@ -268,8 +276,11 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
rows = result.rows;
|
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 등)
|
// 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(`🔄 ${sourceName} 데이터 변환 시작:`, rows.length, "개 행");
|
||||||
console.log(` 📌 mapDisplayType:`, mapDisplayType, `(타입: ${typeof mapDisplayType})`);
|
console.log(` 📌 mapDisplayType:`, mapDisplayType, `(타입: ${typeof mapDisplayType})`);
|
||||||
|
console.log(` 🎨 마커 색상:`, dataSource?.markerColor, `폴리곤 색상:`, dataSource?.polygonColor);
|
||||||
|
|
||||||
if (rows.length === 0) return { markers: [], polygons: [] };
|
if (rows.length === 0) return { markers: [], polygons: [] };
|
||||||
|
|
||||||
|
|
@ -383,8 +400,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
const parsedData = parseTextData(row.text);
|
const parsedData = parseTextData(row.text);
|
||||||
console.log(` ✅ CSV 파싱 결과: ${parsedData.length}개 행`);
|
console.log(` ✅ CSV 파싱 결과: ${parsedData.length}개 행`);
|
||||||
|
|
||||||
// 파싱된 데이터를 재귀적으로 변환
|
// 파싱된 데이터를 재귀적으로 변환 (색상 정보 전달)
|
||||||
const result = convertToMapData(parsedData, sourceName, mapDisplayType);
|
const result = convertToMapData(parsedData, sourceName, mapDisplayType, dataSource);
|
||||||
markers.push(...result.markers);
|
markers.push(...result.markers);
|
||||||
polygons.push(...result.polygons);
|
polygons.push(...result.polygons);
|
||||||
return; // 이 행은 처리 완료
|
return; // 이 행은 처리 완료
|
||||||
|
|
@ -404,7 +421,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
status: row.status || row.level,
|
status: row.status || row.level,
|
||||||
description: row.description || JSON.stringify(row, null, 2),
|
description: row.description || JSON.stringify(row, null, 2),
|
||||||
source: sourceName,
|
source: sourceName,
|
||||||
color: getColorByStatus(row.status || row.level),
|
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
|
||||||
});
|
});
|
||||||
return; // 폴리곤으로 처리했으므로 마커로는 추가하지 않음
|
return; // 폴리곤으로 처리했으므로 마커로는 추가하지 않음
|
||||||
}
|
}
|
||||||
|
|
@ -421,7 +438,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
status: row.status || row.level,
|
status: row.status || row.level,
|
||||||
description: row.description || `${row.type || ''} ${row.level || ''}`.trim() || JSON.stringify(row, null, 2),
|
description: row.description || `${row.type || ''} ${row.level || ''}`.trim() || JSON.stringify(row, null, 2),
|
||||||
source: sourceName,
|
source: sourceName,
|
||||||
color: getColorByStatus(row.status || row.level),
|
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
|
||||||
});
|
});
|
||||||
return; // 폴리곤으로 처리했으므로 마커로는 추가하지 않음
|
return; // 폴리곤으로 처리했으므로 마커로는 추가하지 않음
|
||||||
}
|
}
|
||||||
|
|
@ -466,7 +483,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
status: row.status || row.level,
|
status: row.status || row.level,
|
||||||
description: row.description || JSON.stringify(row, null, 2),
|
description: row.description || JSON.stringify(row, null, 2),
|
||||||
source: sourceName,
|
source: sourceName,
|
||||||
color: getColorByStatus(row.status || row.level),
|
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.log(` ⚠️ 강제 폴리곤 모드지만 지역명 없음 - 스킵`);
|
console.log(` ⚠️ 강제 폴리곤 모드지만 지역명 없음 - 스킵`);
|
||||||
|
|
@ -487,6 +504,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
status: row.status || row.level,
|
status: row.status || row.level,
|
||||||
description: row.description || JSON.stringify(row, null, 2),
|
description: row.description || JSON.stringify(row, null, 2),
|
||||||
source: sourceName,
|
source: sourceName,
|
||||||
|
color: dataSource?.markerColor || "#3b82f6", // 사용자 지정 색상 또는 기본 파랑
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 위도/경도가 없는 육지 지역 → 폴리곤으로 추가 (GeoJSON 매칭용)
|
// 위도/경도가 없는 육지 지역 → 폴리곤으로 추가 (GeoJSON 매칭용)
|
||||||
|
|
@ -500,7 +518,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
status: row.status || row.level,
|
status: row.status || row.level,
|
||||||
description: row.description || JSON.stringify(row, null, 2),
|
description: row.description || JSON.stringify(row, null, 2),
|
||||||
source: sourceName,
|
source: sourceName,
|
||||||
color: getColorByStatus(row.status || row.level),
|
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.log(` ⚠️ 위도/경도 없고 지역명도 없음 - 스킵`);
|
console.log(` ⚠️ 위도/경도 없고 지역명도 없음 - 스킵`);
|
||||||
|
|
@ -803,7 +821,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
<div className="flex items-center justify-between border-b p-4">
|
<div className="flex items-center justify-between border-b p-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold">
|
<h3 className="text-lg font-semibold">
|
||||||
{element?.customTitle || "지도 테스트 V2 (다중 데이터 소스)"}
|
{element?.customTitle || "지도"}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{element?.dataSources?.length || 0}개 데이터 소스 연결됨
|
{element?.dataSources?.length || 0}개 데이터 소스 연결됨
|
||||||
|
|
@ -989,11 +1007,38 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* 마커 렌더링 */}
|
{/* 마커 렌더링 */}
|
||||||
{markers.map((marker) => (
|
{markers.map((marker) => {
|
||||||
<Marker
|
// 커스텀 색상 아이콘 생성
|
||||||
key={marker.id}
|
let customIcon;
|
||||||
position={[marker.lat, marker.lng]}
|
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}>
|
<Popup maxWidth={350}>
|
||||||
<div className="min-w-[250px] max-w-[350px]">
|
<div className="min-w-[250px] max-w-[350px]">
|
||||||
{/* 제목 */}
|
{/* 제목 */}
|
||||||
|
|
@ -1071,7 +1116,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -466,7 +466,7 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
||||||
<div className="flex h-full items-center justify-center p-3">
|
<div className="flex h-full items-center justify-center p-3">
|
||||||
<div className="max-w-xs space-y-2 text-center">
|
<div className="max-w-xs space-y-2 text-center">
|
||||||
<div className="text-3xl">🚨</div>
|
<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">
|
<div className="space-y-1.5 text-xs text-gray-600">
|
||||||
<p className="font-medium">다중 데이터 소스 지원</p>
|
<p className="font-medium">다중 데이터 소스 지원</p>
|
||||||
<ul className="space-y-0.5 text-left">
|
<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 className="flex items-center justify-between border-b bg-white/80 p-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-semibold">
|
<h3 className="text-base font-semibold">
|
||||||
{element?.customTitle || "리스크/알림 테스트"}
|
{element?.customTitle || "리스크/알림"}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{dataSources?.length || 0}개 데이터 소스 • {alerts.length}개 알림
|
{dataSources?.length || 0}개 데이터 소스 • {alerts.length}개 알림
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ async function apiRequest<T>(
|
||||||
const API_BASE_URL = getApiBaseUrl();
|
const API_BASE_URL = getApiBaseUrl();
|
||||||
|
|
||||||
const config: RequestInit = {
|
const config: RequestInit = {
|
||||||
|
credentials: "include", // ⭐ 세션 쿠키 전송 필수
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...(token && { Authorization: `Bearer ${token}` }),
|
...(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