diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index 48df8c8f..0ba9924c 100644 --- a/backend-node/src/controllers/DashboardController.ts +++ b/backend-node/src/controllers/DashboardController.ts @@ -606,16 +606,32 @@ export class DashboardController { } }); - // 외부 API 호출 + // 외부 API 호출 (타임아웃 30초) // @ts-ignore - node-fetch dynamic import const fetch = (await import("node-fetch")).default; - const response = await fetch(urlObj.toString(), { - method: method.toUpperCase(), - headers: { - "Content-Type": "application/json", - ...headers, - }, - }); + + // 타임아웃 설정 (Node.js 글로벌 AbortController 사용) + const controller = new (global as any).AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 60000); // 60초 (기상청 API는 느림) + + let response; + try { + response = await fetch(urlObj.toString(), { + method: method.toUpperCase(), + headers: { + "Content-Type": "application/json", + ...headers, + }, + signal: controller.signal, + }); + clearTimeout(timeoutId); + } catch (err: any) { + clearTimeout(timeoutId); + if (err.name === 'AbortError') { + throw new Error('외부 API 요청 타임아웃 (30초 초과)'); + } + throw err; + } if (!response.ok) { throw new Error( @@ -623,7 +639,40 @@ export class DashboardController { ); } - const data = await response.json(); + // Content-Type에 따라 응답 파싱 + const contentType = response.headers.get("content-type"); + let data: any; + + // 한글 인코딩 처리 (EUC-KR → UTF-8) + const isKoreanApi = urlObj.hostname.includes('kma.go.kr') || + urlObj.hostname.includes('data.go.kr'); + + if (isKoreanApi) { + // 한국 정부 API는 EUC-KR 인코딩 사용 + const buffer = await response.arrayBuffer(); + const decoder = new TextDecoder('euc-kr'); + const text = decoder.decode(buffer); + + try { + data = JSON.parse(text); + } catch { + data = { text, contentType }; + } + } else if (contentType && contentType.includes("application/json")) { + data = await response.json(); + } else if (contentType && contentType.includes("text/")) { + // 텍스트 응답 (CSV, 일반 텍스트 등) + const text = await response.text(); + data = { text, contentType }; + } else { + // 기타 응답 (JSON으로 시도) + try { + data = await response.json(); + } catch { + const text = await response.text(); + data = { text, contentType }; + } + } res.status(200).json({ success: true, diff --git a/backend-node/src/services/externalRestApiConnectionService.ts b/backend-node/src/services/externalRestApiConnectionService.ts index 4d0539b4..63472e6b 100644 --- a/backend-node/src/services/externalRestApiConnectionService.ts +++ b/backend-node/src/services/externalRestApiConnectionService.ts @@ -28,7 +28,7 @@ export class ExternalRestApiConnectionService { try { let query = ` SELECT - id, connection_name, description, base_url, default_headers, + id, connection_name, description, base_url, endpoint_path, default_headers, auth_type, auth_config, timeout, retry_count, retry_delay, company_code, is_active, created_date, created_by, updated_date, updated_by, last_test_date, last_test_result, last_test_message @@ -110,7 +110,7 @@ export class ExternalRestApiConnectionService { try { const query = ` SELECT - id, connection_name, description, base_url, default_headers, + id, connection_name, description, base_url, endpoint_path, default_headers, auth_type, auth_config, timeout, retry_count, retry_delay, company_code, is_active, created_date, created_by, updated_date, updated_by, last_test_date, last_test_result, last_test_message @@ -167,10 +167,10 @@ export class ExternalRestApiConnectionService { const query = ` INSERT INTO external_rest_api_connections ( - connection_name, description, base_url, default_headers, + connection_name, description, base_url, endpoint_path, default_headers, auth_type, auth_config, timeout, retry_count, retry_delay, company_code, is_active, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING * `; @@ -178,6 +178,7 @@ export class ExternalRestApiConnectionService { data.connection_name, data.description || null, data.base_url, + data.endpoint_path || null, JSON.stringify(data.default_headers || {}), data.auth_type, encryptedAuthConfig ? JSON.stringify(encryptedAuthConfig) : null, @@ -261,6 +262,12 @@ export class ExternalRestApiConnectionService { paramIndex++; } + if (data.endpoint_path !== undefined) { + updateFields.push(`endpoint_path = $${paramIndex}`); + params.push(data.endpoint_path); + paramIndex++; + } + if (data.default_headers !== undefined) { updateFields.push(`default_headers = $${paramIndex}`); params.push(JSON.stringify(data.default_headers)); diff --git a/backend-node/src/services/riskAlertService.ts b/backend-node/src/services/riskAlertService.ts index 514d3e95..f3561bbe 100644 --- a/backend-node/src/services/riskAlertService.ts +++ b/backend-node/src/services/riskAlertService.ts @@ -41,7 +41,7 @@ export class RiskAlertService { disp: 0, authKey: apiKey, }, - timeout: 10000, + timeout: 30000, // 30초로 증가 responseType: 'arraybuffer', // 인코딩 문제 해결 }); diff --git a/backend-node/src/types/externalRestApiTypes.ts b/backend-node/src/types/externalRestApiTypes.ts index 061ab6b8..35877974 100644 --- a/backend-node/src/types/externalRestApiTypes.ts +++ b/backend-node/src/types/externalRestApiTypes.ts @@ -7,6 +7,7 @@ export interface ExternalRestApiConnection { connection_name: string; description?: string; base_url: string; + endpoint_path?: string; default_headers: Record; auth_type: AuthType; auth_config?: { diff --git a/frontend/components/admin/RestApiConnectionModal.tsx b/frontend/components/admin/RestApiConnectionModal.tsx index 2b5d2097..1b4ad187 100644 --- a/frontend/components/admin/RestApiConnectionModal.tsx +++ b/frontend/components/admin/RestApiConnectionModal.tsx @@ -33,6 +33,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: const [connectionName, setConnectionName] = useState(""); const [description, setDescription] = useState(""); const [baseUrl, setBaseUrl] = useState(""); + const [endpointPath, setEndpointPath] = useState(""); const [defaultHeaders, setDefaultHeaders] = useState>({}); const [authType, setAuthType] = useState("none"); const [authConfig, setAuthConfig] = useState({}); @@ -55,6 +56,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: setConnectionName(connection.connection_name); setDescription(connection.description || ""); setBaseUrl(connection.base_url); + setEndpointPath(connection.endpoint_path || ""); setDefaultHeaders(connection.default_headers || {}); setAuthType(connection.auth_type); setAuthConfig(connection.auth_config || {}); @@ -67,6 +69,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: setConnectionName(""); setDescription(""); setBaseUrl(""); + setEndpointPath(""); setDefaultHeaders({ "Content-Type": "application/json" }); setAuthType("none"); setAuthConfig({}); @@ -175,6 +178,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: connection_name: connectionName, description: description || undefined, base_url: baseUrl, + endpoint_path: endpointPath || undefined, default_headers: defaultHeaders, auth_type: authType, auth_config: authType === "none" ? undefined : authConfig, @@ -257,6 +261,22 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://api.example.com" /> +

+ 도메인 부분만 입력하세요 (예: https://apihub.kma.go.kr) +

+ + +
+ + setEndpointPath(e.target.value)} + placeholder="/api/typ01/url/wrn_now_data.php" + /> +

+ API 엔드포인트 경로를 입력하세요 (선택사항) +

diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 33b1d801..dd3d08ce 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -60,6 +60,24 @@ const MapSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/Ma loading: () =>
로딩 중...
, }); +// 🧪 테스트용 지도 위젯 (REST API 지원) +const MapTestWidget = dynamic(() => import("@/components/dashboard/widgets/MapTestWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + +// 🧪 테스트용 지도 위젯 V2 (다중 데이터 소스) +const MapTestWidgetV2 = dynamic(() => import("@/components/dashboard/widgets/MapTestWidgetV2"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + +// 🧪 테스트용 차트 위젯 (다중 데이터 소스) +const ChartTestWidget = dynamic(() => import("@/components/dashboard/widgets/ChartTestWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + // 범용 상태 요약 위젯 (차량, 배송 등 모든 상태 위젯 통합) const StatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/StatusSummaryWidget"), { ssr: false, @@ -851,6 +869,21 @@ export function CanvasElement({
+ ) : element.type === "widget" && element.subtype === "map-test" ? ( + // 🧪 테스트용 지도 위젯 (REST API 지원) +
+ +
+ ) : element.type === "widget" && element.subtype === "map-test-v2" ? ( + // 🧪 테스트용 지도 위젯 V2 (다중 데이터 소스) +
+ +
+ ) : element.type === "widget" && element.subtype === "chart-test" ? ( + // 🧪 테스트용 차트 위젯 (다중 데이터 소스) +
+ +
) : element.type === "widget" && element.subtype === "vehicle-map" ? ( // 차량 위치 지도 위젯 렌더링 (구버전 - 호환용)
diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 5b39a8f7..e9ab7df8 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -194,7 +194,13 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D // 요소들 설정 if (dashboard.elements && dashboard.elements.length > 0) { - setElements(dashboard.elements); + // chartConfig.dataSources를 element.dataSources로 복사 (프론트엔드 호환성) + const elementsWithDataSources = dashboard.elements.map((el) => ({ + ...el, + dataSources: el.chartConfig?.dataSources || el.dataSources, + })); + + setElements(elementsWithDataSources); // elementCounter를 가장 큰 ID 번호로 설정 const maxId = dashboard.elements.reduce((max, el) => { @@ -459,7 +465,11 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D showHeader: el.showHeader, content: el.content, dataSource: el.dataSource, - chartConfig: el.chartConfig, + // dataSources는 chartConfig에 포함시켜서 저장 (백엔드 스키마 수정 불필요) + chartConfig: + el.dataSources && el.dataSources.length > 0 + ? { ...el.chartConfig, dataSources: el.dataSources } + : el.chartConfig, listConfig: el.listConfig, yardConfig: el.yardConfig, customMetricConfig: el.customMetricConfig, diff --git a/frontend/components/admin/dashboard/DashboardTopMenu.tsx b/frontend/components/admin/dashboard/DashboardTopMenu.tsx index b9e5976d..283f0918 100644 --- a/frontend/components/admin/dashboard/DashboardTopMenu.tsx +++ b/frontend/components/admin/dashboard/DashboardTopMenu.tsx @@ -181,6 +181,11 @@ export function DashboardTopMenu({ + + 🧪 테스트 위젯 (다중 데이터 소스) + 🧪 지도 테스트 V2 + 🧪 차트 테스트 + 데이터 위젯 리스트 위젯 @@ -188,6 +193,7 @@ export function DashboardTopMenu({ 야드 관리 3D {/* 커스텀 통계 카드 */} 커스텀 지도 카드 + 🧪 지도 테스트 (REST API) {/* 커스텀 상태 카드 */} diff --git a/frontend/components/admin/dashboard/ElementConfigModal.tsx b/frontend/components/admin/dashboard/ElementConfigModal.tsx index 44ae4a55..22b09901 100644 --- a/frontend/components/admin/dashboard/ElementConfigModal.tsx +++ b/frontend/components/admin/dashboard/ElementConfigModal.tsx @@ -5,6 +5,7 @@ import { DashboardElement, ChartDataSource, ChartConfig, QueryResult } from "./t import { QueryEditor } from "./QueryEditor"; import { ChartConfigPanel } from "./ChartConfigPanel"; import { VehicleMapConfigPanel } from "./VehicleMapConfigPanel"; +import { MapTestConfigPanel } from "./MapTestConfigPanel"; import { DataSourceSelector } from "./data-sources/DataSourceSelector"; import { DatabaseConfig } from "./data-sources/DatabaseConfig"; import { ApiConfig } from "./data-sources/ApiConfig"; @@ -17,6 +18,7 @@ interface ElementConfigModalProps { isOpen: boolean; onClose: () => void; onSave: (element: DashboardElement) => void; + onPreview?: (element: DashboardElement) => void; // 실시간 미리보기용 (저장 전) } /** @@ -24,7 +26,7 @@ interface ElementConfigModalProps { * - 2단계 플로우: 데이터 소스 선택 → 데이터 설정 및 차트 설정 * - 새로운 데이터 소스 컴포넌트 통합 */ -export function ElementConfigModal({ element, isOpen, onClose, onSave }: ElementConfigModalProps) { +export function ElementConfigModal({ element, isOpen, onClose, onSave, onPreview }: ElementConfigModalProps) { const [dataSource, setDataSource] = useState( element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 }, ); @@ -61,7 +63,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element element.subtype === "calculator"; // 계산기 위젯 (자체 기능) // 지도 위젯 (위도/경도 매핑 필요) - const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary"; + const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary" || element.subtype === "map-test"; // 주석 // 모달이 열릴 때 초기화 @@ -132,7 +134,18 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element // 차트 설정 변경 처리 const handleChartConfigChange = useCallback((newConfig: ChartConfig) => { setChartConfig(newConfig); - }, []); + + // 🎯 실시간 미리보기: chartConfig 변경 시 즉시 부모에게 전달 + if (onPreview) { + onPreview({ + ...element, + chartConfig: newConfig, + dataSource: dataSource, + customTitle: customTitle, + showHeader: showHeader, + }); + } + }, [element, dataSource, customTitle, showHeader, onPreview]); // 쿼리 테스트 결과 처리 const handleQueryTest = useCallback((result: QueryResult) => { @@ -208,12 +221,16 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element ? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능 (차트 설정 불필요) currentStep === 2 && queryResult && queryResult.rows.length > 0 : isMapWidget - ? // 지도 위젯: 위도/경도 매핑 필요 - currentStep === 2 && - queryResult && - queryResult.rows.length > 0 && - chartConfig.latitudeColumn && - chartConfig.longitudeColumn + ? // 지도 위젯: 타일맵 URL 또는 위도/경도 매핑 필요 + element.subtype === "map-test" + ? // 🧪 지도 테스트 위젯: 타일맵 URL만 있으면 저장 가능 + currentStep === 2 && chartConfig.tileMapUrl + : // 기존 지도 위젯: 쿼리 결과 + 위도/경도 필수 + currentStep === 2 && + queryResult && + queryResult.rows.length > 0 && + chartConfig.latitudeColumn && + chartConfig.longitudeColumn : // 차트: 기존 로직 (2단계에서 차트 설정 필요) currentStep === 2 && queryResult && @@ -324,7 +341,15 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
{isMapWidget ? ( // 지도 위젯: 위도/경도 매핑 패널 - queryResult && queryResult.rows.length > 0 ? ( + element.subtype === "map-test" ? ( + // 🧪 지도 테스트 위젯: 타일맵 URL 필수, 마커 데이터 선택사항 + + ) : queryResult && queryResult.rows.length > 0 ? ( + // 기존 지도 위젯: 쿼리 결과 필수 ([]); const [chartConfig, setChartConfig] = useState({}); const [queryResult, setQueryResult] = useState(null); const [customTitle, setCustomTitle] = useState(""); @@ -42,6 +45,8 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem useEffect(() => { if (isOpen && element) { setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 }); + // dataSources는 element.dataSources 또는 chartConfig.dataSources에서 로드 + setDataSources(element.dataSources || element.chartConfig?.dataSources || []); setChartConfig(element.chartConfig || {}); setQueryResult(null); setCustomTitle(element.customTitle || ""); @@ -89,9 +94,23 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem }, []); // 차트 설정 변경 처리 - const handleChartConfigChange = useCallback((newConfig: ChartConfig) => { - setChartConfig(newConfig); - }, []); + const handleChartConfigChange = useCallback( + (newConfig: ChartConfig) => { + setChartConfig(newConfig); + + // 🎯 실시간 미리보기: 즉시 부모에게 전달 (map-test 위젯용) + if (element && element.subtype === "map-test" && newConfig.tileMapUrl) { + onApply({ + ...element, + chartConfig: newConfig, + dataSource: dataSource, + customTitle: customTitle, + showHeader: showHeader, + }); + } + }, + [element, dataSource, customTitle, showHeader, onApply], + ); // 쿼리 테스트 결과 처리 const handleQueryTest = useCallback((result: QueryResult) => { @@ -103,17 +122,27 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem const handleApply = useCallback(() => { if (!element) return; + console.log("🔧 적용 버튼 클릭 - dataSource:", dataSource); + console.log("🔧 적용 버튼 클릭 - dataSources:", element.dataSources); + console.log("🔧 적용 버튼 클릭 - chartConfig:", chartConfig); + + // 다중 데이터 소스 위젯 체크 + const isMultiDS = element.subtype === "map-test-v2" || element.subtype === "chart-test"; + const updatedElement: DashboardElement = { ...element, - dataSource, - chartConfig, + // 다중 데이터 소스 위젯은 dataSources를 chartConfig에 저장 + chartConfig: isMultiDS ? { ...chartConfig, dataSources } : chartConfig, + dataSources: isMultiDS ? dataSources : undefined, // 프론트엔드 호환성 + dataSource: isMultiDS ? undefined : dataSource, customTitle: customTitle.trim() || undefined, showHeader, }; + console.log("🔧 적용할 요소:", updatedElement); onApply(updatedElement); // 사이드바는 열린 채로 유지 (연속 수정 가능) - }, [element, dataSource, chartConfig, customTitle, showHeader, onApply]); + }, [element, dataSource, dataSources, chartConfig, customTitle, showHeader, onApply]); // 요소가 없으면 렌더링하지 않음 if (!element) return null; @@ -184,13 +213,17 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem element.subtype === "weather" || element.subtype === "exchange" || element.subtype === "calculator"; // 지도 위젯 (위도/경도 매핑 필요) - const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary"; + const isMapWidget = + element.subtype === "vehicle-map" || element.subtype === "map-summary" || element.subtype === "map-test"; // 헤더 전용 위젯 const isHeaderOnlyWidget = element.type === "widget" && (element.subtype === "clock" || element.subtype === "calendar" || isSelfContainedWidget); + // 다중 데이터 소스 테스트 위젯 + const isMultiDataSourceWidget = element.subtype === "map-test-v2" || element.subtype === "chart-test"; + // 저장 가능 여부 확인 const isPieChart = element.subtype === "pie" || element.subtype === "donut"; const isApiSource = dataSource.type === "api"; @@ -205,14 +238,18 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem const canApply = isTitleChanged || isHeaderChanged || - (isSimpleWidget - ? queryResult && queryResult.rows.length > 0 - : isMapWidget - ? queryResult && queryResult.rows.length > 0 && chartConfig.latitudeColumn && chartConfig.longitudeColumn - : queryResult && - queryResult.rows.length > 0 && - chartConfig.xAxis && - (isPieChart || isApiSource ? (chartConfig.aggregation === "count" ? true : hasYAxis) : hasYAxis)); + (isMultiDataSourceWidget + ? true // 다중 데이터 소스 위젯은 항상 적용 가능 + : isSimpleWidget + ? queryResult && queryResult.rows.length > 0 + : isMapWidget + ? element.subtype === "map-test" + ? chartConfig.tileMapUrl || (queryResult && queryResult.rows.length > 0) // 🧪 지도 테스트 위젯: 타일맵 URL 또는 API 데이터 + : queryResult && queryResult.rows.length > 0 && chartConfig.latitudeColumn && chartConfig.longitudeColumn + : queryResult && + queryResult.rows.length > 0 && + chartConfig.xAxis && + (isPieChart || isApiSource ? (chartConfig.aggregation === "count" ? true : hasYAxis) : hasYAxis)); return (
+ {/* 다중 데이터 소스 위젯 */} + {isMultiDataSourceWidget && ( + <> +
+ +
+ + {/* 지도 테스트 V2: 타일맵 URL 설정 */} + {element.subtype === "map-test-v2" && ( +
+
+ +
+
+ 타일맵 설정 (선택사항) +
+
기본 VWorld 타일맵 사용 중
+
+ + + +
+
+ +
+
+
+ )} + + )} + {/* 헤더 전용 위젯이 아닐 때만 데이터 소스 표시 */} - {!isHeaderOnlyWidget && ( + {!isHeaderOnlyWidget && !isMultiDataSourceWidget && (
데이터 소스
@@ -303,52 +380,82 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem /> {/* 차트/지도 설정 */} - {!isSimpleWidget && queryResult && queryResult.rows.length > 0 && ( -
- {isMapWidget ? ( - - ) : ( - - )} -
- )} + {!isSimpleWidget && + (element.subtype === "map-test" || (queryResult && queryResult.rows.length > 0)) && ( +
+ {isMapWidget ? ( + element.subtype === "map-test" ? ( + + ) : ( + queryResult && + queryResult.rows.length > 0 && ( + + ) + ) + ) : ( + queryResult && + queryResult.rows.length > 0 && ( + + ) + )} +
+ )} {/* 차트/지도 설정 */} - {!isSimpleWidget && queryResult && queryResult.rows.length > 0 && ( -
- {isMapWidget ? ( - - ) : ( - - )} -
- )} + {!isSimpleWidget && + (element.subtype === "map-test" || (queryResult && queryResult.rows.length > 0)) && ( +
+ {isMapWidget ? ( + element.subtype === "map-test" ? ( + + ) : ( + queryResult && + queryResult.rows.length > 0 && ( + + ) + ) + ) : ( + queryResult && + queryResult.rows.length > 0 && ( + + ) + )} +
+ )}
diff --git a/frontend/components/admin/dashboard/MapTestConfigPanel.tsx b/frontend/components/admin/dashboard/MapTestConfigPanel.tsx new file mode 100644 index 00000000..b5be7a35 --- /dev/null +++ b/frontend/components/admin/dashboard/MapTestConfigPanel.tsx @@ -0,0 +1,415 @@ +'use client'; + +import React, { useState, useCallback, useEffect } from 'react'; +import { ChartConfig, QueryResult, ChartDataSource } from './types'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Plus, X } from 'lucide-react'; +import { ExternalDbConnectionAPI, ExternalApiConnection } from '@/lib/api/externalDbConnection'; + +interface MapTestConfigPanelProps { + config?: ChartConfig; + queryResult?: QueryResult; + onConfigChange: (config: ChartConfig) => void; +} + +/** + * 지도 테스트 위젯 설정 패널 + * - 타일맵 URL 설정 (VWorld, OpenStreetMap 등) + * - 위도/경도 컬럼 매핑 + * - 라벨/상태 컬럼 설정 + */ +export function MapTestConfigPanel({ config, queryResult, onConfigChange }: MapTestConfigPanelProps) { + const [currentConfig, setCurrentConfig] = useState(config || {}); + const [connections, setConnections] = useState([]); + const [tileMapSources, setTileMapSources] = useState>([ + { id: `tilemap_${Date.now()}`, url: '' } + ]); + + // config prop 변경 시 currentConfig 동기화 + useEffect(() => { + if (config) { + setCurrentConfig(config); + console.log('🔄 config 업데이트:', config); + } + }, [config]); + + // 외부 API 커넥션 목록 불러오기 (REST API만) + useEffect(() => { + const loadApiConnections = async () => { + try { + const apiConnections = await ExternalDbConnectionAPI.getApiConnections({ is_active: 'Y' }); + setConnections(apiConnections); + console.log('✅ REST API 커넥션 로드 완료:', apiConnections); + console.log(`📊 총 ${apiConnections.length}개의 REST API 커넥션`); + } catch (error) { + console.error('❌ REST API 커넥션 로드 실패:', error); + } + }; + + loadApiConnections(); + }, []); + + // 타일맵 URL을 템플릿 형식으로 변환 (10/856/375.png → {z}/{y}/{x}.png) + const convertToTileTemplate = (url: string): string => { + // 이미 템플릿 형식이면 그대로 반환 + if (url.includes('{z}') && url.includes('{y}') && url.includes('{x}')) { + return url; + } + + // 특정 타일 URL 패턴 감지: /숫자/숫자/숫자.png + const tilePattern = /\/(\d+)\/(\d+)\/(\d+)\.(png|jpg|jpeg)$/i; + const match = url.match(tilePattern); + + if (match) { + // /10/856/375.png → /{z}/{y}/{x}.png + const convertedUrl = url.replace(tilePattern, '/{z}/{y}/{x}.$4'); + console.log('🔄 타일 URL 자동 변환:', url, '→', convertedUrl); + return convertedUrl; + } + + return url; + }; + + // 설정 업데이트 + const updateConfig = useCallback((updates: Partial) => { + // tileMapUrl이 업데이트되면 자동으로 템플릿 형식으로 변환 + if (updates.tileMapUrl) { + updates.tileMapUrl = convertToTileTemplate(updates.tileMapUrl); + } + + const newConfig = { ...currentConfig, ...updates }; + setCurrentConfig(newConfig); + onConfigChange(newConfig); + }, [currentConfig, onConfigChange]); + + // 타일맵 소스 추가 + const addTileMapSource = () => { + setTileMapSources([...tileMapSources, { id: `tilemap_${Date.now()}`, url: '' }]); + }; + + // 타일맵 소스 제거 + const removeTileMapSource = (id: string) => { + if (tileMapSources.length === 1) return; // 최소 1개는 유지 + setTileMapSources(tileMapSources.filter(s => s.id !== id)); + }; + + // 타일맵 소스 업데이트 + const updateTileMapSource = (id: string, url: string) => { + setTileMapSources(tileMapSources.map(s => s.id === id ? { ...s, url } : s)); + // 첫 번째 타일맵 URL을 config에 저장 + const firstUrl = id === tileMapSources[0].id ? url : tileMapSources[0].url; + updateConfig({ tileMapUrl: firstUrl }); + }; + + // 외부 커넥션에서 URL 가져오기 + const loadFromConnection = (sourceId: string, connectionId: string) => { + const connection = connections.find(c => c.id?.toString() === connectionId); + if (connection) { + console.log('🔗 선택된 커넥션:', connection.connection_name, '→', connection.base_url); + updateTileMapSource(sourceId, connection.base_url); + } + }; + + // 사용 가능한 컬럼 목록 + const availableColumns = queryResult?.columns || []; + const sampleData = queryResult?.rows?.[0] || {}; + + // 기상특보 데이터인지 감지 (reg_ko, wrn 컬럼이 있으면 기상특보) + const isWeatherAlertData = availableColumns.includes('reg_ko') && availableColumns.includes('wrn'); + + return ( +
+ {/* 타일맵 URL 설정 (외부 커넥션 또는 직접 입력) */} +
+ + + {/* 외부 커넥션 선택 */} + + + {/* 타일맵 URL 직접 입력 */} + updateConfig({ tileMapUrl: e.target.value })} + placeholder="https://api.vworld.kr/req/wmts/1.0.0/{API_KEY}/Base/{z}/{y}/{x}.png" + className="h-8 text-xs" + /> +

+ 💡 {'{z}/{y}/{x}'}는 그대로 입력하세요 (지도 라이브러리가 자동 치환) +

+
+ + {/* 타일맵 소스 목록 */} + {/*
+
+ + +
+ + {tileMapSources.map((source, index) => ( +
+
+ + +
+ +
+ updateTileMapSource(source.id, e.target.value)} + placeholder="https://api.vworld.kr/req/wmts/1.0.0/{API_KEY}/Base/{z}/{y}/{x}.png" + className="h-8 flex-1 text-xs" + /> + {tileMapSources.length > 1 && ( + + )} +
+
+ ))} + +

+ 💡 {'{z}/{y}/{x}'}는 그대로 입력하세요 (지도 라이브러리가 자동 치환) +

+
*/} + + {/* 지도 제목 */} + {/*
+ + updateConfig({ title: e.target.value })} + placeholder="위치 지도" + className="h-10 text-xs" + /> +
*/} + + {/* 구분선 */} + {/*
+
📍 마커 데이터 설정 (선택사항)
+

+ 데이터 소스 탭에서 API 또는 데이터베이스를 연결하면 마커를 표시할 수 있습니다. +

+
*/} + + {/* 쿼리 결과가 없을 때 */} + {/* {!queryResult && ( +
+
+ 💡 데이터 소스를 연결하고 쿼리를 실행하면 마커 설정이 가능합니다. +
+
+ )} */} + + {/* 데이터 필드 매핑 */} + {queryResult && !isWeatherAlertData && ( + <> + {/* 위도 컬럼 설정 */} +
+ + +
+ + {/* 경도 컬럼 설정 */} +
+ + +
+ + {/* 라벨 컬럼 (선택사항) */} +
+ + +
+ + {/* 상태 컬럼 (선택사항) */} +
+ + +
+ + )} + + {/* 기상특보 데이터 안내 */} + {queryResult && isWeatherAlertData && ( +
+
+ 🚨 기상특보 데이터가 감지되었습니다. 지역명(reg_ko)을 기준으로 자동으로 영역이 표시됩니다. +
+
+ )} + + {queryResult && ( + <> + + {/* 날씨 정보 표시 옵션 */} +
+ +

+ 마커 팝업에 해당 위치의 날씨 정보를 함께 표시합니다 +

+
+ +
+ +

+ 현재 발효 중인 기상특보(주의보/경보)를 지도에 색상 영역으로 표시합니다 +

+
+ + {/* 설정 미리보기 */} +
+
📋 설정 미리보기
+
+
타일맵: {currentConfig.tileMapUrl ? '✅ 설정됨' : '❌ 미설정'}
+
위도: {currentConfig.latitudeColumn || '미설정'}
+
경도: {currentConfig.longitudeColumn || '미설정'}
+
라벨: {currentConfig.labelColumn || '없음'}
+
상태: {currentConfig.statusColumn || '없음'}
+
날씨 표시: {currentConfig.showWeather ? '활성화' : '비활성화'}
+
기상특보 표시: {currentConfig.showWeatherAlerts ? '활성화' : '비활성화'}
+
데이터 개수: {queryResult.rows.length}개
+
+
+ + )} + + {/* 필수 필드 확인 */} + {/* {!currentConfig.tileMapUrl && ( +
+
+ ⚠️ 타일맵 URL을 입력해야 지도가 표시됩니다. +
+
+ )} */} +
+ ); +} + diff --git a/frontend/components/admin/dashboard/data-sources/ApiConfig.tsx b/frontend/components/admin/dashboard/data-sources/ApiConfig.tsx index 64d6422e..a8b2b74c 100644 --- a/frontend/components/admin/dashboard/data-sources/ApiConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/ApiConfig.tsx @@ -9,6 +9,15 @@ import { Plus, X, Play, AlertCircle } from "lucide-react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection"; +// 개별 API 소스 인터페이스 +interface ApiSource { + id: string; + endpoint: string; + headers: KeyValuePair[]; + queryParams: KeyValuePair[]; + jsonPath?: string; +} + interface ApiConfigProps { dataSource: ChartDataSource; onChange: (updates: Partial) => void; @@ -52,8 +61,15 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps console.log("불러온 커넥션:", connection); // 커넥션 설정을 API 설정에 자동 적용 + // base_url과 endpoint_path를 조합하여 전체 URL 생성 + const fullEndpoint = connection.endpoint_path + ? `${connection.base_url}${connection.endpoint_path}` + : connection.base_url; + + console.log("전체 엔드포인트:", fullEndpoint); + const updates: Partial = { - endpoint: connection.base_url, + endpoint: fullEndpoint, }; const headers: KeyValuePair[] = []; @@ -119,6 +135,8 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps } } + updates.type = "api"; // ⭐ 중요: type을 api로 명시 + updates.method = "GET"; // 기본 메서드 updates.headers = headers; updates.queryParams = queryParams; console.log("최종 업데이트:", updates); @@ -201,6 +219,17 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps return; } + // 타일맵 URL 감지 (이미지 파일이므로 테스트 불가) + const isTilemapUrl = + dataSource.endpoint.includes('{z}') && + dataSource.endpoint.includes('{y}') && + dataSource.endpoint.includes('{x}'); + + if (isTilemapUrl) { + setTestError("타일맵 URL은 테스트할 수 없습니다. 지도 위젯에서 직접 확인하세요."); + return; + } + setTesting(true); setTestError(null); setTestResult(null); @@ -248,7 +277,36 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps throw new Error(apiResponse.message || "외부 API 호출 실패"); } - const apiData = apiResponse.data; + let apiData = apiResponse.data; + + // 텍스트 응답인 경우 파싱 + if (apiData && typeof apiData === "object" && "text" in apiData && typeof apiData.text === "string") { + const textData = apiData.text; + + // CSV 형식 파싱 (기상청 API) + if (textData.includes("#START7777") || textData.includes(",")) { + const lines = textData.split("\n").filter((line) => line.trim() && !line.startsWith("#")); + const parsedRows = lines.map((line) => { + const values = line.split(",").map((v) => v.trim()); + return { + reg_up: values[0] || "", + reg_up_ko: values[1] || "", + reg_id: values[2] || "", + reg_ko: values[3] || "", + tm_fc: values[4] || "", + tm_ef: values[5] || "", + wrn: values[6] || "", + lvl: values[7] || "", + cmd: values[8] || "", + ed_tm: values[9] || "", + }; + }); + apiData = parsedRows; + } else { + // 일반 텍스트는 그대로 반환 + apiData = [{ text: textData }]; + } + } // JSON Path 처리 let data = apiData; @@ -313,41 +371,47 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps return (
- {/* 외부 커넥션 선택 */} - {apiConnections.length > 0 && ( -
- - + + + + + + 직접 입력 + + {apiConnections.length > 0 ? ( + apiConnections.map((conn) => ( {conn.connection_name} {conn.description && ({conn.description})} - ))} - - -

저장한 REST API 설정을 불러올 수 있습니다

-
- )} + )) + ) : ( + + 등록된 커넥션이 없습니다 + + )} + + +

저장한 REST API 설정을 불러올 수 있습니다

+
{/* API URL */}
onChange({ endpoint: e.target.value })} className="h-8 text-xs" /> -

GET 요청을 보낼 API 엔드포인트

+

+ 전체 URL 또는 base_url 이후 경로를 입력하세요 (외부 커넥션 선택 시 base_url 자동 입력) +

{/* 쿼리 파라미터 */} diff --git a/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx b/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx new file mode 100644 index 00000000..b5a56b9c --- /dev/null +++ b/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx @@ -0,0 +1,529 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { ChartDataSource, KeyValuePair } from "@/components/admin/dashboard/types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Plus, Trash2, Loader2, CheckCircle, XCircle } from "lucide-react"; +import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection"; + +interface MultiApiConfigProps { + dataSource: ChartDataSource; + onChange: (updates: Partial) => void; + onTestResult?: (data: any) => void; // 테스트 결과 데이터 전달 +} + +export default function MultiApiConfig({ dataSource, onChange, onTestResult }: MultiApiConfigProps) { + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); + const [apiConnections, setApiConnections] = useState([]); + const [selectedConnectionId, setSelectedConnectionId] = useState(""); + + console.log("🔧 MultiApiConfig - dataSource:", dataSource); + + // 외부 API 커넥션 목록 로드 + useEffect(() => { + const loadApiConnections = async () => { + const connections = await ExternalDbConnectionAPI.getApiConnections({ is_active: "Y" }); + setApiConnections(connections); + }; + loadApiConnections(); + }, []); + + // 외부 커넥션 선택 핸들러 + const handleConnectionSelect = async (connectionId: string) => { + setSelectedConnectionId(connectionId); + + if (!connectionId || connectionId === "manual") { + return; + } + + const connection = await ExternalDbConnectionAPI.getApiConnectionById(Number(connectionId)); + if (!connection) { + console.error("커넥션을 찾을 수 없습니다:", connectionId); + return; + } + + console.log("불러온 커넥션:", connection); + + // base_url과 endpoint_path를 조합하여 전체 URL 생성 + const fullEndpoint = connection.endpoint_path + ? `${connection.base_url}${connection.endpoint_path}` + : connection.base_url; + + console.log("전체 엔드포인트:", fullEndpoint); + + const updates: Partial = { + endpoint: fullEndpoint, + }; + + const headers: KeyValuePair[] = []; + const queryParams: KeyValuePair[] = []; + + // 기본 헤더가 있으면 적용 + if (connection.default_headers && Object.keys(connection.default_headers).length > 0) { + Object.entries(connection.default_headers).forEach(([key, value]) => { + headers.push({ + id: `header_${Date.now()}_${Math.random()}`, + key, + value, + }); + }); + console.log("기본 헤더 적용:", headers); + } + + // 인증 설정이 있으면 헤더 또는 쿼리 파라미터에 추가 + if (connection.auth_type && connection.auth_type !== "none" && connection.auth_config) { + const authConfig = connection.auth_config; + + switch (connection.auth_type) { + case "api-key": + if (authConfig.keyLocation === "header" && authConfig.keyName && authConfig.keyValue) { + headers.push({ + id: `auth_header_${Date.now()}`, + key: authConfig.keyName, + value: authConfig.keyValue, + }); + console.log("API Key 헤더 추가:", authConfig.keyName); + } else if (authConfig.keyLocation === "query" && authConfig.keyName && authConfig.keyValue) { + queryParams.push({ + id: `auth_query_${Date.now()}`, + key: authConfig.keyName, + value: authConfig.keyValue, + }); + console.log("API Key 쿼리 파라미터 추가:", authConfig.keyName); + } + break; + + case "bearer": + if (authConfig.token) { + headers.push({ + id: `auth_bearer_${Date.now()}`, + key: "Authorization", + value: `Bearer ${authConfig.token}`, + }); + console.log("Bearer Token 헤더 추가"); + } + break; + + case "basic": + if (authConfig.username && authConfig.password) { + const credentials = btoa(`${authConfig.username}:${authConfig.password}`); + headers.push({ + id: `auth_basic_${Date.now()}`, + key: "Authorization", + value: `Basic ${credentials}`, + }); + console.log("Basic Auth 헤더 추가"); + } + break; + + case "oauth2": + if (authConfig.accessToken) { + headers.push({ + id: `auth_oauth_${Date.now()}`, + key: "Authorization", + value: `Bearer ${authConfig.accessToken}`, + }); + console.log("OAuth2 Token 헤더 추가"); + } + break; + } + } + + // 헤더와 쿼리 파라미터 적용 + if (headers.length > 0) { + updates.headers = headers; + } + if (queryParams.length > 0) { + updates.queryParams = queryParams; + } + + console.log("최종 업데이트:", updates); + onChange(updates); + }; + + // 헤더 추가 + const handleAddHeader = () => { + const headers = dataSource.headers || []; + onChange({ + headers: [...headers, { id: Date.now().toString(), key: "", value: "" }], + }); + }; + + // 헤더 삭제 + const handleDeleteHeader = (id: string) => { + const headers = (dataSource.headers || []).filter((h) => h.id !== id); + onChange({ headers }); + }; + + // 헤더 업데이트 + const handleUpdateHeader = (id: string, field: "key" | "value", value: string) => { + const headers = (dataSource.headers || []).map((h) => + h.id === id ? { ...h, [field]: value } : h + ); + onChange({ headers }); + }; + + // 쿼리 파라미터 추가 + const handleAddQueryParam = () => { + const queryParams = dataSource.queryParams || []; + onChange({ + queryParams: [...queryParams, { id: Date.now().toString(), key: "", value: "" }], + }); + }; + + // 쿼리 파라미터 삭제 + const handleDeleteQueryParam = (id: string) => { + const queryParams = (dataSource.queryParams || []).filter((q) => q.id !== id); + onChange({ queryParams }); + }; + + // 쿼리 파라미터 업데이트 + const handleUpdateQueryParam = (id: string, field: "key" | "value", value: string) => { + const queryParams = (dataSource.queryParams || []).map((q) => + q.id === id ? { ...q, [field]: value } : q + ); + onChange({ queryParams }); + }; + + // API 테스트 + const handleTestApi = async () => { + if (!dataSource.endpoint) { + setTestResult({ success: false, message: "API URL을 입력해주세요" }); + return; + } + + setTesting(true); + setTestResult(null); + + try { + const queryParams: Record = {}; + (dataSource.queryParams || []).forEach((param) => { + if (param.key && param.value) { + queryParams[param.key] = param.value; + } + }); + + const headers: Record = {}; + (dataSource.headers || []).forEach((header) => { + if (header.key && header.value) { + headers[header.key] = header.value; + } + }); + + const response = await fetch("/api/dashboards/fetch-external-api", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + url: dataSource.endpoint, + method: dataSource.method || "GET", + headers, + queryParams, + }), + }); + + const result = await response.json(); + + if (result.success) { + // 텍스트 데이터 파싱 함수 (MapTestWidgetV2와 동일) + const parseTextData = (text: string): any[] => { + try { + console.log("🔍 텍스트 파싱 시작 (처음 500자):", text.substring(0, 500)); + + const lines = text.split('\n').filter(line => { + const trimmed = line.trim(); + return trimmed && + !trimmed.startsWith('#') && + !trimmed.startsWith('=') && + !trimmed.startsWith('---'); + }); + + console.log(`📝 유효한 라인: ${lines.length}개`); + + if (lines.length === 0) return []; + + const result: any[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const values = line.split(',').map(v => v.trim().replace(/,=$/g, '')); + + // 기상특보 형식: 지역코드, 지역명, 하위코드, 하위지역명, 발표시각, 특보종류, 등급, 발표상태, 설명 + if (values.length >= 4) { + const obj: any = { + code: values[0] || '', // 지역 코드 (예: L1070000) + region: values[1] || '', // 지역명 (예: 경상북도) + subCode: values[2] || '', // 하위 코드 (예: L1071600) + subRegion: values[3] || '', // 하위 지역명 (예: 영주시) + tmFc: values[4] || '', // 발표시각 + type: values[5] || '', // 특보종류 (강풍, 호우 등) + level: values[6] || '', // 등급 (주의, 경보) + status: values[7] || '', // 발표상태 + description: values.slice(8).join(', ').trim() || '', + name: values[3] || values[1] || values[0], // 하위 지역명 우선 + }; + + result.push(obj); + } + } + + console.log("📊 파싱 결과:", result.length, "개"); + return result; + } catch (error) { + console.error("❌ 텍스트 파싱 오류:", error); + return []; + } + }; + + // JSON Path로 데이터 추출 + let data = result.data; + + // 텍스트 데이터 체크 (기상청 API 등) + if (data && typeof data === 'object' && data.text && typeof data.text === 'string') { + console.log("📄 텍스트 형식 데이터 감지, CSV 파싱 시도"); + const parsedData = parseTextData(data.text); + if (parsedData.length > 0) { + console.log(`✅ CSV 파싱 성공: ${parsedData.length}개 행`); + data = parsedData; + } + } else if (dataSource.jsonPath) { + const pathParts = dataSource.jsonPath.split("."); + for (const part of pathParts) { + data = data?.[part]; + } + } + + const rows = Array.isArray(data) ? data : [data]; + + // 위도/경도 또는 coordinates 필드 또는 지역 코드 체크 + const hasLocationData = rows.some((row) => { + const hasLatLng = (row.lat || row.latitude) && (row.lng || row.longitude); + const hasCoordinates = row.coordinates && Array.isArray(row.coordinates); + const hasRegionCode = row.code || row.areaCode || row.regionCode; + return hasLatLng || hasCoordinates || hasRegionCode; + }); + + if (hasLocationData) { + const markerCount = rows.filter(r => + ((r.lat || r.latitude) && (r.lng || r.longitude)) || + r.code || r.areaCode || r.regionCode + ).length; + const polygonCount = rows.filter(r => r.coordinates && Array.isArray(r.coordinates)).length; + + setTestResult({ + success: true, + message: `API 연결 성공 - 마커 ${markerCount}개, 영역 ${polygonCount}개 발견` + }); + + // 부모에게 테스트 결과 전달 (지도 미리보기용) + if (onTestResult) { + onTestResult(rows); + } + } else { + setTestResult({ + success: true, + message: `API 연결 성공 - ${rows.length}개 데이터 (위치 정보 없음)` + }); + } + } else { + setTestResult({ success: false, message: result.message || "API 호출 실패" }); + } + } catch (error: any) { + setTestResult({ success: false, message: error.message || "네트워크 오류" }); + } finally { + setTesting(false); + } + }; + + return ( +
+
REST API 설정
+ + {/* 외부 연결 선택 */} +
+ + +

+ 외부 연결을 선택하면 API URL이 자동으로 입력됩니다 +

+
+ + {/* API URL (직접 입력 또는 수정) */} +
+ + { + console.log("📝 API URL 변경:", e.target.value); + onChange({ endpoint: e.target.value }); + }} + placeholder="https://api.example.com/data" + className="h-8 text-xs" + /> +

+ 외부 연결을 선택하거나 직접 입력할 수 있습니다 +

+
+ + {/* JSON Path */} +
+ + onChange({ jsonPath: e.target.value })} + placeholder="예: data.results" + className="h-8 text-xs" + /> +

+ 응답 JSON에서 데이터를 추출할 경로 +

+
+ + {/* 쿼리 파라미터 */} +
+
+ + +
+ {(dataSource.queryParams || []).map((param) => ( +
+ handleUpdateQueryParam(param.id, "key", e.target.value)} + placeholder="키" + className="h-8 text-xs" + /> + handleUpdateQueryParam(param.id, "value", e.target.value)} + placeholder="값" + className="h-8 text-xs" + /> + +
+ ))} +
+ + {/* 헤더 */} +
+
+ + +
+ {(dataSource.headers || []).map((header) => ( +
+ handleUpdateHeader(header.id, "key", e.target.value)} + placeholder="키" + className="h-8 text-xs" + /> + handleUpdateHeader(header.id, "value", e.target.value)} + placeholder="값" + className="h-8 text-xs" + /> + +
+ ))} +
+ + {/* 테스트 버튼 */} +
+ + + {testResult && ( +
+ {testResult.success ? ( + + ) : ( + + )} + {testResult.message} +
+ )} +
+
+ ); +} diff --git a/frontend/components/admin/dashboard/data-sources/MultiDataSourceConfig.tsx b/frontend/components/admin/dashboard/data-sources/MultiDataSourceConfig.tsx new file mode 100644 index 00000000..2d92836e --- /dev/null +++ b/frontend/components/admin/dashboard/data-sources/MultiDataSourceConfig.tsx @@ -0,0 +1,315 @@ +"use client"; + +import React, { useState } from "react"; +import { ChartDataSource } from "@/components/admin/dashboard/types"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Plus, Trash2 } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import MultiApiConfig from "./MultiApiConfig"; +import MultiDatabaseConfig from "./MultiDatabaseConfig"; + +interface MultiDataSourceConfigProps { + dataSources: ChartDataSource[]; + onChange: (dataSources: ChartDataSource[]) => void; +} + +export default function MultiDataSourceConfig({ + dataSources = [], + onChange, +}: MultiDataSourceConfigProps) { + const [activeTab, setActiveTab] = useState( + dataSources.length > 0 ? dataSources[0].id || "0" : "new" + ); + const [previewData, setPreviewData] = useState([]); + const [showPreview, setShowPreview] = useState(false); + + // 새 데이터 소스 추가 + const handleAddDataSource = () => { + const newId = Date.now().toString(); + const newSource: ChartDataSource = { + id: newId, + name: `데이터 소스 ${dataSources.length + 1}`, + type: "api", + }; + + onChange([...dataSources, newSource]); + setActiveTab(newId); + }; + + // 데이터 소스 삭제 + const handleDeleteDataSource = (id: string) => { + const filtered = dataSources.filter((ds) => ds.id !== id); + onChange(filtered); + + // 삭제 후 첫 번째 탭으로 이동 + if (filtered.length > 0) { + setActiveTab(filtered[0].id || "0"); + } else { + setActiveTab("new"); + } + }; + + // 데이터 소스 업데이트 + const handleUpdateDataSource = (id: string, updates: Partial) => { + const updated = dataSources.map((ds) => + ds.id === id ? { ...ds, ...updates } : ds + ); + onChange(updated); + }; + + return ( +
+ {/* 헤더 */} +
+
+

데이터 소스 관리

+

+ 여러 데이터 소스를 연결하여 데이터를 통합할 수 있습니다 +

+
+ +
+ + {/* 데이터 소스가 없는 경우 */} + {dataSources.length === 0 ? ( +
+

+ 연결된 데이터 소스가 없습니다 +

+ +
+ ) : ( + /* 탭 UI */ + + + {dataSources.map((ds, index) => ( + + {ds.name || `소스 ${index + 1}`} + + ))} + + + {dataSources.map((ds, index) => ( + + {/* 데이터 소스 기본 정보 */} +
+ {/* 이름 */} +
+ + + handleUpdateDataSource(ds.id!, { name: e.target.value }) + } + placeholder="예: 기상특보, 교통정보" + className="h-8 text-xs" + /> +
+ + {/* 타입 선택 */} +
+ + + handleUpdateDataSource(ds.id!, { type: value }) + } + > +
+ + +
+
+ + +
+
+
+ + {/* 삭제 버튼 */} +
+ +
+
+ + {/* 지도 표시 방식 선택 (지도 위젯만) */} +
+ + + handleUpdateDataSource(ds.id!, { mapDisplayType: value as "auto" | "marker" | "polygon" }) + } + className="flex gap-4" + > +
+ + +
+
+ + +
+
+ + +
+
+

+ {ds.mapDisplayType === "marker" && "모든 데이터를 마커로 표시합니다"} + {ds.mapDisplayType === "polygon" && "모든 데이터를 영역(폴리곤)으로 표시합니다"} + {(!ds.mapDisplayType || ds.mapDisplayType === "auto") && "데이터에 coordinates가 있으면 영역, 없으면 마커로 자동 표시"} +

+
+ + {/* 타입별 설정 */} + {ds.type === "api" ? ( + handleUpdateDataSource(ds.id!, updates)} + onTestResult={(data) => { + setPreviewData(data); + setShowPreview(true); + }} + /> + ) : ( + handleUpdateDataSource(ds.id!, updates)} + /> + )} +
+ ))} +
+ )} + + {/* 지도 미리보기 */} + {showPreview && previewData.length > 0 && ( +
+
+
+
+ 데이터 미리보기 ({previewData.length}건) +
+

+ "적용" 버튼을 눌러 지도에 표시하세요 +

+
+ +
+ +
+ {previewData.map((item, index) => { + const hasLatLng = (item.lat || item.latitude) && (item.lng || item.longitude); + const hasCoordinates = item.coordinates && Array.isArray(item.coordinates); + + return ( +
+
+
+ {item.name || item.title || item.area || item.region || `항목 ${index + 1}`} +
+ {(item.status || item.level) && ( +
+ {item.status || item.level} +
+ )} +
+ + {hasLatLng && ( +
+ 📍 마커: ({item.lat || item.latitude}, {item.lng || item.longitude}) +
+ )} + + {hasCoordinates && ( +
+ 🔷 영역: {item.coordinates.length}개 좌표 +
+ )} + + {(item.type || item.description) && ( +
+ {item.type && `${item.type} `} + {item.description && item.description !== item.type && `- ${item.description}`} +
+ )} +
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx b/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx new file mode 100644 index 00000000..63af568d --- /dev/null +++ b/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx @@ -0,0 +1,222 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { ChartDataSource } from "@/components/admin/dashboard/types"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Loader2, CheckCircle, XCircle } from "lucide-react"; + +interface MultiDatabaseConfigProps { + dataSource: ChartDataSource; + onChange: (updates: Partial) => void; +} + +interface ExternalConnection { + id: string; + name: string; + type: string; +} + +export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatabaseConfigProps) { + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState<{ success: boolean; message: string; rowCount?: number } | null>(null); + const [externalConnections, setExternalConnections] = useState([]); + const [loadingConnections, setLoadingConnections] = useState(false); + + // 외부 DB 커넥션 목록 로드 + useEffect(() => { + if (dataSource.connectionType === "external") { + loadExternalConnections(); + } + }, [dataSource.connectionType]); + + const loadExternalConnections = async () => { + setLoadingConnections(true); + try { + const response = await fetch("/api/admin/reports/external-connections", { + credentials: "include", + }); + + if (response.ok) { + const result = await response.json(); + if (result.success && result.data) { + const connections = Array.isArray(result.data) ? result.data : result.data.data || []; + setExternalConnections(connections); + } + } + } catch (error) { + console.error("외부 DB 커넥션 로드 실패:", error); + } finally { + setLoadingConnections(false); + } + }; + + // 쿼리 테스트 + const handleTestQuery = async () => { + if (!dataSource.query) { + setTestResult({ success: false, message: "SQL 쿼리를 입력해주세요" }); + return; + } + + setTesting(true); + setTestResult(null); + + try { + const response = await fetch("/api/dashboards/query", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + connectionType: dataSource.connectionType || "current", + externalConnectionId: dataSource.externalConnectionId, + query: dataSource.query, + }), + }); + + const result = await response.json(); + + if (result.success) { + const rowCount = Array.isArray(result.data) ? result.data.length : 0; + setTestResult({ + success: true, + message: "쿼리 실행 성공", + rowCount, + }); + } else { + setTestResult({ success: false, message: result.message || "쿼리 실행 실패" }); + } + } catch (error: any) { + setTestResult({ success: false, message: error.message || "네트워크 오류" }); + } finally { + setTesting(false); + } + }; + + return ( +
+
Database 설정
+ + {/* 커넥션 타입 */} +
+ + + onChange({ connectionType: value }) + } + > +
+ + +
+
+ + +
+
+
+ + {/* 외부 DB 선택 */} + {dataSource.connectionType === "external" && ( +
+ + {loadingConnections ? ( +
+ +
+ ) : ( + + )} +
+ )} + + {/* SQL 쿼리 */} +
+ +