테스트 위젯 원본 승격 전 세이브

This commit is contained in:
leeheejin 2025-10-28 17:40:48 +09:00
parent fb73ee2878
commit 81458549af
17 changed files with 2404 additions and 306 deletions

View File

@ -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 모두 지원
- ✅ 실시간으로 결과 확인
- ✅ 언제든지 수정 가능
**지금 바로 사용해보세요!** 🚀

View File

@ -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
**상태**: ✅ 완료

View File

@ -908,7 +908,7 @@ export function CanvasElement({
<ListTestWidget element={element} />
</div>
) : element.type === "widget" && element.subtype === "custom-metric-test" ? (
// 🧪 테스트용 커스텀 메트릭 위젯 (다중 데이터 소스)
// 🧪 통계 카드 (다중 데이터 소스)
<div className="widget-interactive-area h-full w-full">
<CustomMetricTestWidget element={element} />
</div>

View File

@ -181,15 +181,15 @@ export function DashboardTopMenu({
<SelectValue placeholder="위젯 추가" />
</SelectTrigger>
<SelectContent className="z-[99999]">
<SelectGroup>
<SelectLabel>🧪 ( )</SelectLabel>
<SelectItem value="map-test-v2">🧪 V2</SelectItem>
<SelectItem value="chart-test">🧪 </SelectItem>
<SelectItem value="list-test">🧪 </SelectItem>
<SelectItem value="custom-metric-test">🧪 </SelectItem>
<SelectItem value="status-summary-test">🧪 </SelectItem>
<SelectItem value="risk-alert-test">🧪 / </SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel>🧪 ( )</SelectLabel>
<SelectItem value="map-test-v2">🧪 V2</SelectItem>
<SelectItem value="chart-test">🧪 </SelectItem>
<SelectItem value="list-test">🧪 </SelectItem>
<SelectItem value="custom-metric-test"> </SelectItem>
{/* <SelectItem value="status-summary-test">🧪 상태 요약 테스트</SelectItem> */}
<SelectItem value="risk-alert-test">🧪 / </SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel> </SelectLabel>
<SelectItem value="list"> </SelectItem>
@ -197,7 +197,7 @@ export function DashboardTopMenu({
<SelectItem value="yard-management-3d"> 3D</SelectItem>
{/* <SelectItem value="transport-stats">커스텀 통계 카드</SelectItem> */}
<SelectItem value="map-summary"> </SelectItem>
<SelectItem value="map-test">🧪 (REST API)</SelectItem>
{/* <SelectItem value="map-test">🧪 지도 테스트 (REST API)</SelectItem> */}
{/* <SelectItem value="status-summary">커스텀 상태 카드</SelectItem> */}
</SelectGroup>
<SelectGroup>

View File

@ -6,6 +6,7 @@ import { QueryEditor } from "./QueryEditor";
import { ChartConfigPanel } from "./ChartConfigPanel";
import { VehicleMapConfigPanel } from "./VehicleMapConfigPanel";
import { MapTestConfigPanel } from "./MapTestConfigPanel";
import { MultiChartConfigPanel } from "./MultiChartConfigPanel";
import { DatabaseConfig } from "./data-sources/DatabaseConfig";
import { ApiConfig } from "./data-sources/ApiConfig";
import MultiDataSourceConfig from "./data-sources/MultiDataSourceConfig";
@ -41,16 +42,41 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
const [customTitle, setCustomTitle] = useState<string>("");
const [showHeader, setShowHeader] = useState<boolean>(true);
// 멀티 데이터 소스의 테스트 결과 저장 (ChartTestWidget용)
const [testResults, setTestResults] = useState<Map<string, { columns: string[]; rows: Record<string, unknown>[] }>>(
new Map(),
);
// 사이드바가 열릴 때 초기화
useEffect(() => {
if (isOpen && element) {
console.log("🔄 ElementConfigSidebar 초기화 - element.id:", element.id);
console.log("🔄 element.dataSources:", element.dataSources);
console.log("🔄 element.chartConfig?.dataSources:", element.chartConfig?.dataSources);
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 });
// dataSources는 element.dataSources 또는 chartConfig.dataSources에서 로드
setDataSources(element.dataSources || element.chartConfig?.dataSources || []);
// ⚠️ 중요: 없으면 반드시 빈 배열로 초기화
const initialDataSources = element.dataSources || element.chartConfig?.dataSources || [];
console.log("🔄 초기화된 dataSources:", initialDataSources);
setDataSources(initialDataSources);
setChartConfig(element.chartConfig || {});
setQueryResult(null);
setTestResults(new Map()); // 테스트 결과도 초기화
setCustomTitle(element.customTitle || "");
setShowHeader(element.showHeader !== false);
} else if (!isOpen) {
// 사이드바가 닫힐 때 모든 상태 초기화
console.log("🧹 ElementConfigSidebar 닫힘 - 상태 초기화");
setDataSource({ type: "database", connectionType: "current", refreshInterval: 0 });
setDataSources([]);
setChartConfig({});
setQueryResult(null);
setTestResults(new Map());
setCustomTitle("");
setShowHeader(true);
}
}, [isOpen, element]);
@ -321,7 +347,27 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
{isMultiDataSourceWidget && (
<>
<div className="rounded-lg bg-white p-3 shadow-sm">
<MultiDataSourceConfig dataSources={dataSources} onChange={setDataSources} />
<MultiDataSourceConfig
dataSources={dataSources}
onChange={setDataSources}
onTestResult={(result, dataSourceId) => {
// API 테스트 결과를 queryResult로 설정 (차트 설정용)
setQueryResult({
...result,
totalRows: result.rows.length,
executionTime: 0,
});
console.log("📊 API 테스트 결과 수신:", result, "데이터 소스 ID:", dataSourceId);
// ChartTestWidget용: 각 데이터 소스의 테스트 결과 저장
setTestResults((prev) => {
const updated = new Map(prev);
updated.set(dataSourceId, result);
console.log("📊 테스트 결과 저장:", dataSourceId, result);
return updated;
});
}}
/>
</div>
{/* 지도 테스트 V2: 타일맵 URL 설정 */}
@ -354,6 +400,40 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
</details>
</div>
)}
{/* 차트 테스트: 차트 설정 */}
{element.subtype === "chart-test" && (
<div className="rounded-lg bg-white shadow-sm">
<details className="group" open>
<summary className="flex cursor-pointer items-center justify-between p-3 hover:bg-gray-50">
<div>
<div className="text-xs font-semibold tracking-wide text-gray-500 uppercase"> </div>
<div className="text-muted-foreground mt-0.5 text-[10px]">
{testResults.size > 0
? `${testResults.size}개 데이터 소스 • X축, Y축, 차트 타입 설정`
: "먼저 데이터 소스를 추가하고 API 테스트를 실행하세요"}
</div>
</div>
<svg
className="h-4 w-4 transition-transform group-open:rotate-180"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div className="border-t p-3">
<MultiChartConfigPanel
config={chartConfig}
dataSources={dataSources}
testResults={testResults}
onConfigChange={handleChartConfigChange}
/>
</div>
</details>
</div>
)}
</>
)}

View File

@ -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>
);
}

View File

@ -557,6 +557,55 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
</p>
</div>
{/* 지도 색상 설정 (MapTestWidgetV2 전용) */}
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
<h5 className="text-xs font-semibold">🎨 </h5>
{/* 색상 팔레트 */}
<div className="space-y-2">
<Label className="text-xs"></Label>
<div className="grid grid-cols-4 gap-2">
{[
{ name: "파랑", marker: "#3b82f6", polygon: "#3b82f6" },
{ name: "빨강", marker: "#ef4444", polygon: "#ef4444" },
{ name: "초록", marker: "#10b981", polygon: "#10b981" },
{ name: "노랑", marker: "#f59e0b", polygon: "#f59e0b" },
{ name: "보라", marker: "#8b5cf6", polygon: "#8b5cf6" },
{ name: "주황", marker: "#f97316", polygon: "#f97316" },
{ name: "청록", marker: "#06b6d4", polygon: "#06b6d4" },
{ name: "분홍", marker: "#ec4899", polygon: "#ec4899" },
].map((color) => {
const isSelected = dataSource.markerColor === color.marker;
return (
<button
key={color.name}
type="button"
onClick={() => onChange({
markerColor: color.marker,
polygonColor: color.polygon,
polygonOpacity: 0.5,
})}
className={`flex h-16 flex-col items-center justify-center gap-1 rounded-md border-2 transition-all hover:scale-105 ${
isSelected
? "border-primary bg-primary/10 shadow-md"
: "border-border bg-background hover:border-primary/50"
}`}
>
<div
className="h-6 w-6 rounded-full border-2 border-white shadow-sm"
style={{ backgroundColor: color.marker }}
/>
<span className="text-[10px] font-medium">{color.name}</span>
</button>
);
})}
</div>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
</div>
{/* 테스트 버튼 */}
<div className="space-y-2 border-t pt-4">
<Button
@ -745,6 +794,103 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
)}
</div>
)}
{/* 컬럼 매핑 (API 테스트 성공 후에만 표시) */}
{testResult?.success && availableColumns.length > 0 && (
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
<div className="flex items-center justify-between">
<div>
<h5 className="text-xs font-semibold">🔄 ()</h5>
<p className="text-[10px] text-muted-foreground mt-0.5">
</p>
</div>
{dataSource.columnMapping && Object.keys(dataSource.columnMapping).length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => onChange({ columnMapping: {} })}
className="h-7 text-xs"
>
</Button>
)}
</div>
{/* 매핑 목록 */}
{dataSource.columnMapping && Object.keys(dataSource.columnMapping).length > 0 && (
<div className="space-y-2">
{Object.entries(dataSource.columnMapping).map(([original, mapped]) => (
<div key={original} className="flex items-center gap-2">
{/* 원본 컬럼 (읽기 전용) */}
<Input
value={original}
disabled
className="h-8 flex-1 text-xs bg-muted"
/>
{/* 화살표 */}
<span className="text-muted-foreground text-xs"></span>
{/* 표시 이름 (편집 가능) */}
<Input
value={mapped}
onChange={(e) => {
const newMapping = { ...dataSource.columnMapping };
newMapping[original] = e.target.value;
onChange({ columnMapping: newMapping });
}}
placeholder="표시 이름"
className="h-8 flex-1 text-xs"
/>
{/* 삭제 버튼 */}
<Button
variant="ghost"
size="sm"
onClick={() => {
const newMapping = { ...dataSource.columnMapping };
delete newMapping[original];
onChange({ columnMapping: newMapping });
}}
className="h-8 w-8 p-0"
>
<XCircle className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{/* 매핑 추가 */}
<Select
value=""
onValueChange={(col) => {
const newMapping = { ...dataSource.columnMapping } || {};
newMapping[col] = col; // 기본값은 원본과 동일
onChange({ columnMapping: newMapping });
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택하여 매핑 추가" />
</SelectTrigger>
<SelectContent>
{availableColumns
.filter(col => !dataSource.columnMapping || !dataSource.columnMapping[col])
.map(col => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))
}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
💡
</p>
</div>
)}
</div>
);
}

View File

@ -20,11 +20,13 @@ import MultiDatabaseConfig from "./MultiDatabaseConfig";
interface MultiDataSourceConfigProps {
dataSources: ChartDataSource[];
onChange: (dataSources: ChartDataSource[]) => void;
onTestResult?: (result: { columns: string[]; rows: any[] }, dataSourceId: string) => void;
}
export default function MultiDataSourceConfig({
dataSources = [],
onChange,
onTestResult,
}: MultiDataSourceConfigProps) {
const [activeTab, setActiveTab] = useState<string>(
dataSources.length > 0 ? dataSources[0].id || "0" : "new"
@ -258,12 +260,24 @@ export default function MultiDataSourceConfig({
onTestResult={(data) => {
setPreviewData(data);
setShowPreview(true);
// 부모로 테스트 결과 전달 (차트 설정용)
if (onTestResult && data.length > 0 && ds.id) {
const columns = Object.keys(data[0]);
onTestResult({ columns, rows: data }, ds.id);
}
}}
/>
) : (
<MultiDatabaseConfig
dataSource={ds}
onChange={(updates) => handleUpdateDataSource(ds.id!, updates)}
onTestResult={(data) => {
// 부모로 테스트 결과 전달 (차트 설정용)
if (onTestResult && data.length > 0 && ds.id) {
const columns = Object.keys(data[0]);
onTestResult({ columns, rows: data }, ds.id);
}
}}
/>
)}
</TabsContent>

View File

@ -13,6 +13,7 @@ import { Loader2, CheckCircle, XCircle } from "lucide-react";
interface MultiDatabaseConfigProps {
dataSource: ChartDataSource;
onChange: (updates: Partial<ChartDataSource>) => void;
onTestResult?: (data: any[]) => void;
}
interface ExternalConnection {
@ -21,7 +22,7 @@ interface ExternalConnection {
type: string;
}
export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatabaseConfigProps) {
export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult }: MultiDatabaseConfigProps) {
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string; rowCount?: number } | null>(null);
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
@ -122,6 +123,11 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
message: "쿼리 실행 성공",
rowCount,
});
// 부모로 테스트 결과 전달 (차트 설정용)
if (onTestResult && rows && rows.length > 0) {
onTestResult(rows);
}
} else {
setTestResult({ success: false, message: result.message || "쿼리 실행 실패" });
}
@ -166,6 +172,11 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
message: "쿼리 실행 성공",
rowCount: result.rowCount || 0,
});
// 부모로 테스트 결과 전달 (차트 설정용)
if (onTestResult && result.rows && result.rows.length > 0) {
onTestResult(result.rows);
}
}
} catch (error: any) {
setTestResult({ success: false, message: error.message || "네트워크 오류" });
@ -240,9 +251,61 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
{/* SQL 쿼리 */}
<div className="space-y-2">
<Label htmlFor={`query-\${dataSource.id}`} className="text-xs">
SQL *
</Label>
<div className="flex items-center justify-between">
<Label htmlFor={`query-\${dataSource.id}`} className="text-xs">
SQL *
</Label>
<Select onValueChange={(value) => {
const samples = {
users: `SELECT
dept_name as ,
COUNT(*) as
FROM user_info
WHERE dept_name IS NOT NULL
GROUP BY dept_name
ORDER BY DESC`,
dept: `SELECT
dept_code as ,
dept_name as ,
location_name as ,
TO_CHAR(regdate, 'YYYY-MM-DD') as
FROM dept_info
ORDER BY dept_code`,
usersByDate: `SELECT
DATE_TRUNC('month', regdate)::date as ,
COUNT(*) as
FROM user_info
WHERE regdate >= CURRENT_DATE - INTERVAL '12 months'
GROUP BY DATE_TRUNC('month', regdate)
ORDER BY `,
usersByPosition: `SELECT
position_name as ,
COUNT(*) as
FROM user_info
WHERE position_name IS NOT NULL
GROUP BY position_name
ORDER BY DESC`,
deptHierarchy: `SELECT
COALESCE(parent_dept_code, '최상위') as ,
COUNT(*) as
FROM dept_info
GROUP BY parent_dept_code
ORDER BY DESC`,
};
onChange({ query: samples[value as keyof typeof samples] || "" });
}}>
<SelectTrigger className="h-7 w-32 text-xs">
<SelectValue placeholder="샘플 쿼리" />
</SelectTrigger>
<SelectContent>
<SelectItem value="users" className="text-xs"> </SelectItem>
<SelectItem value="dept" className="text-xs"> </SelectItem>
<SelectItem value="usersByDate" className="text-xs"> </SelectItem>
<SelectItem value="usersByPosition" className="text-xs"> </SelectItem>
<SelectItem value="deptHierarchy" className="text-xs"> </SelectItem>
</SelectContent>
</Select>
</div>
<Textarea
id={`query-\${dataSource.id}`}
value={dataSource.query || ""}
@ -251,7 +314,7 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
className="min-h-[120px] font-mono text-xs"
/>
<p className="text-[10px] text-muted-foreground">
SELECT
SELECT . .
</p>
</div>
@ -283,6 +346,55 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
</p>
</div>
{/* 지도 색상 설정 (MapTestWidgetV2 전용) */}
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
<h5 className="text-xs font-semibold">🎨 </h5>
{/* 색상 팔레트 */}
<div className="space-y-2">
<Label className="text-xs"></Label>
<div className="grid grid-cols-4 gap-2">
{[
{ name: "파랑", marker: "#3b82f6", polygon: "#3b82f6" },
{ name: "빨강", marker: "#ef4444", polygon: "#ef4444" },
{ name: "초록", marker: "#10b981", polygon: "#10b981" },
{ name: "노랑", marker: "#f59e0b", polygon: "#f59e0b" },
{ name: "보라", marker: "#8b5cf6", polygon: "#8b5cf6" },
{ name: "주황", marker: "#f97316", polygon: "#f97316" },
{ name: "청록", marker: "#06b6d4", polygon: "#06b6d4" },
{ name: "분홍", marker: "#ec4899", polygon: "#ec4899" },
].map((color) => {
const isSelected = dataSource.markerColor === color.marker;
return (
<button
key={color.name}
type="button"
onClick={() => onChange({
markerColor: color.marker,
polygonColor: color.polygon,
polygonOpacity: 0.5,
})}
className={`flex h-16 flex-col items-center justify-center gap-1 rounded-md border-2 transition-all hover:scale-105 ${
isSelected
? "border-primary bg-primary/10 shadow-md"
: "border-border bg-background hover:border-primary/50"
}`}
>
<div
className="h-6 w-6 rounded-full border-2 border-white shadow-sm"
style={{ backgroundColor: color.marker }}
/>
<span className="text-[10px] font-medium">{color.name}</span>
</button>
);
})}
</div>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
</div>
{/* 테스트 버튼 */}
<div className="space-y-2 border-t pt-4">
<Button
@ -476,6 +588,103 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
)}
</div>
)}
{/* 컬럼 매핑 (쿼리 테스트 성공 후에만 표시) */}
{testResult?.success && availableColumns.length > 0 && (
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
<div className="flex items-center justify-between">
<div>
<h5 className="text-xs font-semibold">🔄 ()</h5>
<p className="text-[10px] text-muted-foreground mt-0.5">
</p>
</div>
{dataSource.columnMapping && Object.keys(dataSource.columnMapping).length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => onChange({ columnMapping: {} })}
className="h-7 text-xs"
>
</Button>
)}
</div>
{/* 매핑 목록 */}
{dataSource.columnMapping && Object.keys(dataSource.columnMapping).length > 0 && (
<div className="space-y-2">
{Object.entries(dataSource.columnMapping).map(([original, mapped]) => (
<div key={original} className="flex items-center gap-2">
{/* 원본 컬럼 (읽기 전용) */}
<Input
value={original}
disabled
className="h-8 flex-1 text-xs bg-muted"
/>
{/* 화살표 */}
<span className="text-muted-foreground text-xs"></span>
{/* 표시 이름 (편집 가능) */}
<Input
value={mapped}
onChange={(e) => {
const newMapping = { ...dataSource.columnMapping };
newMapping[original] = e.target.value;
onChange({ columnMapping: newMapping });
}}
placeholder="표시 이름"
className="h-8 flex-1 text-xs"
/>
{/* 삭제 버튼 */}
<Button
variant="ghost"
size="sm"
onClick={() => {
const newMapping = { ...dataSource.columnMapping };
delete newMapping[original];
onChange({ columnMapping: newMapping });
}}
className="h-8 w-8 p-0"
>
<XCircle className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{/* 매핑 추가 */}
<Select
value=""
onValueChange={(col) => {
const newMapping = { ...dataSource.columnMapping } || {};
newMapping[col] = col; // 기본값은 원본과 동일
onChange({ columnMapping: newMapping });
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택하여 매핑 추가" />
</SelectTrigger>
<SelectContent>
{availableColumns
.filter(col => !dataSource.columnMapping || !dataSource.columnMapping[col])
.map(col => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))
}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
💡
</p>
</div>
)}
</div>
);
}

View File

@ -23,12 +23,12 @@ export type ElementSubtype =
| "vehicle-list" // (구버전 - 호환용)
| "vehicle-map" // (구버전 - 호환용)
| "map-summary" // 범용 지도 카드 (통합)
| "map-test" // 🧪 지도 테스트 위젯 (REST API 지원)
// | "map-test" // 🧪 지도 테스트 위젯 (REST API 지원) - V2로 대체
| "map-test-v2" // 🧪 지도 테스트 V2 (다중 데이터 소스)
| "chart-test" // 🧪 차트 테스트 (다중 데이터 소스)
| "list-test" // 🧪 리스트 테스트 (다중 데이터 소스)
| "custom-metric-test" // 🧪 커스텀 메트릭 테스트 (다중 데이터 소스)
| "status-summary-test" // 🧪 상태 요약 테스트 (다중 데이터 소스)
| "custom-metric-test" // 🧪 통계 카드 (다중 데이터 소스)
// | "status-summary-test" // 🧪 상태 요약 테스트 (CustomMetricTest로 대체 가능)
| "risk-alert-test" // 🧪 리스크/알림 테스트 (다중 데이터 소스)
| "delivery-status"
| "status-summary" // 범용 상태 카드 (통합)
@ -155,6 +155,14 @@ export interface ChartDataSource {
lastError?: string; // 마지막 오류 메시지
mapDisplayType?: "auto" | "marker" | "polygon"; // 지도 표시 방식 (auto: 자동, marker: 마커, polygon: 영역)
// 지도 색상 설정 (MapTestWidgetV2용)
markerColor?: string; // 마커 색상 (예: "#ff0000")
polygonColor?: string; // 폴리곤 색상 (예: "#0000ff")
polygonOpacity?: number; // 폴리곤 투명도 (0.0 ~ 1.0, 기본값: 0.5)
// 컬럼 매핑 (다중 데이터 소스 통합용)
columnMapping?: Record<string, string>; // { 원본컬럼: 표시이름 } (예: { "name": "product" })
// 메트릭 설정 (CustomMetricTestWidget용)
selectedColumns?: string[]; // 표시할 컬럼 선택 (빈 배열이면 전체 표시)
}
@ -163,7 +171,18 @@ export interface ChartConfig {
// 다중 데이터 소스 (테스트 위젯용)
dataSources?: ChartDataSource[]; // 여러 데이터 소스 (REST API + Database 혼합 가능)
// 축 매핑
// 멀티 차트 설정 (ChartTestWidget용)
chartType?: string; // 차트 타입 (line, bar, pie, etc.)
mergeMode?: boolean; // 데이터 병합 모드 (여러 데이터 소스를 하나의 라인/바로 합침)
dataSourceConfigs?: Array<{
dataSourceId: string; // 데이터 소스 ID
xAxis: string; // X축 필드명
yAxis: string[]; // Y축 필드명 배열
label?: string; // 데이터 소스 라벨
chartType?: "bar" | "line" | "area"; // 🆕 각 데이터 소스별 차트 타입 (바/라인/영역 혼합 가능)
}>;
// 축 매핑 (단일 데이터 소스용)
xAxis?: string; // X축 필드명
yAxis?: string | string[]; // Y축 필드명 (다중 가능)

View File

@ -4,6 +4,7 @@ import React, { useEffect, useState, useCallback, useMemo } from "react";
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
import { Button } from "@/components/ui/button";
import { Loader2, RefreshCw } from "lucide-react";
import { applyColumnMapping } from "@/lib/utils/columnMapping";
import {
LineChart,
Line,
@ -18,6 +19,8 @@ import {
Tooltip,
Legend,
ResponsiveContainer,
ComposedChart, // 🆕 바/라인/영역 혼합 차트
Area, // 🆕 영역 차트
} from "recharts";
interface ChartTestWidgetProps {
@ -70,7 +73,7 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
console.error(`❌ 데이터 소스 "\${source.name || source.id}" 로딩 실패:`, err);
return [];
}
})
}),
);
// 성공한 데이터만 병합
@ -155,7 +158,10 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
}
}
return Array.isArray(apiData) ? apiData : [apiData];
const rows = Array.isArray(apiData) ? apiData : [apiData];
// 컬럼 매핑 적용
return applyColumnMapping(rows, source.columnMapping);
};
// Database 데이터 로딩
@ -164,27 +170,51 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
throw new Error("SQL 쿼리가 없습니다.");
}
const response = await fetch("/api/dashboards/query", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
connectionType: source.connectionType || "current",
externalConnectionId: source.externalConnectionId,
query: source.query,
}),
});
let result;
if (source.connectionType === "external" && source.externalConnectionId) {
// 외부 DB (ExternalDbConnectionAPI 사용)
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
result = await ExternalDbConnectionAPI.executeQuery(parseInt(source.externalConnectionId), source.query);
} else {
// 현재 DB (dashboardApi.executeQuery 사용)
const { dashboardApi } = await import("@/lib/api/dashboard");
if (!response.ok) {
throw new Error(`데이터베이스 쿼리 실패: \${response.status}`);
try {
const queryResult = await dashboardApi.executeQuery(source.query);
result = {
success: true,
rows: queryResult.rows || [],
};
} catch (err: any) {
console.error("❌ 내부 DB 쿼리 실패:", err);
throw new Error(err.message || "쿼리 실패");
}
}
const result = await response.json();
if (!result.success) {
throw new Error(result.message || "쿼리 실패");
}
return result.data || [];
const rows = result.rows || result.data || [];
console.log("💾 내부 DB 쿼리 결과:", {
hasRows: !!rows,
rowCount: rows.length,
hasColumns: rows.length > 0 && Object.keys(rows[0]).length > 0,
columnCount: rows.length > 0 ? Object.keys(rows[0]).length : 0,
firstRow: rows[0],
});
// 컬럼 매핑 적용
const mappedRows = applyColumnMapping(rows, source.columnMapping);
console.log("✅ 매핑 후:", {
columns: mappedRows.length > 0 ? Object.keys(mappedRows[0]) : [],
rowCount: mappedRows.length,
firstMappedRow: mappedRows[0],
});
return mappedRows;
};
// 초기 로드
@ -218,98 +248,342 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
};
}, [dataSources, loadMultipleDataSources]);
const chartType = element?.subtype || "line";
const chartConfig = element?.chartConfig || {};
const chartType = chartConfig.chartType || "line";
const mergeMode = chartConfig.mergeMode || false;
const dataSourceConfigs = chartConfig.dataSourceConfigs || [];
// 멀티 데이터 소스 차트 렌더링
const renderChart = () => {
if (data.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground"> </p>
<p className="text-muted-foreground text-sm"> </p>
</div>
);
}
const xAxis = chartConfig.xAxis || Object.keys(data[0])[0];
const yAxis = chartConfig.yAxis || Object.keys(data[0])[1];
if (dataSourceConfigs.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
<br />
X축, Y축을
</p>
</div>
);
}
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":
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 "area":
// 🆕 ComposedChart 사용 (바/라인/영역 혼합 가능)
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data}>
<ComposedChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={xAxis} />
<XAxis dataKey="_xValue" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey={yAxis} fill="#3b82f6" />
</BarChart>
{dataSourceConfigs.map((dsConfig, index) => {
const sourceName =
dataSources.find((ds) => ds.id === dsConfig.dataSourceId)?.name || `소스 ${index + 1}`;
const yField = dsConfig.yAxis[0];
const dataKey = `${sourceName}_${yField}`;
const label = dsConfig.label || sourceName;
const color = COLORS[index % COLORS.length];
// 개별 차트 타입 또는 전역 차트 타입 사용
const individualChartType = dsConfig.chartType || chartType;
// 차트 타입에 따라 다른 컴포넌트 렌더링
switch (individualChartType) {
case "bar":
return <Bar key={dsConfig.dataSourceId} dataKey={dataKey} name={label} fill={color} />;
case "area":
return (
<Area
key={dsConfig.dataSourceId}
type="monotone"
dataKey={dataKey}
name={label}
stroke={color}
fill={color}
fillOpacity={0.3}
strokeWidth={2}
/>
);
case "line":
default:
return (
<Line
key={dsConfig.dataSourceId}
type="monotone"
dataKey={dataKey}
name={label}
stroke={color}
strokeWidth={2}
/>
);
}
})}
</ComposedChart>
</ResponsiveContainer>
);
case "pie":
case "donut":
// 파이 차트는 첫 번째 데이터 소스만 사용
if (dataSourceConfigs.length > 0) {
const firstConfig = dataSourceConfigs[0];
const sourceName = dataSources.find((ds) => ds.id === firstConfig.dataSourceId)?.name;
// 해당 데이터 소스의 데이터만 필터링
const sourceData = data.filter((item) => item._source === sourceName);
console.log("🍩 도넛/파이 차트 데이터:", {
sourceName,
totalData: data.length,
filteredData: sourceData.length,
firstConfig,
sampleItem: sourceData[0],
});
// 파이 차트용 데이터 변환
const pieData = sourceData.map((item) => ({
name: String(item[firstConfig.xAxis] || "Unknown"),
value: Number(item[firstConfig.yAxis[0]]) || 0,
}));
console.log("🍩 변환된 파이 데이터:", pieData);
console.log("🍩 첫 번째 데이터:", pieData[0]);
console.log("🍩 데이터 타입 체크:", {
firstValue: pieData[0]?.value,
valueType: typeof pieData[0]?.value,
isNumber: typeof pieData[0]?.value === "number",
});
if (pieData.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm"> .</p>
</div>
);
}
// value가 모두 0인지 체크
const totalValue = pieData.reduce((sum, item) => sum + (item.value || 0), 0);
if (totalValue === 0) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm"> 0. Y축 .</p>
</div>
);
}
return (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius="70%"
innerRadius={chartType === "donut" ? "45%" : 0}
label={(entry) => `${entry.name}: ${entry.value}`}
labelLine={true}
fill="#8884d8"
>
{pieData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend verticalAlign="bottom" height={36} />
</PieChart>
</ResponsiveContainer>
);
}
return (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
dataKey={yAxis}
nameKey={xAxis}
cx="50%"
cy="50%"
outerRadius={80}
label
>
{data.map((entry, index) => (
<Cell key={`cell-\${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm"> .</p>
</div>
);
default:
return (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
: {chartType}
</p>
<p className="text-muted-foreground text-sm"> : {chartType}</p>
</div>
);
}
};
return (
<div className="flex h-full w-full flex-col bg-background">
<div className="bg-background flex h-full w-full flex-col">
<div className="flex items-center justify-between border-b p-4">
<div>
<h3 className="text-lg font-semibold">
{element?.customTitle || "차트 테스트 (다중 데이터 소스)"}
</h3>
<p className="text-xs text-muted-foreground">
<h3 className="text-lg font-semibold">{element?.customTitle || "차트"}</h3>
<p className="text-muted-foreground text-xs">
{dataSources?.length || 0} {data.length}
{lastRefreshTime && (
<span className="ml-2">
{lastRefreshTime.toLocaleTimeString("ko-KR")}
</span>
)}
{lastRefreshTime && <span className="ml-2"> {lastRefreshTime.toLocaleTimeString("ko-KR")}</span>}
</p>
</div>
<div className="flex items-center gap-2">
@ -330,13 +604,12 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
<div className="flex-1 p-4">
{error ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-destructive">{error}</p>
<p className="text-destructive text-sm">{error}</p>
</div>
) : !(element?.dataSources || element?.chartConfig?.dataSources) || (element?.dataSources || element?.chartConfig?.dataSources)?.length === 0 ? (
) : !(element?.dataSources || element?.chartConfig?.dataSources) ||
(element?.dataSources || element?.chartConfig?.dataSources)?.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
</p>
<p className="text-muted-foreground text-sm"> </p>
</div>
) : (
renderChart()
@ -344,9 +617,7 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
</div>
{data.length > 0 && (
<div className="border-t p-2 text-xs text-muted-foreground">
{data.length}
</div>
<div className="text-muted-foreground border-t p-2 text-xs"> {data.length} </div>
)}
</div>
);

View File

@ -4,6 +4,9 @@ import React, { useState, useEffect, useCallback, useMemo } from "react";
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
import { Button } from "@/components/ui/button";
import { Loader2, RefreshCw } from "lucide-react";
import { applyColumnMapping } from "@/lib/utils/columnMapping";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
interface CustomMetricTestWidgetProps {
element: DashboardElement;
@ -45,7 +48,7 @@ const colorMap = {
};
/**
* ( )
* ( )
* - REST API
* - Database
* - REST API + Database
@ -53,9 +56,12 @@ const colorMap = {
*/
export default function CustomMetricTestWidget({ element }: CustomMetricTestWidgetProps) {
const [metrics, setMetrics] = useState<any[]>([]);
const [groupedCards, setGroupedCards] = useState<Array<{ label: string; value: number }>>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
const [selectedMetric, setSelectedMetric] = useState<any | null>(null);
const [isDetailOpen, setIsDetailOpen] = useState(false);
console.log("🧪 CustomMetricTestWidget 렌더링!", element);
@ -63,18 +69,94 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
return element?.dataSources || element?.chartConfig?.dataSources;
}, [element?.dataSources, element?.chartConfig?.dataSources]);
// 🆕 그룹별 카드 모드 체크
const isGroupByMode = element?.customMetricConfig?.groupByMode || false;
// 메트릭 설정 (없으면 기본값 사용) - useMemo로 메모이제이션
const metricConfig = useMemo(() => {
return element?.customMetricConfig?.metrics || [
{
label: "총 개수",
field: "id",
aggregation: "count",
color: "indigo",
},
];
return (
element?.customMetricConfig?.metrics || [
{
label: "총 개수",
field: "id",
aggregation: "count",
color: "indigo",
},
]
);
}, [element?.customMetricConfig?.metrics]);
// 🆕 그룹별 카드 데이터 로드 (원본에서 복사)
const loadGroupByData = useCallback(async () => {
const groupByDS = element?.customMetricConfig?.groupByDataSource;
if (!groupByDS) return;
const dataSourceType = groupByDS.type;
// Database 타입
if (dataSourceType === "database") {
if (!groupByDS.query) return;
const { dashboardApi } = await import("@/lib/api/dashboard");
const result = await dashboardApi.executeQuery(groupByDS.query);
if (result.success && result.data?.rows) {
const rows = result.data.rows;
if (rows.length > 0) {
const columns = result.data.columns || Object.keys(rows[0]);
const labelColumn = columns[0];
const valueColumn = columns[1];
const cards = rows.map((row: any) => ({
label: String(row[labelColumn] || ""),
value: parseFloat(row[valueColumn]) || 0,
}));
setGroupedCards(cards);
}
}
}
// API 타입
else if (dataSourceType === "api") {
if (!groupByDS.endpoint) return;
const { dashboardApi } = await import("@/lib/api/dashboard");
const result = await dashboardApi.fetchExternalApi({
method: "GET",
url: groupByDS.endpoint,
headers: (groupByDS as any).headers || {},
});
if (result.success && result.data) {
let rows: any[] = [];
if (Array.isArray(result.data)) {
rows = result.data;
} else if (result.data.results && Array.isArray(result.data.results)) {
rows = result.data.results;
} else if (result.data.items && Array.isArray(result.data.items)) {
rows = result.data.items;
} else if (result.data.data && Array.isArray(result.data.data)) {
rows = result.data.data;
} else {
rows = [result.data];
}
if (rows.length > 0) {
const columns = Object.keys(rows[0]);
const labelColumn = columns[0];
const valueColumn = columns[1];
const cards = rows.map((row: any) => ({
label: String(row[labelColumn] || ""),
value: parseFloat(row[valueColumn]) || 0,
}));
setGroupedCards(cards);
}
}
}
}, [element?.customMetricConfig?.groupByDataSource]);
// 다중 데이터 소스 로딩
const loadMultipleDataSources = useCallback(async () => {
const dataSources = element?.dataSources || element?.chartConfig?.dataSources;
@ -117,7 +199,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
rows: [],
};
}
})
}),
);
console.log(`✅ 총 ${results.length}개의 데이터 소스 로딩 완료`);
@ -141,21 +223,21 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
const columns = Object.keys(firstRow);
// 숫자 컬럼 찾기
const numericColumns = columns.filter(col => {
const numericColumns = columns.filter((col) => {
const value = firstRow[col];
return typeof value === 'number' || !isNaN(Number(value));
return typeof value === "number" || !isNaN(Number(value));
});
// 문자열 컬럼 찾기
const stringColumns = columns.filter(col => {
const stringColumns = columns.filter((col) => {
const value = firstRow[col];
return typeof value === 'string' || !numericColumns.includes(col);
return typeof value === "string" || !numericColumns.includes(col);
});
console.log(`📊 [${sourceName}] 컬럼 분석:`, {
전체: columns,
숫자: numericColumns,
문자열: stringColumns
문자열: stringColumns,
});
// 숫자 컬럼이 있으면 집계된 데이터로 판단
@ -180,6 +262,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
aggregation: "custom",
color: colors[allMetrics.length % colors.length],
sourceName: sourceName,
rawData: rows, // 원본 데이터 저장
});
});
} else {
@ -188,7 +271,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
// 데이터 소스에서 선택된 컬럼 가져오기
const dataSourceConfig = (element?.dataSources || element?.chartConfig?.dataSources)?.find(
ds => ds.name === sourceName || ds.id === result.value.sourceIndex.toString()
(ds) => ds.name === sourceName || ds.id === result.value.sourceIndex.toString(),
);
const selectedColumns = dataSourceConfig?.selectedColumns || [];
@ -205,7 +288,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
}
// 해당 컬럼의 고유값 개수 계산
const uniqueValues = new Set(rows.map(row => row[col]));
const uniqueValues = new Set(rows.map((row) => row[col]));
const uniqueCount = uniqueValues.size;
console.log(` [${sourceName}] ${col}: ${uniqueCount}개 고유값`);
@ -217,6 +300,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
aggregation: "distinct",
color: colors[allMetrics.length % colors.length],
sourceName: sourceName,
rawData: rows, // 원본 데이터 저장
});
});
@ -228,6 +312,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
aggregation: "count",
color: colors[allMetrics.length % colors.length],
sourceName: sourceName,
rawData: rows, // 원본 데이터 저장
});
}
} else {
@ -239,7 +324,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
// 데이터 소스에서 선택된 컬럼 가져오기
const dataSourceConfig = (element?.dataSources || element?.chartConfig?.dataSources)?.find(
ds => ds.name === sourceName || ds.id === result.value.sourceIndex.toString()
(ds) => ds.name === sourceName || ds.id === result.value.sourceIndex.toString(),
);
const selectedColumns = dataSourceConfig?.selectedColumns || [];
@ -256,7 +341,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
return;
}
const uniqueValues = new Set(rows.map(row => row[col]));
const uniqueValues = new Set(rows.map((row) => row[col]));
const uniqueCount = uniqueValues.size;
console.log(` [${sourceName}] ${col}: ${uniqueCount}개 고유값`);
@ -268,6 +353,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
aggregation: "distinct",
color: colors[allMetrics.length % colors.length],
sourceName: sourceName,
rawData: rows, // 원본 데이터 저장
});
});
@ -279,6 +365,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
aggregation: "count",
color: colors[allMetrics.length % colors.length],
sourceName: sourceName,
rawData: rows, // 원본 데이터 저장
});
}
});
@ -293,11 +380,40 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
}
}, [element?.dataSources, element?.chartConfig?.dataSources, metricConfig]);
// 🆕 통합 데이터 로딩 (그룹별 카드 + 일반 메트릭)
const loadAllData = useCallback(async () => {
try {
setLoading(true);
setError(null);
// 그룹별 카드 데이터 로드
if (isGroupByMode && element?.customMetricConfig?.groupByDataSource) {
await loadGroupByData();
}
// 일반 메트릭 데이터 로드
if (dataSources && dataSources.length > 0) {
await loadMultipleDataSources();
}
} catch (err) {
console.error("데이터 로드 실패:", err);
setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다");
} finally {
setLoading(false);
}
}, [
isGroupByMode,
element?.customMetricConfig?.groupByDataSource,
dataSources,
loadGroupByData,
loadMultipleDataSources,
]);
// 수동 새로고침 핸들러
const handleManualRefresh = useCallback(() => {
console.log("🔄 수동 새로고침 버튼 클릭");
loadMultipleDataSources();
}, [loadMultipleDataSources]);
loadAllData();
}, [loadAllData]);
// XML 데이터 파싱
const parseXmlData = (xmlText: string): any[] => {
@ -344,7 +460,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
const lines = text.trim().split("\n");
if (lines.length === 0) return [];
const headers = lines[0].split(",").map(h => h.trim());
const headers = lines[0].split(",").map((h) => h.trim());
const result: any[] = [];
for (let i = 1; i < lines.length; i++) {
@ -455,7 +571,10 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
}
}
return Array.isArray(processedData) ? processedData : [processedData];
const rows = Array.isArray(processedData) ? processedData : [processedData];
// 컬럼 매핑 적용
return applyColumnMapping(rows, source.columnMapping);
};
// Database 데이터 로딩
@ -464,6 +583,8 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
throw new Error("SQL 쿼리가 없습니다.");
}
let rows: any[] = [];
if (source.connectionType === "external" && source.externalConnectionId) {
// 외부 DB
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
@ -480,24 +601,27 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
rows: Record<string, unknown>[];
};
return resultData.rows;
rows = resultData.rows;
} else {
// 현재 DB
const { dashboardApi } = await import("@/lib/api/dashboard");
const result = await dashboardApi.executeQuery(source.query);
return result.rows;
rows = result.rows;
}
// 컬럼 매핑 적용
return applyColumnMapping(rows, source.columnMapping);
};
// 초기 로드
// 초기 로드 (🆕 loadAllData 사용)
useEffect(() => {
if (dataSources && dataSources.length > 0 && metricConfig.length > 0) {
loadMultipleDataSources();
if ((dataSources && dataSources.length > 0) || (isGroupByMode && element?.customMetricConfig?.groupByDataSource)) {
loadAllData();
}
}, [dataSources, loadMultipleDataSources, metricConfig]);
}, [dataSources, isGroupByMode, element?.customMetricConfig?.groupByDataSource, loadAllData]);
// 자동 새로고침
// 자동 새로고침 (🆕 loadAllData 사용)
useEffect(() => {
if (!dataSources || dataSources.length === 0) return;
@ -512,107 +636,206 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
const intervalId = setInterval(() => {
console.log("🔄 자동 새로고침 실행");
loadMultipleDataSources();
loadAllData();
}, minInterval * 1000);
return () => {
console.log("⏹️ 자동 새로고침 정리");
clearInterval(intervalId);
};
}, [dataSources, loadMultipleDataSources]);
}, [dataSources, loadAllData]);
// 메트릭 카드 렌더링
const renderMetricCard = (metric: any, index: number) => {
const color = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray;
const formattedValue = metric.value.toLocaleString(undefined, {
minimumFractionDigits: metric.decimals || 0,
maximumFractionDigits: metric.decimals || 0,
});
// renderMetricCard 함수 제거 - 인라인으로 렌더링
// 로딩 상태 (원본 스타일)
if (loading) {
return (
<div
key={index}
className={`rounded-lg border ${color.border} ${color.bg} p-4 shadow-sm transition-all hover:shadow-md`}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="text-xs font-medium text-muted-foreground">{metric.label}</p>
<p className={`mt-1 text-2xl font-bold ${color.text}`}>
{formattedValue}
{metric.unit && <span className="ml-1 text-sm">{metric.unit}</span>}
</p>
</div>
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
<div className="text-center">
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
<p className="mt-2 text-sm text-gray-500"> ...</p>
</div>
</div>
);
};
}
// 메트릭 개수에 따라 그리드 컬럼 동적 결정
const getGridCols = () => {
const count = metrics.length;
if (count === 0) return "grid-cols-1";
if (count === 1) return "grid-cols-1";
if (count <= 4) return "grid-cols-1 sm:grid-cols-2";
return "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3";
};
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"
// 에러 상태 (원본 스타일)
if (error) {
return (
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
<div className="text-center">
<p className="text-sm text-red-600"> {error}</p>
<button
onClick={handleManualRefresh}
disabled={loading}
className="h-8 gap-2 text-xs"
className="mt-2 rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
>
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
</Button>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
</button>
</div>
</div>
);
}
{/* 컨텐츠 */}
<div className="flex-1 overflow-auto p-4">
{error ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-destructive">{error}</p>
</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>
)}
// 데이터 소스 없음 (원본 스타일)
if (!(element?.dataSources || element?.chartConfig?.dataSources) && !isGroupByMode) {
return (
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
<p className="text-sm text-gray-500"> </p>
</div>
);
}
// 메트릭 설정 없음 (원본 스타일)
if (metricConfig.length === 0 && !isGroupByMode) {
return (
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
<p className="text-sm text-gray-500"> </p>
</div>
);
}
// 메인 렌더링 (원본 스타일 - 심플하게)
return (
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
{/* 콘텐츠 영역 - 스크롤 없이 자동으로 크기 조정 (원본과 동일) */}
<div className="grid h-full w-full gap-2" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))" }}>
{/* 그룹별 카드 (활성화 시) */}
{isGroupByMode &&
groupedCards.map((card, index) => {
// 색상 순환 (6가지 색상)
const colorKeys = Object.keys(colorMap) as Array<keyof typeof colorMap>;
const colorKey = colorKeys[index % colorKeys.length];
const colors = colorMap[colorKey];
return (
<div
key={`group-${index}`}
className={`flex flex-col items-center justify-center rounded-lg border ${colors.bg} ${colors.border} p-2`}
>
<div className="text-[10px] text-gray-600">{card.label}</div>
<div className={`mt-0.5 text-xl font-bold ${colors.text}`}>{card.value.toLocaleString()}</div>
</div>
);
})}
{/* 일반 지표 카드 (항상 표시) */}
{metrics.map((metric, index) => {
const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray;
const formattedValue = metric.value.toLocaleString(undefined, {
minimumFractionDigits: 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>
);
}

View File

@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card } from "@/components/ui/card";
import { Loader2, RefreshCw } from "lucide-react";
import { applyColumnMapping } from "@/lib/utils/columnMapping";
interface ListTestWidgetProps {
element: DashboardElement;
@ -32,12 +33,18 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
const [currentPage, setCurrentPage] = useState(1);
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
console.log("🧪 ListTestWidget 렌더링!", element);
// console.log("🧪 ListTestWidget 렌더링!", element);
const dataSources = useMemo(() => {
return element?.dataSources || element?.chartConfig?.dataSources;
}, [element?.dataSources, element?.chartConfig?.dataSources]);
// console.log("📊 dataSources 확인:", {
// hasDataSources: !!dataSources,
// dataSourcesLength: dataSources?.length || 0,
// dataSources: dataSources,
// });
const config = element.listConfig || {
columnMode: "auto",
viewMode: "table",
@ -52,8 +59,6 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
// 다중 데이터 소스 로딩
const loadMultipleDataSources = useCallback(async () => {
const dataSources = element?.dataSources || element?.chartConfig?.dataSources;
if (!dataSources || dataSources.length === 0) {
console.log("⚠️ 데이터 소스가 없습니다.");
return;
@ -127,7 +132,7 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
} finally {
setIsLoading(false);
}
}, [element?.dataSources, element?.chartConfig?.dataSources]);
}, [dataSources]);
// 수동 새로고침 핸들러
const handleManualRefresh = useCallback(() => {
@ -195,7 +200,11 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
}
}
const rows = Array.isArray(processedData) ? processedData : [processedData];
let rows = Array.isArray(processedData) ? processedData : [processedData];
// 컬럼 매핑 적용
rows = applyColumnMapping(rows, source.columnMapping);
const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
return { columns, rows };
@ -224,18 +233,41 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
rows: Record<string, unknown>[];
};
// 컬럼 매핑 적용
const mappedRows = applyColumnMapping(resultData.rows, source.columnMapping);
const columns = mappedRows.length > 0 ? Object.keys(mappedRows[0]) : resultData.columns;
return {
columns: resultData.columns,
rows: resultData.rows,
columns,
rows: mappedRows,
};
} else {
// 현재 DB
const { dashboardApi } = await import("@/lib/api/dashboard");
const result = await dashboardApi.executeQuery(source.query);
// console.log("💾 내부 DB 쿼리 결과:", {
// hasRows: !!result.rows,
// rowCount: result.rows?.length || 0,
// hasColumns: !!result.columns,
// columnCount: result.columns?.length || 0,
// firstRow: result.rows?.[0],
// resultKeys: Object.keys(result),
// });
// 컬럼 매핑 적용
const mappedRows = applyColumnMapping(result.rows, source.columnMapping);
const columns = mappedRows.length > 0 ? Object.keys(mappedRows[0]) : result.columns;
// console.log("✅ 매핑 후:", {
// columns,
// rowCount: mappedRows.length,
// firstMappedRow: mappedRows[0],
// });
return {
columns: result.columns,
rows: result.rows,
columns,
rows: mappedRows,
};
}
};
@ -330,7 +362,7 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
<div className="flex items-center justify-between border-b p-4">
<div>
<h3 className="text-lg font-semibold">
{element?.customTitle || "리스트 테스트 (다중 데이터 소스)"}
{element?.customTitle || "리스트"}
</h3>
<p className="text-xs text-muted-foreground">
{dataSources?.length || 0} {data?.totalRows || 0}

View File

@ -5,6 +5,7 @@ import dynamic from "next/dynamic";
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
import { Button } from "@/components/ui/button";
import { Loader2, RefreshCw } from "lucide-react";
import { applyColumnMapping } from "@/lib/utils/columnMapping";
import "leaflet/dist/leaflet.css";
// Leaflet 아이콘 경로 설정 (엑박 방지)
@ -43,6 +44,7 @@ interface MarkerData {
status?: string;
description?: string;
source?: string; // 어느 데이터 소스에서 왔는지
color?: string; // 마커 색상
}
interface PolygonData {
@ -53,6 +55,7 @@ interface PolygonData {
description?: string;
source?: string;
color?: string;
opacity?: number; // 투명도 (0.0 ~ 1.0)
}
export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
@ -215,7 +218,9 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
const parsedData = parseTextData(data.text);
if (parsedData.length > 0) {
console.log(`✅ CSV 파싱 성공: ${parsedData.length}개 행`);
return convertToMapData(parsedData, source.name || source.id || "API", source.mapDisplayType);
// 컬럼 매핑 적용
const mappedData = applyColumnMapping(parsedData, source.columnMapping);
return convertToMapData(mappedData, source.name || source.id || "API", source.mapDisplayType, source);
}
}
@ -229,8 +234,11 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
const rows = Array.isArray(data) ? data : [data];
// 마커와 폴리곤으로 변환 (mapDisplayType 전달)
return convertToMapData(rows, source.name || source.id || "API", source.mapDisplayType);
// 컬럼 매핑 적용
const mappedRows = applyColumnMapping(rows, source.columnMapping);
// 마커와 폴리곤으로 변환 (mapDisplayType + dataSource 전달)
return convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source);
};
// Database 데이터 로딩
@ -268,8 +276,11 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
rows = result.rows;
}
// 마커와 폴리곤으로 변환 (mapDisplayType 전달)
return convertToMapData(rows, source.name || source.id || "Database", source.mapDisplayType);
// 컬럼 매핑 적용
const mappedRows = applyColumnMapping(rows, source.columnMapping);
// 마커와 폴리곤으로 변환 (mapDisplayType + dataSource 전달)
return convertToMapData(mappedRows, source.name || source.id || "Database", source.mapDisplayType, source);
};
// XML 데이터 파싱 (UTIC API 등)
@ -365,9 +376,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
};
// 데이터를 마커와 폴리곤으로 변환
const convertToMapData = (rows: any[], sourceName: string, mapDisplayType?: "auto" | "marker" | "polygon"): { markers: MarkerData[]; polygons: PolygonData[] } => {
const convertToMapData = (
rows: any[],
sourceName: string,
mapDisplayType?: "auto" | "marker" | "polygon",
dataSource?: ChartDataSource
): { markers: MarkerData[]; polygons: PolygonData[] } => {
console.log(`🔄 ${sourceName} 데이터 변환 시작:`, rows.length, "개 행");
console.log(` 📌 mapDisplayType:`, mapDisplayType, `(타입: ${typeof mapDisplayType})`);
console.log(` 🎨 마커 색상:`, dataSource?.markerColor, `폴리곤 색상:`, dataSource?.polygonColor);
if (rows.length === 0) return { markers: [], polygons: [] };
@ -383,8 +400,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
const parsedData = parseTextData(row.text);
console.log(` ✅ CSV 파싱 결과: ${parsedData.length}개 행`);
// 파싱된 데이터를 재귀적으로 변환
const result = convertToMapData(parsedData, sourceName, mapDisplayType);
// 파싱된 데이터를 재귀적으로 변환 (색상 정보 전달)
const result = convertToMapData(parsedData, sourceName, mapDisplayType, dataSource);
markers.push(...result.markers);
polygons.push(...result.polygons);
return; // 이 행은 처리 완료
@ -404,7 +421,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
status: row.status || row.level,
description: row.description || JSON.stringify(row, null, 2),
source: sourceName,
color: getColorByStatus(row.status || row.level),
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
});
return; // 폴리곤으로 처리했으므로 마커로는 추가하지 않음
}
@ -421,7 +438,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
status: row.status || row.level,
description: row.description || `${row.type || ''} ${row.level || ''}`.trim() || JSON.stringify(row, null, 2),
source: sourceName,
color: getColorByStatus(row.status || row.level),
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
});
return; // 폴리곤으로 처리했으므로 마커로는 추가하지 않음
}
@ -466,7 +483,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
status: row.status || row.level,
description: row.description || JSON.stringify(row, null, 2),
source: sourceName,
color: getColorByStatus(row.status || row.level),
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
});
} else {
console.log(` ⚠️ 강제 폴리곤 모드지만 지역명 없음 - 스킵`);
@ -487,6 +504,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
status: row.status || row.level,
description: row.description || JSON.stringify(row, null, 2),
source: sourceName,
color: dataSource?.markerColor || "#3b82f6", // 사용자 지정 색상 또는 기본 파랑
});
} else {
// 위도/경도가 없는 육지 지역 → 폴리곤으로 추가 (GeoJSON 매칭용)
@ -500,7 +518,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
status: row.status || row.level,
description: row.description || JSON.stringify(row, null, 2),
source: sourceName,
color: getColorByStatus(row.status || row.level),
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
});
} else {
console.log(` ⚠️ 위도/경도 없고 지역명도 없음 - 스킵`);
@ -803,7 +821,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
<div className="flex items-center justify-between border-b p-4">
<div>
<h3 className="text-lg font-semibold">
{element?.customTitle || "지도 테스트 V2 (다중 데이터 소스)"}
{element?.customTitle || "지도"}
</h3>
<p className="text-xs text-muted-foreground">
{element?.dataSources?.length || 0}
@ -989,11 +1007,38 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
))}
{/* 마커 렌더링 */}
{markers.map((marker) => (
<Marker
key={marker.id}
position={[marker.lat, marker.lng]}
>
{markers.map((marker) => {
// 커스텀 색상 아이콘 생성
let customIcon;
if (typeof window !== "undefined") {
const L = require("leaflet");
customIcon = L.divIcon({
className: "custom-marker",
html: `
<div style="
width: 30px;
height: 30px;
background-color: ${marker.color || "#3b82f6"};
border: 3px solid white;
border-radius: 50%;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
display: flex;
align-items: center;
justify-content: center;
transform: translate(-50%, -50%);
"></div>
`,
iconSize: [30, 30],
iconAnchor: [15, 15],
});
}
return (
<Marker
key={marker.id}
position={[marker.lat, marker.lng]}
icon={customIcon}
>
<Popup maxWidth={350}>
<div className="min-w-[250px] max-w-[350px]">
{/* 제목 */}
@ -1071,7 +1116,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
</div>
</Popup>
</Marker>
))}
);
})}
</MapContainer>
)}
</div>

View File

@ -466,7 +466,7 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
<div className="flex h-full items-center justify-center p-3">
<div className="max-w-xs space-y-2 text-center">
<div className="text-3xl">🚨</div>
<h3 className="text-sm font-bold text-gray-900">🧪 / </h3>
<h3 className="text-sm font-bold text-gray-900">/</h3>
<div className="space-y-1.5 text-xs text-gray-600">
<p className="font-medium"> </p>
<ul className="space-y-0.5 text-left">
@ -491,7 +491,7 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
<div className="flex items-center justify-between border-b bg-white/80 p-3">
<div>
<h3 className="text-base font-semibold">
{element?.customTitle || "리스크/알림 테스트"}
{element?.customTitle || "리스크/알림"}
</h3>
<p className="text-xs text-muted-foreground">
{dataSources?.length || 0} {alerts.length}

View File

@ -40,6 +40,7 @@ async function apiRequest<T>(
const API_BASE_URL = getApiBaseUrl();
const config: RequestInit = {
credentials: "include", // ⭐ 세션 쿠키 전송 필수
headers: {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${token}` }),

View File

@ -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;
}