From 5b394473f4bdae50a6caa32a8e0ca646b79a2f42 Mon Sep 17 00:00:00 2001
From: leeheejin
Date: Mon, 27 Oct 2025 18:33:15 +0900
Subject: [PATCH 01/20] =?UTF-8?q?restapi=20=EC=97=AC=EB=9F=AC=EA=B0=9C=20?=
=?UTF-8?q?=EB=9D=84=EC=9A=B0=EB=8A=94=EA=B1=B0=20=EC=9E=91=EC=97=85=20?=
=?UTF-8?q?=EA=B0=80=EB=8A=A5=ED=95=98=EA=B2=8C=20=ED=95=98=EB=8A=94?=
=?UTF-8?q?=EA=B1=B0=20=EC=A7=84=ED=96=89=EC=A4=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/controllers/DashboardController.ts | 67 +-
.../externalRestApiConnectionService.ts | 15 +-
backend-node/src/services/riskAlertService.ts | 2 +-
.../src/types/externalRestApiTypes.ts | 1 +
.../admin/RestApiConnectionModal.tsx | 20 +
.../admin/dashboard/CanvasElement.tsx | 33 +
.../admin/dashboard/DashboardDesigner.tsx | 14 +-
.../admin/dashboard/DashboardTopMenu.tsx | 6 +
.../admin/dashboard/ElementConfigModal.tsx | 45 +-
.../admin/dashboard/ElementConfigSidebar.tsx | 219 ++-
.../admin/dashboard/MapTestConfigPanel.tsx | 415 ++++++
.../dashboard/data-sources/ApiConfig.tsx | 110 +-
.../dashboard/data-sources/MultiApiConfig.tsx | 529 ++++++++
.../data-sources/MultiDataSourceConfig.tsx | 315 +++++
.../data-sources/MultiDatabaseConfig.tsx | 222 +++
frontend/components/admin/dashboard/types.ts | 10 +-
.../components/dashboard/DashboardViewer.tsx | 9 +
.../dashboard/widgets/ChartTestWidget.tsx | 297 ++++
.../dashboard/widgets/MapTestWidget.tsx | 1193 +++++++++++++++++
.../dashboard/widgets/MapTestWidgetV2.tsx | 863 ++++++++++++
frontend/lib/api/externalDbConnection.ts | 1 +
frontend/lib/api/externalRestApiConnection.ts | 1 +
frontend/lib/api/openApi.ts | 2 +
23 files changed, 4283 insertions(+), 106 deletions(-)
create mode 100644 frontend/components/admin/dashboard/MapTestConfigPanel.tsx
create mode 100644 frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx
create mode 100644 frontend/components/admin/dashboard/data-sources/MultiDataSourceConfig.tsx
create mode 100644 frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx
create mode 100644 frontend/components/dashboard/widgets/ChartTestWidget.tsx
create mode 100644 frontend/components/dashboard/widgets/MapTestWidget.tsx
create mode 100644 frontend/components/dashboard/widgets/MapTestWidgetV2.tsx
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 && (
-
-
-
+
저장한 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 설정
+
+ {/* 외부 연결 선택 */}
+
+
+
+
+
+
+
+
+ 직접 입력
+
+ {apiConnections.map((conn) => (
+
+ {conn.connection_name}
+
+ ))}
+
+
+
+ 외부 연결을 선택하면 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에서 데이터를 추출할 경로
+
+
+
+ {/* 쿼리 파라미터 */}
+
+
+ {/* 헤더 */}
+
+
+ {/* 테스트 버튼 */}
+
+
+
+ {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 ? (
+
+
+
+ ) : (
+
onChange({ externalConnectionId: value })}
+ >
+
+
+
+
+ {externalConnections.map((conn) => (
+
+ {conn.name} ({conn.type})
+
+ ))}
+
+
+ )}
+
+ )}
+
+ {/* SQL 쿼리 */}
+
+
+ {/* 테스트 버튼 */}
+
+
+
+ {testResult && (
+
+ {testResult.success ? (
+
+ ) : (
+
+ )}
+
+ {testResult.message}
+ {testResult.rowCount !== undefined && (
+ ({testResult.rowCount}행)
+ )}
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts
index 096273c9..e30995bc 100644
--- a/frontend/components/admin/dashboard/types.ts
+++ b/frontend/components/admin/dashboard/types.ts
@@ -23,6 +23,9 @@ export type ElementSubtype =
| "vehicle-list" // (구버전 - 호환용)
| "vehicle-map" // (구버전 - 호환용)
| "map-summary" // 범용 지도 카드 (통합)
+ | "map-test" // 🧪 지도 테스트 위젯 (REST API 지원)
+ | "map-test-v2" // 🧪 지도 테스트 V2 (다중 데이터 소스)
+ | "chart-test" // 🧪 차트 테스트 (다중 데이터 소스)
| "delivery-status"
| "status-summary" // 범용 상태 카드 (통합)
// | "list-summary" // 범용 목록 카드 (다른 분 작업 중 - 임시 주석)
@@ -97,7 +100,8 @@ export interface DashboardElement {
customTitle?: string; // 사용자 정의 제목 (옵션)
showHeader?: boolean; // 헤더 표시 여부 (기본값: true)
content: string;
- dataSource?: ChartDataSource; // 데이터 소스 설정
+ dataSource?: ChartDataSource; // 데이터 소스 설정 (단일, 하위 호환용)
+ dataSources?: ChartDataSource[]; // 다중 데이터 소스 설정 (테스트 위젯용)
chartConfig?: ChartConfig; // 차트 설정
clockConfig?: ClockConfig; // 시계 설정
calendarConfig?: CalendarConfig; // 달력 설정
@@ -125,6 +129,8 @@ export interface KeyValuePair {
}
export interface ChartDataSource {
+ id?: string; // 고유 ID (다중 데이터 소스용)
+ name?: string; // 사용자 지정 이름 (예: "기상특보", "교통정보")
type: "database" | "api"; // 데이터 소스 타입
// DB 커넥션 관련
@@ -143,6 +149,7 @@ export interface ChartDataSource {
refreshInterval?: number; // 자동 새로고침 (초, 0이면 수동)
lastExecuted?: string; // 마지막 실행 시간
lastError?: string; // 마지막 오류 메시지
+ mapDisplayType?: "auto" | "marker" | "polygon"; // 지도 표시 방식 (auto: 자동, marker: 마커, polygon: 영역)
}
export interface ChartConfig {
@@ -199,6 +206,7 @@ export interface ChartConfig {
stackMode?: "normal" | "percent"; // 누적 모드
// 지도 관련 설정
+ tileMapUrl?: string; // 타일맵 URL (예: VWorld, OpenStreetMap)
latitudeColumn?: string; // 위도 컬럼
longitudeColumn?: string; // 경도 컬럼
labelColumn?: string; // 라벨 컬럼
diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx
index 4bbca728..52cdee88 100644
--- a/frontend/components/dashboard/DashboardViewer.tsx
+++ b/frontend/components/dashboard/DashboardViewer.tsx
@@ -9,6 +9,9 @@ import dynamic from "next/dynamic";
// 위젯 동적 import - 모든 위젯
const MapSummaryWidget = dynamic(() => import("./widgets/MapSummaryWidget"), { ssr: false });
+const MapTestWidget = dynamic(() => import("./widgets/MapTestWidget"), { ssr: false });
+const MapTestWidgetV2 = dynamic(() => import("./widgets/MapTestWidgetV2"), { ssr: false });
+const ChartTestWidget = dynamic(() => import("./widgets/ChartTestWidget"), { ssr: false });
const StatusSummaryWidget = dynamic(() => import("./widgets/StatusSummaryWidget"), { ssr: false });
const RiskAlertWidget = dynamic(() => import("./widgets/RiskAlertWidget"), { ssr: false });
const WeatherWidget = dynamic(() => import("./widgets/WeatherWidget"), { ssr: false });
@@ -76,6 +79,12 @@ function renderWidget(element: DashboardElement) {
return ;
case "map-summary":
return ;
+ case "map-test":
+ return ;
+ case "map-test-v2":
+ return ;
+ case "chart-test":
+ return ;
case "risk-alert":
return ;
case "calendar":
diff --git a/frontend/components/dashboard/widgets/ChartTestWidget.tsx b/frontend/components/dashboard/widgets/ChartTestWidget.tsx
new file mode 100644
index 00000000..3a27d039
--- /dev/null
+++ b/frontend/components/dashboard/widgets/ChartTestWidget.tsx
@@ -0,0 +1,297 @@
+"use client";
+
+import React, { useEffect, useState, useCallback } from "react";
+import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
+import { Loader2 } from "lucide-react";
+import {
+ LineChart,
+ Line,
+ BarChart,
+ Bar,
+ PieChart,
+ Pie,
+ Cell,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ Legend,
+ ResponsiveContainer,
+} from "recharts";
+
+interface ChartTestWidgetProps {
+ element: DashboardElement;
+}
+
+const COLORS = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899"];
+
+export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
+ const [data, setData] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ console.log("🧪 ChartTestWidget 렌더링!", element);
+
+ // 다중 데이터 소스 로딩
+ const loadMultipleDataSources = useCallback(async () => {
+ const dataSources = element?.dataSources;
+
+ if (!dataSources || dataSources.length === 0) {
+ console.log("⚠️ 데이터 소스가 없습니다.");
+ return;
+ }
+
+ console.log(`🔄 \${dataSources.length}개의 데이터 소스 로딩 시작...`);
+ setLoading(true);
+ setError(null);
+
+ try {
+ // 모든 데이터 소스를 병렬로 로딩
+ const results = await Promise.allSettled(
+ dataSources.map(async (source) => {
+ try {
+ console.log(`📡 데이터 소스 "\${source.name || source.id}" 로딩 중...`);
+
+ if (source.type === "api") {
+ return await loadRestApiData(source);
+ } else if (source.type === "database") {
+ return await loadDatabaseData(source);
+ }
+
+ return [];
+ } catch (err: any) {
+ console.error(`❌ 데이터 소스 "\${source.name || source.id}" 로딩 실패:`, err);
+ return [];
+ }
+ })
+ );
+
+ // 성공한 데이터만 병합
+ const allData: any[] = [];
+ results.forEach((result, index) => {
+ if (result.status === "fulfilled" && Array.isArray(result.value)) {
+ const sourceData = result.value.map((item: any) => ({
+ ...item,
+ _source: dataSources[index].name || dataSources[index].id || `소스 \${index + 1}`,
+ }));
+ allData.push(...sourceData);
+ }
+ });
+
+ console.log(`✅ 총 \${allData.length}개의 데이터 로딩 완료`);
+ setData(allData);
+ } catch (err: any) {
+ console.error("❌ 데이터 로딩 중 오류:", err);
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }, [element?.dataSources]);
+
+ // REST API 데이터 로딩
+ const loadRestApiData = async (source: ChartDataSource): Promise => {
+ if (!source.endpoint) {
+ throw new Error("API endpoint가 없습니다.");
+ }
+
+ const queryParams: Record = {};
+ if (source.queryParams) {
+ source.queryParams.forEach((param) => {
+ if (param.key && param.value) {
+ queryParams[param.key] = param.value;
+ }
+ });
+ }
+
+ const headers: Record = {};
+ if (source.headers) {
+ source.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: source.endpoint,
+ method: source.method || "GET",
+ headers,
+ queryParams,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`API 호출 실패: \${response.status}`);
+ }
+
+ const result = await response.json();
+ if (!result.success) {
+ throw new Error(result.message || "API 호출 실패");
+ }
+
+ let apiData = result.data;
+ if (source.jsonPath) {
+ const pathParts = source.jsonPath.split(".");
+ for (const part of pathParts) {
+ apiData = apiData?.[part];
+ }
+ }
+
+ return Array.isArray(apiData) ? apiData : [apiData];
+ };
+
+ // Database 데이터 로딩
+ const loadDatabaseData = async (source: ChartDataSource): Promise => {
+ if (!source.query) {
+ 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,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`데이터베이스 쿼리 실패: \${response.status}`);
+ }
+
+ const result = await response.json();
+ if (!result.success) {
+ throw new Error(result.message || "쿼리 실패");
+ }
+
+ return result.data || [];
+ };
+
+ useEffect(() => {
+ if (element?.dataSources && element.dataSources.length > 0) {
+ loadMultipleDataSources();
+ }
+ }, [element?.dataSources, loadMultipleDataSources]);
+
+ const chartType = element?.subtype || "line";
+ const chartConfig = element?.chartConfig || {};
+
+ const renderChart = () => {
+ if (data.length === 0) {
+ return (
+
+ );
+ }
+
+ const xAxis = chartConfig.xAxis || Object.keys(data[0])[0];
+ const yAxis = chartConfig.yAxis || Object.keys(data[0])[1];
+
+ switch (chartType) {
+ case "line":
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+
+ case "bar":
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+
+ case "pie":
+ return (
+
+
+
+ {data.map((entry, index) => (
+ |
+ ))}
+
+
+
+
+
+ );
+
+ default:
+ return (
+
+
+ 지원하지 않는 차트 타입: {chartType}
+
+
+ );
+ }
+ };
+
+ return (
+
+
+
+
+ {element?.customTitle || "차트 테스트 (다중 데이터 소스)"}
+
+
+ {element?.dataSources?.length || 0}개 데이터 소스 연결됨
+
+
+ {loading &&
}
+
+
+
+ {error ? (
+
+ ) : !element?.dataSources || element.dataSources.length === 0 ? (
+
+ ) : (
+ renderChart()
+ )}
+
+
+ {data.length > 0 && (
+
+ 총 {data.length}개 데이터 표시 중
+
+ )}
+
+ );
+}
diff --git a/frontend/components/dashboard/widgets/MapTestWidget.tsx b/frontend/components/dashboard/widgets/MapTestWidget.tsx
new file mode 100644
index 00000000..fb9071fa
--- /dev/null
+++ b/frontend/components/dashboard/widgets/MapTestWidget.tsx
@@ -0,0 +1,1193 @@
+"use client";
+
+import React, { useEffect, useState } from "react";
+import dynamic from "next/dynamic";
+import { DashboardElement } from "@/components/admin/dashboard/types";
+import { getWeather, WeatherData, getWeatherAlerts, WeatherAlert } from "@/lib/api/openApi";
+import { Cloud, CloudRain, CloudSnow, Sun, Wind, AlertTriangle } from "lucide-react";
+import turfUnion from "@turf/union";
+import { polygon } from "@turf/helpers";
+import { getApiUrl } from "@/lib/utils/apiUrl";
+import "leaflet/dist/leaflet.css";
+
+// Leaflet 아이콘 경로 설정 (엑박 방지)
+if (typeof window !== "undefined") {
+ const L = require("leaflet");
+ delete (L.Icon.Default.prototype as any)._getIconUrl;
+ L.Icon.Default.mergeOptions({
+ iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
+ iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
+ shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
+ });
+}
+
+// Leaflet 동적 import (SSR 방지)
+const MapContainer = dynamic(() => import("react-leaflet").then((mod) => mod.MapContainer), { ssr: false });
+const TileLayer = dynamic(() => import("react-leaflet").then((mod) => mod.TileLayer), { ssr: false });
+const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker), { ssr: false });
+const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false });
+const GeoJSON = dynamic(() => import("react-leaflet").then((mod) => mod.GeoJSON), { ssr: false });
+const Polygon = dynamic(() => import("react-leaflet").then((mod) => mod.Polygon), { ssr: false });
+
+// 브이월드 API 키
+const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033";
+
+interface MapTestWidgetProps {
+ element: DashboardElement;
+}
+
+interface MarkerData {
+ id?: string;
+ lat: number;
+ lng: number;
+ latitude?: number;
+ longitude?: number;
+ name: string;
+ status?: string;
+ description?: string;
+ info?: any;
+ weather?: WeatherData | null;
+}
+
+// 테이블명 한글 번역
+const translateTableName = (name: string): string => {
+ const tableTranslations: { [key: string]: string } = {
+ vehicle_locations: "차량",
+ vehicles: "차량",
+ warehouses: "창고",
+ warehouse: "창고",
+ customers: "고객",
+ customer: "고객",
+ deliveries: "배송",
+ delivery: "배송",
+ drivers: "기사",
+ driver: "기사",
+ stores: "매장",
+ store: "매장",
+ };
+
+ return tableTranslations[name.toLowerCase()] || tableTranslations[name.replace(/_/g, "").toLowerCase()] || name;
+};
+
+// 주요 도시 좌표 (날씨 API 지원 도시)
+const CITY_COORDINATES = [
+ { name: "서울", lat: 37.5665, lng: 126.978 },
+ { name: "부산", lat: 35.1796, lng: 129.0756 },
+ { name: "인천", lat: 37.4563, lng: 126.7052 },
+ { name: "대구", lat: 35.8714, lng: 128.6014 },
+ { name: "광주", lat: 35.1595, lng: 126.8526 },
+ { name: "대전", lat: 36.3504, lng: 127.3845 },
+ { name: "울산", lat: 35.5384, lng: 129.3114 },
+ { name: "세종", lat: 36.48, lng: 127.289 },
+ { name: "제주", lat: 33.4996, lng: 126.5312 },
+];
+
+// 해상 구역 폴리곤 좌표 (기상청 특보 구역 기준 - 깔끔한 사각형)
+const MARITIME_ZONES: Record> = {
+ // 제주도 해역
+ 제주도남부앞바다: [
+ [33.25, 126.0],
+ [33.25, 126.85],
+ [33.0, 126.85],
+ [33.0, 126.0],
+ ],
+ 제주도남쪽바깥먼바다: [
+ [33.15, 125.7],
+ [33.15, 127.3],
+ [32.5, 127.3],
+ [32.5, 125.7],
+ ],
+ 제주도동부앞바다: [
+ [33.4, 126.7],
+ [33.4, 127.25],
+ [33.05, 127.25],
+ [33.05, 126.7],
+ ],
+ 제주도남동쪽안쪽먼바다: [
+ [33.3, 126.85],
+ [33.3, 127.95],
+ [32.65, 127.95],
+ [32.65, 126.85],
+ ],
+ 제주도남서쪽안쪽먼바다: [
+ [33.3, 125.35],
+ [33.3, 126.45],
+ [32.7, 126.45],
+ [32.7, 125.35],
+ ],
+
+ // 남해 해역
+ 남해동부앞바다: [
+ [34.65, 128.3],
+ [34.65, 129.65],
+ [33.95, 129.65],
+ [33.95, 128.3],
+ ],
+ 남해동부안쪽먼바다: [
+ [34.25, 127.95],
+ [34.25, 129.75],
+ [33.45, 129.75],
+ [33.45, 127.95],
+ ],
+ 남해동부바깥먼바다: [
+ [33.65, 127.95],
+ [33.65, 130.35],
+ [32.45, 130.35],
+ [32.45, 127.95],
+ ],
+
+ // 동해 해역
+ 경북북부앞바다: [
+ [36.65, 129.2],
+ [36.65, 130.1],
+ [35.95, 130.1],
+ [35.95, 129.2],
+ ],
+ 경북남부앞바다: [
+ [36.15, 129.1],
+ [36.15, 129.95],
+ [35.45, 129.95],
+ [35.45, 129.1],
+ ],
+ 동해남부남쪽안쪽먼바다: [
+ [35.65, 129.35],
+ [35.65, 130.65],
+ [34.95, 130.65],
+ [34.95, 129.35],
+ ],
+ 동해남부남쪽바깥먼바다: [
+ [35.25, 129.45],
+ [35.25, 131.15],
+ [34.15, 131.15],
+ [34.15, 129.45],
+ ],
+ 동해남부북쪽안쪽먼바다: [
+ [36.6, 129.65],
+ [36.6, 130.95],
+ [35.85, 130.95],
+ [35.85, 129.65],
+ ],
+ 동해남부북쪽바깥먼바다: [
+ [36.65, 130.35],
+ [36.65, 132.15],
+ [35.85, 132.15],
+ [35.85, 130.35],
+ ],
+
+ // 강원 해역
+ 강원북부앞바다: [
+ [38.15, 128.4],
+ [38.15, 129.55],
+ [37.45, 129.55],
+ [37.45, 128.4],
+ ],
+ 강원중부앞바다: [
+ [37.65, 128.7],
+ [37.65, 129.6],
+ [36.95, 129.6],
+ [36.95, 128.7],
+ ],
+ 강원남부앞바다: [
+ [37.15, 128.9],
+ [37.15, 129.85],
+ [36.45, 129.85],
+ [36.45, 128.9],
+ ],
+ 동해중부안쪽먼바다: [
+ [38.55, 129.35],
+ [38.55, 131.15],
+ [37.25, 131.15],
+ [37.25, 129.35],
+ ],
+ 동해중부바깥먼바다: [
+ [38.6, 130.35],
+ [38.6, 132.55],
+ [37.65, 132.55],
+ [37.65, 130.35],
+ ],
+
+ // 울릉도·독도
+ "울릉도.독도": [
+ [37.7, 130.7],
+ [37.7, 132.0],
+ [37.4, 132.0],
+ [37.4, 130.7],
+ ],
+};
+
+// 두 좌표 간 거리 계산 (Haversine formula)
+const getDistance = (lat1: number, lng1: number, lat2: number, lng2: number): number => {
+ const R = 6371; // 지구 반경 (km)
+ const dLat = ((lat2 - lat1) * Math.PI) / 180;
+ const dLng = ((lng2 - lng1) * Math.PI) / 180;
+ const a =
+ Math.sin(dLat / 2) * Math.sin(dLat / 2) +
+ Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+ return R * c;
+};
+
+// 가장 가까운 도시 찾기
+const findNearestCity = (lat: number, lng: number): string => {
+ let nearestCity = "서울";
+ let minDistance = Infinity;
+
+ for (const city of CITY_COORDINATES) {
+ const distance = getDistance(lat, lng, city.lat, city.lng);
+ if (distance < minDistance) {
+ minDistance = distance;
+ nearestCity = city.name;
+ }
+ }
+
+ return nearestCity;
+};
+
+// 날씨 아이콘 반환
+const getWeatherIcon = (weatherMain: string) => {
+ switch (weatherMain.toLowerCase()) {
+ case "clear":
+ return ;
+ case "rain":
+ return ;
+ case "snow":
+ return ;
+ case "clouds":
+ return ;
+ default:
+ return ;
+ }
+};
+
+// 특보 심각도별 색상 반환
+const getAlertColor = (severity: string): string => {
+ switch (severity) {
+ case "high":
+ return "#ef4444"; // 빨강 (경보)
+ case "medium":
+ return "#f59e0b"; // 주황 (주의보)
+ case "low":
+ return "#eab308"; // 노랑 (약한 주의보)
+ default:
+ return "#6b7280"; // 회색
+ }
+};
+
+// 지역명 정규화 (특보 API 지역명 → GeoJSON 지역명)
+const normalizeRegionName = (location: string): string => {
+ // 기상청 특보는 "강릉시", "속초시", "인제군" 등으로 옴
+ // GeoJSON도 같은 형식이므로 그대로 반환
+ return location;
+};
+
+/**
+ * 범용 지도 위젯 (커스텀 지도 카드)
+ * - 위도/경도가 있는 모든 데이터를 지도에 표시
+ * - 차량, 창고, 고객, 배송 등 모든 위치 데이터 지원
+ * - Leaflet + 브이월드 지도 사용
+ */
+function MapTestWidget({ element }: MapTestWidgetProps) {
+ console.log("🧪 MapTestWidget 렌더링!", element);
+
+ const [markers, setMarkers] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [tableName, setTableName] = useState(null);
+ const [weatherCache, setWeatherCache] = useState
-
+
+
+
+
+
+ handleAddDataSource("api")}>
+
+ REST API 추가
+
+ handleAddDataSource("database")}>
+
+ Database 추가
+
+
+
{/* 데이터 소스가 없는 경우 */}
@@ -95,15 +108,28 @@ export default function MultiDataSourceConfig({
연결된 데이터 소스가 없습니다
-
+
+
+
+
+
+ handleAddDataSource("api")}>
+
+ REST API 추가
+
+ handleAddDataSource("database")}>
+
+ Database 추가
+
+
+
) : (
/* 탭 UI */
diff --git a/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx b/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx
index cf1efaa5..62a38701 100644
--- a/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx
+++ b/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx
@@ -3,6 +3,7 @@
import React, { useState, useEffect } from "react";
import { ChartDataSource } from "@/components/admin/dashboard/types";
import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
@@ -25,6 +26,10 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
const [testResult, setTestResult] = useState<{ success: boolean; message: string; rowCount?: number } | null>(null);
const [externalConnections, setExternalConnections] = useState([]);
const [loadingConnections, setLoadingConnections] = useState(false);
+ const [availableColumns, setAvailableColumns] = useState([]); // 쿼리 테스트 후 발견된 컬럼 목록
+ const [columnTypes, setColumnTypes] = useState>({}); // 컬럼 타입 정보
+ const [sampleData, setSampleData] = useState([]); // 샘플 데이터 (최대 3개)
+ const [columnSearchTerm, setColumnSearchTerm] = useState(""); // 컬럼 검색어
// 외부 DB 커넥션 목록 로드
useEffect(() => {
@@ -36,19 +41,19 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
const loadExternalConnections = async () => {
setLoadingConnections(true);
try {
- const response = await fetch("/api/admin/reports/external-connections", {
- credentials: "include",
- });
+ // ExternalDbConnectionAPI 사용 (인증 토큰 자동 포함)
+ const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
+ const connections = await ExternalDbConnectionAPI.getConnections({ is_active: "Y" });
- 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);
- }
- }
+ console.log("✅ 외부 DB 커넥션 로드 성공:", connections.length, "개");
+ setExternalConnections(connections.map((conn: any) => ({
+ id: String(conn.id),
+ name: conn.connection_name,
+ type: conn.db_type,
+ })));
} catch (error) {
- console.error("외부 DB 커넥션 로드 실패:", error);
+ console.error("❌ 외부 DB 커넥션 로드 실패:", error);
+ setExternalConnections([]);
} finally {
setLoadingConnections(false);
}
@@ -77,7 +82,41 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
);
if (result.success && result.data) {
- const rowCount = Array.isArray(result.data.rows) ? result.data.rows.length : 0;
+ const rows = Array.isArray(result.data.rows) ? result.data.rows : [];
+ const rowCount = rows.length;
+
+ // 컬럼 목록 및 타입 추출
+ if (rows.length > 0) {
+ const columns = Object.keys(rows[0]);
+ setAvailableColumns(columns);
+
+ // 컬럼 타입 분석
+ const types: Record = {};
+ columns.forEach(col => {
+ const value = rows[0][col];
+ if (value === null || value === undefined) {
+ types[col] = "unknown";
+ } else if (typeof value === "number") {
+ types[col] = "number";
+ } else if (typeof value === "boolean") {
+ types[col] = "boolean";
+ } else if (typeof value === "string") {
+ if (/^\d{4}-\d{2}-\d{2}/.test(value)) {
+ types[col] = "date";
+ } else {
+ types[col] = "string";
+ }
+ } else {
+ types[col] = "object";
+ }
+ });
+ setColumnTypes(types);
+ setSampleData(rows.slice(0, 3));
+
+ console.log("📊 발견된 컬럼:", columns);
+ console.log("📊 컬럼 타입:", types);
+ }
+
setTestResult({
success: true,
message: "쿼리 실행 성공",
@@ -89,6 +128,39 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
} else {
// 현재 DB
const result = await dashboardApi.executeQuery(dataSource.query);
+
+ // 컬럼 목록 및 타입 추출
+ if (result.rows && result.rows.length > 0) {
+ const columns = Object.keys(result.rows[0]);
+ setAvailableColumns(columns);
+
+ // 컬럼 타입 분석
+ const types: Record = {};
+ columns.forEach(col => {
+ const value = result.rows[0][col];
+ if (value === null || value === undefined) {
+ types[col] = "unknown";
+ } else if (typeof value === "number") {
+ types[col] = "number";
+ } else if (typeof value === "boolean") {
+ types[col] = "boolean";
+ } else if (typeof value === "string") {
+ if (/^\d{4}-\d{2}-\d{2}/.test(value)) {
+ types[col] = "date";
+ } else {
+ types[col] = "string";
+ }
+ } else {
+ types[col] = "object";
+ }
+ });
+ setColumnTypes(types);
+ setSampleData(result.rows.slice(0, 3));
+
+ console.log("📊 발견된 컬럼:", columns);
+ console.log("📊 컬럼 타입:", types);
+ }
+
setTestResult({
success: true,
message: "쿼리 실행 성공",
@@ -183,6 +255,34 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
+ {/* 자동 새로고침 설정 */}
+
+
+
onChange({ refreshInterval: Number(value) })}
+ >
+
+
+
+
+ 새로고침 안 함
+ 10초마다
+ 30초마다
+ 1분마다
+ 5분마다
+ 10분마다
+ 30분마다
+ 1시간마다
+
+
+
+ 설정한 간격마다 자동으로 데이터를 다시 불러옵니다
+
+
+
{/* 테스트 버튼 */}
+
+ {/* 컬럼 선택 (메트릭 위젯용) - 개선된 UI */}
+ {availableColumns.length > 0 && (
+
+
+
+
+
+ {dataSource.selectedColumns && dataSource.selectedColumns.length > 0
+ ? `${dataSource.selectedColumns.length}개 컬럼 선택됨`
+ : "모든 컬럼 표시"}
+
+
+
+
+
+
+
+
+ {/* 검색 */}
+ {availableColumns.length > 5 && (
+
setColumnSearchTerm(e.target.value)}
+ className="h-8 text-xs"
+ />
+ )}
+
+ {/* 컬럼 카드 그리드 */}
+
+ {availableColumns
+ .filter(col =>
+ !columnSearchTerm ||
+ col.toLowerCase().includes(columnSearchTerm.toLowerCase())
+ )
+ .map((col) => {
+ const isSelected =
+ !dataSource.selectedColumns ||
+ dataSource.selectedColumns.length === 0 ||
+ dataSource.selectedColumns.includes(col);
+
+ const type = columnTypes[col] || "unknown";
+ const typeIcon = {
+ number: "🔢",
+ string: "📝",
+ date: "📅",
+ boolean: "✓",
+ object: "📦",
+ unknown: "❓"
+ }[type];
+
+ const typeColor = {
+ number: "text-blue-600 bg-blue-50",
+ string: "text-gray-600 bg-gray-50",
+ date: "text-purple-600 bg-purple-50",
+ boolean: "text-green-600 bg-green-50",
+ object: "text-orange-600 bg-orange-50",
+ unknown: "text-gray-400 bg-gray-50"
+ }[type];
+
+ return (
+
{
+ const currentSelected = dataSource.selectedColumns && dataSource.selectedColumns.length > 0
+ ? dataSource.selectedColumns
+ : availableColumns;
+
+ const newSelected = isSelected
+ ? currentSelected.filter(c => c !== col)
+ : [...currentSelected, col];
+
+ onChange({ selectedColumns: newSelected });
+ }}
+ className={`
+ relative flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-all
+ ${isSelected
+ ? "border-primary bg-primary/5 shadow-sm"
+ : "border-border bg-card hover:border-primary/50 hover:bg-muted/50"
+ }
+ `}
+ >
+ {/* 체크박스 */}
+
+
+ {isSelected && (
+
+ )}
+
+
+
+ {/* 컬럼 정보 */}
+
+
+ {col}
+
+ {typeIcon} {type}
+
+
+
+ {/* 샘플 데이터 */}
+ {sampleData.length > 0 && (
+
+ 예시:{" "}
+ {sampleData.slice(0, 2).map((row, i) => (
+
+ {String(row[col]).substring(0, 20)}
+ {String(row[col]).length > 20 && "..."}
+ {i < Math.min(sampleData.length - 1, 1) && ", "}
+
+ ))}
+
+ )}
+
+
+ );
+ })}
+
+
+ {/* 검색 결과 없음 */}
+ {columnSearchTerm && availableColumns.filter(col =>
+ col.toLowerCase().includes(columnSearchTerm.toLowerCase())
+ ).length === 0 && (
+
+ "{columnSearchTerm}"에 대한 컬럼을 찾을 수 없습니다
+
+ )}
+
+ )}
);
}
diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts
index ed615762..15236d42 100644
--- a/frontend/components/admin/dashboard/types.ts
+++ b/frontend/components/admin/dashboard/types.ts
@@ -28,6 +28,8 @@ export type ElementSubtype =
| "chart-test" // 🧪 차트 테스트 (다중 데이터 소스)
| "list-test" // 🧪 리스트 테스트 (다중 데이터 소스)
| "custom-metric-test" // 🧪 커스텀 메트릭 테스트 (다중 데이터 소스)
+ | "status-summary-test" // 🧪 상태 요약 테스트 (다중 데이터 소스)
+ | "risk-alert-test" // 🧪 리스크/알림 테스트 (다중 데이터 소스)
| "delivery-status"
| "status-summary" // 범용 상태 카드 (통합)
// | "list-summary" // 범용 목록 카드 (다른 분 작업 중 - 임시 주석)
@@ -152,6 +154,9 @@ export interface ChartDataSource {
lastExecuted?: string; // 마지막 실행 시간
lastError?: string; // 마지막 오류 메시지
mapDisplayType?: "auto" | "marker" | "polygon"; // 지도 표시 방식 (auto: 자동, marker: 마커, polygon: 영역)
+
+ // 메트릭 설정 (CustomMetricTestWidget용)
+ selectedColumns?: string[]; // 표시할 컬럼 선택 (빈 배열이면 전체 표시)
}
export interface ChartConfig {
diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx
index 062a1b1f..b24f9219 100644
--- a/frontend/components/dashboard/DashboardViewer.tsx
+++ b/frontend/components/dashboard/DashboardViewer.tsx
@@ -12,8 +12,12 @@ const MapSummaryWidget = dynamic(() => import("./widgets/MapSummaryWidget"), { s
const MapTestWidget = dynamic(() => import("./widgets/MapTestWidget"), { ssr: false });
const MapTestWidgetV2 = dynamic(() => import("./widgets/MapTestWidgetV2"), { ssr: false });
const ChartTestWidget = dynamic(() => import("./widgets/ChartTestWidget"), { ssr: false });
-const ListTestWidget = dynamic(() => import("./widgets/ListTestWidget").then((mod) => ({ default: mod.ListTestWidget })), { ssr: false });
+const ListTestWidget = dynamic(
+ () => import("./widgets/ListTestWidget").then((mod) => ({ default: mod.ListTestWidget })),
+ { ssr: false },
+);
const CustomMetricTestWidget = dynamic(() => import("./widgets/CustomMetricTestWidget"), { ssr: false });
+const RiskAlertTestWidget = dynamic(() => import("./widgets/RiskAlertTestWidget"), { ssr: false });
const StatusSummaryWidget = dynamic(() => import("./widgets/StatusSummaryWidget"), { ssr: false });
const RiskAlertWidget = dynamic(() => import("./widgets/RiskAlertWidget"), { ssr: false });
const WeatherWidget = dynamic(() => import("./widgets/WeatherWidget"), { ssr: false });
@@ -91,6 +95,8 @@ function renderWidget(element: DashboardElement) {
return ;
case "custom-metric-test":
return ;
+ case "risk-alert-test":
+ return ;
case "risk-alert":
return ;
case "calendar":
diff --git a/frontend/components/dashboard/widgets/ChartTestWidget.tsx b/frontend/components/dashboard/widgets/ChartTestWidget.tsx
index b445c48e..f4b21f43 100644
--- a/frontend/components/dashboard/widgets/ChartTestWidget.tsx
+++ b/frontend/components/dashboard/widgets/ChartTestWidget.tsx
@@ -1,8 +1,9 @@
"use client";
-import React, { useEffect, useState, useCallback } from "react";
+import React, { useEffect, useState, useCallback, useMemo } from "react";
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
-import { Loader2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Loader2, RefreshCw } from "lucide-react";
import {
LineChart,
Line,
@@ -29,9 +30,14 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
+ const [lastRefreshTime, setLastRefreshTime] = useState(null);
console.log("🧪 ChartTestWidget 렌더링!", element);
+ const dataSources = useMemo(() => {
+ return element?.dataSources || element?.chartConfig?.dataSources;
+ }, [element?.dataSources, element?.chartConfig?.dataSources]);
+
// 다중 데이터 소스 로딩
const loadMultipleDataSources = useCallback(async () => {
// dataSources는 element.dataSources 또는 chartConfig.dataSources에서 로드
@@ -81,6 +87,7 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
console.log(`✅ 총 \${allData.length}개의 데이터 로딩 완료`);
setData(allData);
+ setLastRefreshTime(new Date());
} catch (err: any) {
console.error("❌ 데이터 로딩 중 오류:", err);
setError(err.message);
@@ -89,6 +96,12 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
}
}, [element?.dataSources]);
+ // 수동 새로고침 핸들러
+ const handleManualRefresh = useCallback(() => {
+ console.log("🔄 수동 새로고침 버튼 클릭");
+ loadMultipleDataSources();
+ }, [loadMultipleDataSources]);
+
// REST API 데이터 로딩
const loadRestApiData = async (source: ChartDataSource): Promise => {
if (!source.endpoint) {
@@ -174,12 +187,36 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
return result.data || [];
};
+ // 초기 로드
useEffect(() => {
- const dataSources = element?.dataSources || element?.chartConfig?.dataSources;
if (dataSources && dataSources.length > 0) {
loadMultipleDataSources();
}
- }, [element?.dataSources, element?.chartConfig?.dataSources, loadMultipleDataSources]);
+ }, [dataSources, loadMultipleDataSources]);
+
+ // 자동 새로고침
+ useEffect(() => {
+ if (!dataSources || dataSources.length === 0) return;
+
+ const intervals = dataSources
+ .map((ds) => ds.refreshInterval)
+ .filter((interval): interval is number => typeof interval === "number" && interval > 0);
+
+ if (intervals.length === 0) return;
+
+ const minInterval = Math.min(...intervals);
+ console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`);
+
+ const intervalId = setInterval(() => {
+ console.log("🔄 자동 새로고침 실행");
+ loadMultipleDataSources();
+ }, minInterval * 1000);
+
+ return () => {
+ console.log("⏹️ 자동 새로고침 정리");
+ clearInterval(intervalId);
+ };
+ }, [dataSources, loadMultipleDataSources]);
const chartType = element?.subtype || "line";
const chartConfig = element?.chartConfig || {};
@@ -267,10 +304,27 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
{element?.customTitle || "차트 테스트 (다중 데이터 소스)"}
- {(element?.dataSources || element?.chartConfig?.dataSources)?.length || 0}개 데이터 소스 연결됨
+ {dataSources?.length || 0}개 데이터 소스 • {data.length}개 데이터
+ {lastRefreshTime && (
+
+ • {lastRefreshTime.toLocaleTimeString("ko-KR")}
+
+ )}
- {loading && }
+
+
+ {loading && }
+
diff --git a/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx
index b0f9122c..8c58fe4f 100644
--- a/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx
+++ b/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx
@@ -1,8 +1,9 @@
"use client";
-import React, { useState, useEffect, useCallback } from "react";
+import React, { useState, useEffect, useCallback, useMemo } from "react";
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
-import { Loader2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Loader2, RefreshCw } from "lucide-react";
interface CustomMetricTestWidgetProps {
element: DashboardElement;
@@ -54,10 +55,25 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
const [metrics, setMetrics] = useState
([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
+ const [lastRefreshTime, setLastRefreshTime] = useState(null);
console.log("🧪 CustomMetricTestWidget 렌더링!", element);
- const metricConfig = element?.customMetricConfig?.metrics || [];
+ const dataSources = useMemo(() => {
+ return element?.dataSources || element?.chartConfig?.dataSources;
+ }, [element?.dataSources, element?.chartConfig?.dataSources]);
+
+ // 메트릭 설정 (없으면 기본값 사용) - useMemo로 메모이제이션
+ const metricConfig = useMemo(() => {
+ return element?.customMetricConfig?.metrics || [
+ {
+ label: "총 개수",
+ field: "id",
+ aggregation: "count",
+ color: "indigo",
+ },
+ ];
+ }, [element?.customMetricConfig?.metrics]);
// 다중 데이터 소스 로딩
const loadMultipleDataSources = useCallback(async () => {
@@ -73,43 +89,203 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
setError(null);
try {
- // 모든 데이터 소스를 병렬로 로딩
+ // 모든 데이터 소스를 병렬로 로딩 (각각 별도로 처리)
const results = await Promise.allSettled(
- dataSources.map(async (source) => {
+ dataSources.map(async (source, sourceIndex) => {
try {
- console.log(`📡 데이터 소스 "${source.name || source.id}" 로딩 중...`);
+ console.log(`📡 데이터 소스 ${sourceIndex + 1} "${source.name || source.id}" 로딩 중...`);
+ let rows: any[] = [];
if (source.type === "api") {
- return await loadRestApiData(source);
+ rows = await loadRestApiData(source);
} else if (source.type === "database") {
- return await loadDatabaseData(source);
+ rows = await loadDatabaseData(source);
}
- return [];
+ console.log(`✅ 데이터 소스 ${sourceIndex + 1}: ${rows.length}개 행`);
+
+ return {
+ sourceName: source.name || `데이터 소스 ${sourceIndex + 1}`,
+ sourceIndex: sourceIndex,
+ rows: rows,
+ };
} catch (err: any) {
console.error(`❌ 데이터 소스 "${source.name || source.id}" 로딩 실패:`, err);
- return [];
+ return {
+ sourceName: source.name || `데이터 소스 ${sourceIndex + 1}`,
+ sourceIndex: sourceIndex,
+ rows: [],
+ };
}
})
);
- // 성공한 데이터만 병합
- const allRows: any[] = [];
+ console.log(`✅ 총 ${results.length}개의 데이터 소스 로딩 완료`);
+
+ // 각 데이터 소스별로 메트릭 생성
+ const allMetrics: any[] = [];
+ const colors = ["indigo", "green", "blue", "purple", "orange", "gray"];
+
results.forEach((result) => {
- if (result.status === "fulfilled" && Array.isArray(result.value)) {
- allRows.push(...result.value);
+ if (result.status !== "fulfilled" || !result.value.rows || result.value.rows.length === 0) {
+ return;
+ }
+
+ const { sourceName, rows } = result.value;
+
+ // 집계된 데이터인지 확인 (행이 적고 숫자 컬럼이 있으면)
+ const hasAggregatedData = rows.length > 0 && rows.length <= 100;
+
+ if (hasAggregatedData && rows.length > 0) {
+ const firstRow = rows[0];
+ const columns = Object.keys(firstRow);
+
+ // 숫자 컬럼 찾기
+ const numericColumns = columns.filter(col => {
+ const value = firstRow[col];
+ return typeof value === 'number' || !isNaN(Number(value));
+ });
+
+ // 문자열 컬럼 찾기
+ const stringColumns = columns.filter(col => {
+ const value = firstRow[col];
+ return typeof value === 'string' || !numericColumns.includes(col);
+ });
+
+ console.log(`📊 [${sourceName}] 컬럼 분석:`, {
+ 전체: columns,
+ 숫자: numericColumns,
+ 문자열: stringColumns
+ });
+
+ // 숫자 컬럼이 있으면 집계된 데이터로 판단
+ if (numericColumns.length > 0) {
+ console.log(`✅ [${sourceName}] 집계된 데이터, 각 행을 메트릭으로 변환`);
+
+ rows.forEach((row, index) => {
+ // 라벨: 첫 번째 문자열 컬럼
+ const labelField = stringColumns[0] || columns[0];
+ const label = String(row[labelField] || `항목 ${index + 1}`);
+
+ // 값: 첫 번째 숫자 컬럼
+ const valueField = numericColumns[0] || columns[1] || columns[0];
+ const value = Number(row[valueField]) || 0;
+
+ console.log(` [${sourceName}] 메트릭: ${label} = ${value}`);
+
+ allMetrics.push({
+ label: `${sourceName} - ${label}`,
+ value: value,
+ field: valueField,
+ aggregation: "custom",
+ color: colors[allMetrics.length % colors.length],
+ sourceName: sourceName,
+ });
+ });
+ } else {
+ // 숫자 컬럼이 없으면 각 컬럼별 고유값 개수 표시
+ console.log(`📊 [${sourceName}] 문자열 데이터, 각 컬럼별 고유값 개수 표시`);
+
+ // 데이터 소스에서 선택된 컬럼 가져오기
+ const dataSourceConfig = (element?.dataSources || element?.chartConfig?.dataSources)?.find(
+ ds => ds.name === sourceName || ds.id === result.value.sourceIndex.toString()
+ );
+ const selectedColumns = dataSourceConfig?.selectedColumns || [];
+
+ // 선택된 컬럼이 있으면 해당 컬럼만, 없으면 전체 컬럼 표시
+ const columnsToShow = selectedColumns.length > 0 ? selectedColumns : columns;
+
+ console.log(` [${sourceName}] 표시할 컬럼:`, columnsToShow);
+
+ columnsToShow.forEach((col) => {
+ // 해당 컬럼이 실제로 존재하는지 확인
+ if (!columns.includes(col)) {
+ console.warn(` [${sourceName}] 컬럼 "${col}"이 데이터에 없습니다.`);
+ return;
+ }
+
+ // 해당 컬럼의 고유값 개수 계산
+ const uniqueValues = new Set(rows.map(row => row[col]));
+ const uniqueCount = uniqueValues.size;
+
+ console.log(` [${sourceName}] ${col}: ${uniqueCount}개 고유값`);
+
+ allMetrics.push({
+ label: `${sourceName} - ${col} (고유값)`,
+ value: uniqueCount,
+ field: col,
+ aggregation: "distinct",
+ color: colors[allMetrics.length % colors.length],
+ sourceName: sourceName,
+ });
+ });
+
+ // 총 행 개수도 추가
+ allMetrics.push({
+ label: `${sourceName} - 총 개수`,
+ value: rows.length,
+ field: "count",
+ aggregation: "count",
+ color: colors[allMetrics.length % colors.length],
+ sourceName: sourceName,
+ });
+ }
+ } else {
+ // 행이 많으면 각 컬럼별 고유값 개수 + 총 개수 표시
+ console.log(`📊 [${sourceName}] 일반 데이터 (행 많음), 컬럼별 통계 표시`);
+
+ const firstRow = rows[0];
+ const columns = Object.keys(firstRow);
+
+ // 데이터 소스에서 선택된 컬럼 가져오기
+ const dataSourceConfig = (element?.dataSources || element?.chartConfig?.dataSources)?.find(
+ ds => ds.name === sourceName || ds.id === result.value.sourceIndex.toString()
+ );
+ const selectedColumns = dataSourceConfig?.selectedColumns || [];
+
+ // 선택된 컬럼이 있으면 해당 컬럼만, 없으면 전체 컬럼 표시
+ const columnsToShow = selectedColumns.length > 0 ? selectedColumns : columns;
+
+ console.log(` [${sourceName}] 표시할 컬럼:`, columnsToShow);
+
+ // 각 컬럼별 고유값 개수
+ columnsToShow.forEach((col) => {
+ // 해당 컬럼이 실제로 존재하는지 확인
+ if (!columns.includes(col)) {
+ console.warn(` [${sourceName}] 컬럼 "${col}"이 데이터에 없습니다.`);
+ return;
+ }
+
+ const uniqueValues = new Set(rows.map(row => row[col]));
+ const uniqueCount = uniqueValues.size;
+
+ console.log(` [${sourceName}] ${col}: ${uniqueCount}개 고유값`);
+
+ allMetrics.push({
+ label: `${sourceName} - ${col} (고유값)`,
+ value: uniqueCount,
+ field: col,
+ aggregation: "distinct",
+ color: colors[allMetrics.length % colors.length],
+ sourceName: sourceName,
+ });
+ });
+
+ // 총 행 개수
+ allMetrics.push({
+ label: `${sourceName} - 총 개수`,
+ value: rows.length,
+ field: "count",
+ aggregation: "count",
+ color: colors[allMetrics.length % colors.length],
+ sourceName: sourceName,
+ });
}
});
- console.log(`✅ 총 ${allRows.length}개의 행 로딩 완료`);
-
- // 메트릭 계산
- const calculatedMetrics = metricConfig.map((metric) => ({
- ...metric,
- value: calculateMetric(allRows, metric.field, metric.aggregation),
- }));
-
- setMetrics(calculatedMetrics);
+ console.log(`✅ 총 ${allMetrics.length}개의 메트릭 생성 완료`);
+ setMetrics(allMetrics);
+ setLastRefreshTime(new Date());
} catch (err) {
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
} finally {
@@ -117,6 +293,75 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
}
}, [element?.dataSources, element?.chartConfig?.dataSources, metricConfig]);
+ // 수동 새로고침 핸들러
+ const handleManualRefresh = useCallback(() => {
+ console.log("🔄 수동 새로고침 버튼 클릭");
+ loadMultipleDataSources();
+ }, [loadMultipleDataSources]);
+
+ // XML 데이터 파싱
+ const parseXmlData = (xmlText: string): any[] => {
+ console.log("🔍 XML 파싱 시작");
+ try {
+ const parser = new DOMParser();
+ const xmlDoc = parser.parseFromString(xmlText, "text/xml");
+
+ const records = xmlDoc.getElementsByTagName("record");
+ const result: any[] = [];
+
+ for (let i = 0; i < records.length; i++) {
+ const record = records[i];
+ const obj: any = {};
+
+ for (let j = 0; j < record.children.length; j++) {
+ const child = record.children[j];
+ obj[child.tagName] = child.textContent || "";
+ }
+
+ result.push(obj);
+ }
+
+ console.log(`✅ XML 파싱 완료: ${result.length}개 레코드`);
+ return result;
+ } catch (error) {
+ console.error("❌ XML 파싱 실패:", error);
+ throw new Error("XML 파싱 실패");
+ }
+ };
+
+ // 텍스트/CSV 데이터 파싱
+ const parseTextData = (text: string): any[] => {
+ console.log("🔍 텍스트 파싱 시작 (처음 500자):", text.substring(0, 500));
+
+ // XML 감지
+ if (text.trim().startsWith("")) {
+ console.log("📄 XML 형식 감지");
+ return parseXmlData(text);
+ }
+
+ // CSV 파싱
+ console.log("📄 CSV 형식으로 파싱 시도");
+ const lines = text.trim().split("\n");
+ if (lines.length === 0) return [];
+
+ const headers = lines[0].split(",").map(h => h.trim());
+ const result: any[] = [];
+
+ for (let i = 1; i < lines.length; i++) {
+ const values = lines[i].split(",");
+ const obj: any = {};
+
+ headers.forEach((header, index) => {
+ obj[header] = values[index]?.trim() || "";
+ });
+
+ result.push(obj);
+ }
+
+ console.log(`✅ CSV 파싱 완료: ${result.length}개 행`);
+ return result;
+ };
+
// REST API 데이터 로딩
const loadRestApiData = async (source: ChartDataSource): Promise => {
if (!source.endpoint) {
@@ -124,14 +369,26 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
}
const params = new URLSearchParams();
+
+ // queryParams 배열 또는 객체 처리
if (source.queryParams) {
- Object.entries(source.queryParams).forEach(([key, value]) => {
- if (key && value) {
- params.append(key, String(value));
- }
- });
+ if (Array.isArray(source.queryParams)) {
+ source.queryParams.forEach((param: any) => {
+ if (param.key && param.value) {
+ params.append(param.key, String(param.value));
+ }
+ });
+ } else {
+ Object.entries(source.queryParams).forEach(([key, value]) => {
+ if (key && value) {
+ params.append(key, String(value));
+ }
+ });
+ }
}
+ console.log("🌐 API 호출:", source.endpoint, "파라미터:", Object.fromEntries(params));
+
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
method: "POST",
headers: {
@@ -146,17 +403,34 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
});
if (!response.ok) {
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ const errorText = await response.text();
+ console.error("❌ API 호출 실패:", {
+ status: response.status,
+ statusText: response.statusText,
+ body: errorText.substring(0, 500),
+ });
+ throw new Error(`HTTP ${response.status}: ${errorText.substring(0, 100)}`);
}
const result = await response.json();
+ console.log("✅ API 응답:", result);
if (!result.success) {
- throw new Error(result.message || "외부 API 호출 실패");
+ console.error("❌ API 실패:", result);
+ throw new Error(result.message || result.error || "외부 API 호출 실패");
}
let processedData = result.data;
+ // 텍스트/XML 데이터 처리
+ if (typeof processedData === "string") {
+ console.log("📄 텍스트 형식 데이터 감지");
+ processedData = parseTextData(processedData);
+ } else if (processedData && typeof processedData === "object" && processedData.text) {
+ console.log("📄 래핑된 텍스트 데이터 감지");
+ processedData = parseTextData(processedData.text);
+ }
+
// JSON Path 처리
if (source.jsonPath) {
const paths = source.jsonPath.split(".");
@@ -167,6 +441,18 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
throw new Error(`JSON Path "${source.jsonPath}"에서 데이터를 찾을 수 없습니다`);
}
}
+ } else if (!Array.isArray(processedData) && typeof processedData === "object") {
+ // JSON Path 없으면 자동으로 배열 찾기
+ console.log("🔍 JSON Path 없음, 자동으로 배열 찾기 시도");
+ const arrayKeys = ["data", "items", "result", "records", "rows", "list"];
+
+ for (const key of arrayKeys) {
+ if (Array.isArray(processedData[key])) {
+ console.log(`✅ 배열 발견: ${key}`);
+ processedData = processedData[key];
+ break;
+ }
+ }
}
return Array.isArray(processedData) ? processedData : [processedData];
@@ -206,11 +492,34 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
// 초기 로드
useEffect(() => {
- const dataSources = element?.dataSources || element?.chartConfig?.dataSources;
if (dataSources && dataSources.length > 0 && metricConfig.length > 0) {
loadMultipleDataSources();
}
- }, [element?.dataSources, element?.chartConfig?.dataSources, loadMultipleDataSources, metricConfig]);
+ }, [dataSources, loadMultipleDataSources, metricConfig]);
+
+ // 자동 새로고침
+ useEffect(() => {
+ if (!dataSources || dataSources.length === 0) return;
+
+ const intervals = dataSources
+ .map((ds) => ds.refreshInterval)
+ .filter((interval): interval is number => typeof interval === "number" && interval > 0);
+
+ if (intervals.length === 0) return;
+
+ const minInterval = Math.min(...intervals);
+ console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`);
+
+ const intervalId = setInterval(() => {
+ console.log("🔄 자동 새로고침 실행");
+ loadMultipleDataSources();
+ }, minInterval * 1000);
+
+ return () => {
+ console.log("⏹️ 자동 새로고침 정리");
+ clearInterval(intervalId);
+ };
+ }, [dataSources, loadMultipleDataSources]);
// 메트릭 카드 렌더링
const renderMetricCard = (metric: any, index: number) => {
@@ -238,6 +547,15 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
);
};
+ // 메트릭 개수에 따라 그리드 컬럼 동적 결정
+ 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 (
{/* 헤더 */}
@@ -247,10 +565,27 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
{element?.customTitle || "커스텀 메트릭 (다중 데이터 소스)"}
- {(element?.dataSources || element?.chartConfig?.dataSources)?.length || 0}개 데이터 소스 연결됨
+ {dataSources?.length || 0}개 데이터 소스 • {metrics.length}개 메트릭
+ {lastRefreshTime && (
+
+ • {lastRefreshTime.toLocaleTimeString("ko-KR")}
+
+ )}
- {loading && }
+
+
+ {loading && }
+
{/* 컨텐츠 */}
@@ -272,7 +607,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
) : (
-
+
{metrics.map((metric, index) => renderMetricCard(metric, index))}
)}
diff --git a/frontend/components/dashboard/widgets/ListTestWidget.tsx b/frontend/components/dashboard/widgets/ListTestWidget.tsx
index b5dceead..23911ecf 100644
--- a/frontend/components/dashboard/widgets/ListTestWidget.tsx
+++ b/frontend/components/dashboard/widgets/ListTestWidget.tsx
@@ -1,11 +1,11 @@
"use client";
-import React, { useState, useEffect, useCallback } from "react";
+import React, { useState, useEffect, useCallback, useMemo } from "react";
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
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 } from "lucide-react";
+import { Loader2, RefreshCw } from "lucide-react";
interface ListTestWidgetProps {
element: DashboardElement;
@@ -30,9 +30,14 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState
(null);
const [currentPage, setCurrentPage] = useState(1);
+ const [lastRefreshTime, setLastRefreshTime] = useState(null);
console.log("🧪 ListTestWidget 렌더링!", element);
+ const dataSources = useMemo(() => {
+ return element?.dataSources || element?.chartConfig?.dataSources;
+ }, [element?.dataSources, element?.chartConfig?.dataSources]);
+
const config = element.listConfig || {
columnMode: "auto",
viewMode: "table",
@@ -114,6 +119,7 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
totalRows: allRows.length,
executionTime: 0,
});
+ setLastRefreshTime(new Date());
console.log(`✅ 총 ${allRows.length}개의 행 로딩 완료`);
} catch (err) {
@@ -123,6 +129,12 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
}
}, [element?.dataSources, element?.chartConfig?.dataSources]);
+ // 수동 새로고침 핸들러
+ const handleManualRefresh = useCallback(() => {
+ console.log("🔄 수동 새로고침 버튼 클릭");
+ loadMultipleDataSources();
+ }, [loadMultipleDataSources]);
+
// REST API 데이터 로딩
const loadRestApiData = async (source: ChartDataSource): Promise<{ columns: string[]; rows: any[] }> => {
if (!source.endpoint) {
@@ -152,13 +164,21 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
});
if (!response.ok) {
+ const errorText = await response.text();
+ console.error("❌ API 호출 실패:", {
+ status: response.status,
+ statusText: response.statusText,
+ body: errorText.substring(0, 500),
+ });
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
+ console.log("✅ API 응답:", result);
if (!result.success) {
- throw new Error(result.message || "외부 API 호출 실패");
+ console.error("❌ API 실패:", result);
+ throw new Error(result.message || result.error || "외부 API 호출 실패");
}
let processedData = result.data;
@@ -222,11 +242,34 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
// 초기 로드
useEffect(() => {
- const dataSources = element?.dataSources || element?.chartConfig?.dataSources;
if (dataSources && dataSources.length > 0) {
loadMultipleDataSources();
}
- }, [element?.dataSources, element?.chartConfig?.dataSources, loadMultipleDataSources]);
+ }, [dataSources, loadMultipleDataSources]);
+
+ // 자동 새로고침
+ useEffect(() => {
+ if (!dataSources || dataSources.length === 0) return;
+
+ const intervals = dataSources
+ .map((ds) => ds.refreshInterval)
+ .filter((interval): interval is number => typeof interval === "number" && interval > 0);
+
+ if (intervals.length === 0) return;
+
+ const minInterval = Math.min(...intervals);
+ console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`);
+
+ const intervalId = setInterval(() => {
+ console.log("🔄 자동 새로고침 실행");
+ loadMultipleDataSources();
+ }, minInterval * 1000);
+
+ return () => {
+ console.log("⏹️ 자동 새로고침 정리");
+ clearInterval(intervalId);
+ };
+ }, [dataSources, loadMultipleDataSources]);
// 페이지네이션
const pageSize = config.pageSize || 10;
@@ -290,10 +333,27 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
{element?.customTitle || "리스트 테스트 (다중 데이터 소스)"}
- {(element?.dataSources || element?.chartConfig?.dataSources)?.length || 0}개 데이터 소스 연결됨
+ {dataSources?.length || 0}개 데이터 소스 • {data?.totalRows || 0}개 행
+ {lastRefreshTime && (
+
+ • {lastRefreshTime.toLocaleTimeString("ko-KR")}
+
+ )}
- {isLoading && }
+
+
+ {isLoading && }
+
{/* 컨텐츠 */}
diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx
index e684f9e1..349cb9f3 100644
--- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx
+++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx
@@ -3,7 +3,8 @@
import React, { useEffect, useState, useCallback, useMemo } from "react";
import dynamic from "next/dynamic";
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
-import { Loader2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Loader2, RefreshCw } from "lucide-react";
import "leaflet/dist/leaflet.css";
// Leaflet 아이콘 경로 설정 (엑박 방지)
@@ -60,6 +61,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [geoJsonData, setGeoJsonData] = useState(null);
+ const [lastRefreshTime, setLastRefreshTime] = useState(null);
console.log("🧪 MapTestWidgetV2 렌더링!", element);
console.log("📍 마커:", markers.length, "🔷 폴리곤:", polygons.length);
@@ -136,6 +138,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
setMarkers(allMarkers);
setPolygons(allPolygons);
+ setLastRefreshTime(new Date());
} catch (err: any) {
console.error("❌ 데이터 로딩 중 오류:", err);
setError(err.message);
@@ -144,6 +147,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
}
}, [dataSources]);
+ // 수동 새로고침 핸들러
+ const handleManualRefresh = useCallback(() => {
+ console.log("🔄 수동 새로고침 버튼 클릭");
+ loadMultipleDataSources();
+ }, [loadMultipleDataSources]);
+
// REST API 데이터 로딩
const loadRestApiData = async (source: ChartDataSource): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => {
console.log(`🌐 REST API 데이터 로딩 시작:`, source.name, `mapDisplayType:`, source.mapDisplayType);
@@ -263,11 +272,47 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
return convertToMapData(rows, source.name || source.id || "Database", source.mapDisplayType);
};
+ // XML 데이터 파싱 (UTIC API 등)
+ const parseXmlData = (xmlText: string): any[] => {
+ try {
+ console.log(" 📄 XML 파싱 시작");
+ const parser = new DOMParser();
+ const xmlDoc = parser.parseFromString(xmlText, "text/xml");
+
+ const records = xmlDoc.getElementsByTagName("record");
+ const results: any[] = [];
+
+ for (let i = 0; i < records.length; i++) {
+ const record = records[i];
+ const obj: any = {};
+
+ for (let j = 0; j < record.children.length; j++) {
+ const child = record.children[j];
+ obj[child.tagName] = child.textContent || "";
+ }
+
+ results.push(obj);
+ }
+
+ console.log(` ✅ XML 파싱 완료: ${results.length}개 레코드`);
+ return results;
+ } catch (error) {
+ console.error(" ❌ XML 파싱 실패:", error);
+ return [];
+ }
+ };
+
// 텍스트 데이터 파싱 (CSV, 기상청 형식 등)
const parseTextData = (text: string): any[] => {
try {
console.log(" 🔍 원본 텍스트 (처음 500자):", text.substring(0, 500));
+ // XML 형식 감지
+ if (text.trim().startsWith("")) {
+ console.log(" 📄 XML 형식 데이터 감지");
+ return parseXmlData(text);
+ }
+
const lines = text.split('\n').filter(line => {
const trimmed = line.trim();
return trimmed &&
@@ -382,8 +427,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
}
// 마커 데이터 처리 (위도/경도가 있는 경우)
- let lat = row.lat || row.latitude || row.y;
- let lng = row.lng || row.longitude || row.x;
+ let lat = row.lat || row.latitude || row.y || row.locationDataY;
+ let lng = row.lng || row.longitude || row.x || row.locationDataX;
// 위도/경도가 없으면 지역 코드/지역명으로 변환 시도
if ((lat === undefined || lng === undefined) && (row.code || row.areaCode || row.regionCode || row.tmFc || row.stnId)) {
@@ -715,6 +760,31 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
}
}, [dataSources, loadMultipleDataSources]);
+ // 자동 새로고침
+ useEffect(() => {
+ if (!dataSources || dataSources.length === 0) return;
+
+ // 모든 데이터 소스 중 가장 짧은 refreshInterval 찾기
+ const intervals = dataSources
+ .map((ds) => ds.refreshInterval)
+ .filter((interval): interval is number => typeof interval === "number" && interval > 0);
+
+ if (intervals.length === 0) return;
+
+ const minInterval = Math.min(...intervals);
+ console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`);
+
+ const intervalId = setInterval(() => {
+ console.log("🔄 자동 새로고침 실행");
+ loadMultipleDataSources();
+ }, minInterval * 1000);
+
+ return () => {
+ console.log("⏹️ 자동 새로고침 정리");
+ clearInterval(intervalId);
+ };
+ }, [dataSources, loadMultipleDataSources]);
+
// 타일맵 URL (chartConfig에서 가져오기)
const tileMapUrl = element?.chartConfig?.tileMapUrl ||
`https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`;
@@ -737,9 +807,26 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
{element?.dataSources?.length || 0}개 데이터 소스 연결됨
+ {lastRefreshTime && (
+
+ • 마지막 업데이트: {lastRefreshTime.toLocaleTimeString("ko-KR")}
+
+ )}
- {loading && }
+
+
+ {loading && }
+
{/* 지도 */}
@@ -769,19 +856,22 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
{/* 폴리곤 렌더링 */}
{/* GeoJSON 렌더링 (육지 지역 경계선) */}
- {geoJsonData && polygons.length > 0 && (
+ {(() => {
+ console.log(`🗺️ GeoJSON 렌더링 조건 체크:`, {
+ geoJsonData: !!geoJsonData,
+ polygonsLength: polygons.length,
+ polygonNames: polygons.map(p => p.name),
+ });
+ return null;
+ })()}
+ {geoJsonData && polygons.length > 0 ? (
p.id))} // 폴리곤 변경 시 재렌더링
data={geoJsonData}
style={(feature: any) => {
const ctpName = feature?.properties?.CTP_KOR_NM; // 시/도명 (예: 경상북도)
const sigName = feature?.properties?.SIG_KOR_NM; // 시/군/구명 (예: 군위군)
- // 🔍 디버그: GeoJSON 속성 확인
- if (ctpName === "경상북도" || sigName?.includes("군위") || sigName?.includes("영천")) {
- console.log(`🔍 GeoJSON 속성:`, { ctpName, sigName, properties: feature?.properties });
- console.log(`🔍 매칭 시도할 폴리곤:`, polygons.map(p => p.name));
- }
-
// 폴리곤 매칭 (시/군/구명 우선, 없으면 시/도명)
const matchingPolygon = polygons.find(p => {
if (!p.name) return false;
@@ -859,6 +949,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
}
}}
/>
+ ) : (
+ <>{console.log(`⚠️ GeoJSON 렌더링 안 됨: geoJsonData=${!!geoJsonData}, polygons=${polygons.length}`)}>
)}
{/* 폴리곤 렌더링 (해상 구역만) */}
@@ -902,21 +994,79 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
key={marker.id}
position={[marker.lat, marker.lng]}
>
-
-
-
{marker.name}
- {marker.source && (
-
- 출처: {marker.source}
+
+
+ {/* 제목 */}
+
+
{marker.name}
+ {marker.source && (
+
+ 📡 {marker.source}
+
+ )}
+
+
+ {/* 상세 정보 */}
+
+ {marker.description && (
+
+
상세 정보
+
+ {(() => {
+ try {
+ const parsed = JSON.parse(marker.description);
+ return (
+
+ {parsed.incidenteTypeCd === "1" && (
+
🚨 교통사고
+ )}
+ {parsed.incidenteTypeCd === "2" && (
+
🚧 도로공사
+ )}
+ {parsed.addressJibun && (
+
📍 {parsed.addressJibun}
+ )}
+ {parsed.addressNew && parsed.addressNew !== parsed.addressJibun && (
+
📍 {parsed.addressNew}
+ )}
+ {parsed.roadName && (
+
🛣️ {parsed.roadName}
+ )}
+ {parsed.linkName && (
+
🔗 {parsed.linkName}
+ )}
+ {parsed.incidentMsg && (
+
💬 {parsed.incidentMsg}
+ )}
+ {parsed.eventContent && (
+
📝 {parsed.eventContent}
+ )}
+ {parsed.startDate && (
+
🕐 {parsed.startDate}
+ )}
+ {parsed.endDate && (
+
🕐 종료: {parsed.endDate}
+ )}
+
+ );
+ } catch {
+ return marker.description;
+ }
+ })()}
+
+
+ )}
+
+ {marker.status && (
+
+ 상태: {marker.status}
+
+ )}
+
+ {/* 좌표 */}
+
+ 📍 {marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
- )}
- {marker.status && (
-
- 상태: {marker.status}
-
- )}
-
- {marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
diff --git a/frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx b/frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx
new file mode 100644
index 00000000..0a39a8b1
--- /dev/null
+++ b/frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx
@@ -0,0 +1,586 @@
+"use client";
+
+import React, { useState, useEffect, useCallback, useMemo } from "react";
+import { Card } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { RefreshCw, AlertTriangle, Cloud, Construction, Database as DatabaseIcon } from "lucide-react";
+import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
+
+type AlertType = "accident" | "weather" | "construction" | "system" | "security" | "other";
+
+interface Alert {
+ id: string;
+ type: AlertType;
+ severity: "high" | "medium" | "low";
+ title: string;
+ location?: string;
+ description: string;
+ timestamp: string;
+ source?: string;
+}
+
+interface RiskAlertTestWidgetProps {
+ element: DashboardElement;
+}
+
+export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProps) {
+ const [alerts, setAlerts] = useState
([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [filter, setFilter] = useState("all");
+ const [lastRefreshTime, setLastRefreshTime] = useState(null);
+
+ const dataSources = useMemo(() => {
+ return element?.dataSources || element?.chartConfig?.dataSources;
+ }, [element?.dataSources, element?.chartConfig?.dataSources]);
+
+ const parseTextData = (text: string): any[] => {
+ // XML 형식 감지
+ if (text.trim().startsWith("")) {
+ console.log("📄 XML 형식 데이터 감지");
+ return parseXmlData(text);
+ }
+
+ // CSV 형식 (기상청 특보)
+ console.log("📄 CSV 형식 데이터 감지");
+ const lines = text.split("\n").filter((line) => {
+ const trimmed = line.trim();
+ return trimmed && !trimmed.startsWith("#") && trimmed !== "=";
+ });
+
+ return lines.map((line) => {
+ const values = line.split(",");
+ const obj: any = {};
+
+ if (values.length >= 11) {
+ obj.code = values[0];
+ obj.region = values[1];
+ obj.subCode = values[2];
+ obj.subRegion = values[3];
+ obj.tmFc = values[4];
+ obj.tmEf = values[5];
+ obj.warning = values[6];
+ obj.level = values[7];
+ obj.status = values[8];
+ obj.period = values[9];
+ obj.name = obj.subRegion || obj.region || obj.code;
+ } else {
+ values.forEach((value, index) => {
+ obj[`field_${index}`] = value;
+ });
+ }
+
+ return obj;
+ });
+ };
+
+ const parseXmlData = (xmlText: string): any[] => {
+ try {
+ // 간단한 XML 파싱 (DOMParser 사용)
+ const parser = new DOMParser();
+ const xmlDoc = parser.parseFromString(xmlText, "text/xml");
+
+ const records = xmlDoc.getElementsByTagName("record");
+ const results: any[] = [];
+
+ for (let i = 0; i < records.length; i++) {
+ const record = records[i];
+ const obj: any = {};
+
+ // 모든 자식 노드를 객체로 변환
+ for (let j = 0; j < record.children.length; j++) {
+ const child = record.children[j];
+ obj[child.tagName] = child.textContent || "";
+ }
+
+ results.push(obj);
+ }
+
+ console.log(`✅ XML 파싱 완료: ${results.length}개 레코드`);
+ return results;
+ } catch (error) {
+ console.error("❌ XML 파싱 실패:", error);
+ return [];
+ }
+ };
+
+ const loadRestApiData = useCallback(async (source: ChartDataSource) => {
+ if (!source.endpoint) {
+ throw new Error("API endpoint가 없습니다.");
+ }
+
+ // 쿼리 파라미터 처리
+ const queryParamsObj: Record = {};
+ if (source.queryParams && Array.isArray(source.queryParams)) {
+ source.queryParams.forEach((param) => {
+ if (param.key && param.value) {
+ queryParamsObj[param.key] = param.value;
+ }
+ });
+ }
+
+ // 헤더 처리
+ const headersObj: Record = {};
+ if (source.headers && Array.isArray(source.headers)) {
+ source.headers.forEach((header) => {
+ if (header.key && header.value) {
+ headersObj[header.key] = header.value;
+ }
+ });
+ }
+
+ console.log("🌐 API 호출 준비:", {
+ endpoint: source.endpoint,
+ queryParams: queryParamsObj,
+ headers: headersObj,
+ });
+ console.log("🔍 원본 source.queryParams:", source.queryParams);
+ console.log("🔍 원본 source.headers:", source.headers);
+
+ const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ url: source.endpoint,
+ method: "GET",
+ headers: headersObj,
+ queryParams: queryParamsObj,
+ }),
+ });
+
+ console.log("🌐 API 응답 상태:", response.status);
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+
+ const result = await response.json();
+ if (!result.success) {
+ throw new Error(result.message || "API 호출 실패");
+ }
+
+ let apiData = result.data;
+
+ console.log("🔍 API 응답 데이터 타입:", typeof apiData);
+ console.log("🔍 API 응답 데이터 (처음 500자):", typeof apiData === "string" ? apiData.substring(0, 500) : JSON.stringify(apiData).substring(0, 500));
+
+ // 백엔드가 {text: "XML..."} 형태로 감싼 경우 처리
+ if (apiData && typeof apiData === "object" && apiData.text && typeof apiData.text === "string") {
+ console.log("📦 백엔드가 text 필드로 감싼 데이터 감지");
+ apiData = parseTextData(apiData.text);
+ console.log("✅ 파싱 성공:", apiData.length, "개 행");
+ } else if (typeof apiData === "string") {
+ console.log("📄 텍스트 형식 데이터 감지, 파싱 시도");
+ apiData = parseTextData(apiData);
+ console.log("✅ 파싱 성공:", apiData.length, "개 행");
+ } else if (Array.isArray(apiData)) {
+ console.log("✅ 이미 배열 형태의 데이터입니다.");
+ } else {
+ console.log("⚠️ 예상치 못한 데이터 형식입니다. 배열로 변환 시도.");
+ apiData = [apiData];
+ }
+
+ // JSON Path 적용
+ if (source.jsonPath && typeof apiData === "object" && !Array.isArray(apiData)) {
+ const paths = source.jsonPath.split(".");
+ for (const path of paths) {
+ if (apiData && typeof apiData === "object" && path in apiData) {
+ apiData = apiData[path];
+ }
+ }
+ }
+
+ const rows = Array.isArray(apiData) ? apiData : [apiData];
+ return convertToAlerts(rows, source.name || source.id || "API");
+ }, []);
+
+ const loadDatabaseData = useCallback(async (source: ChartDataSource) => {
+ if (!source.query) {
+ throw new Error("SQL 쿼리가 없습니다.");
+ }
+
+ if (source.connectionType === "external" && source.externalConnectionId) {
+ const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
+ const externalResult = await ExternalDbConnectionAPI.executeQuery(
+ parseInt(source.externalConnectionId),
+ source.query
+ );
+ if (!externalResult.success || !externalResult.data) {
+ throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패");
+ }
+ const resultData = externalResult.data as unknown as { rows: Record[] };
+ return convertToAlerts(resultData.rows, source.name || source.id || "Database");
+ } else {
+ const { dashboardApi } = await import("@/lib/api/dashboard");
+ const result = await dashboardApi.executeQuery(source.query);
+ return convertToAlerts(result.rows, source.name || source.id || "Database");
+ }
+ }, []);
+
+ const convertToAlerts = useCallback((rows: any[], sourceName: string): Alert[] => {
+ console.log("🔄 convertToAlerts 호출:", rows.length, "개 행");
+
+ return rows.map((row: any, index: number) => {
+ // 타입 결정 (UTIC XML 기준)
+ let type: AlertType = "other";
+
+ // incidenteTypeCd: 1=사고, 2=공사, 3=행사, 4=기타
+ if (row.incidenteTypeCd) {
+ const typeCode = String(row.incidenteTypeCd);
+ if (typeCode === "1") {
+ type = "accident";
+ } else if (typeCode === "2") {
+ type = "construction";
+ }
+ }
+ // 기상 특보 데이터 (warning 필드가 있으면 무조건 날씨)
+ else if (row.warning) {
+ type = "weather";
+ }
+ // 일반 데이터
+ else if (row.type || row.타입 || row.alert_type) {
+ type = (row.type || row.타입 || row.alert_type) as AlertType;
+ }
+
+ // 심각도 결정
+ let severity: "high" | "medium" | "low" = "medium";
+
+ if (type === "accident") {
+ severity = "high"; // 사고는 항상 높음
+ } else if (type === "construction") {
+ severity = "medium"; // 공사는 중간
+ } else if (row.level === "경보") {
+ severity = "high";
+ } else if (row.level === "주의" || row.level === "주의보") {
+ severity = "medium";
+ } else if (row.severity || row.심각도 || row.priority) {
+ severity = (row.severity || row.심각도 || row.priority) as "high" | "medium" | "low";
+ }
+
+ // 제목 생성 (UTIC XML 기준)
+ let title = "";
+
+ if (type === "accident") {
+ // incidenteSubTypeCd: 1=추돌, 2=접촉, 3=전복, 4=추락, 5=화재, 6=침수, 7=기타
+ const subType = row.incidenteSubTypeCd;
+ const subTypeMap: { [key: string]: string } = {
+ "1": "추돌사고", "2": "접촉사고", "3": "전복사고",
+ "4": "추락사고", "5": "화재사고", "6": "침수사고", "7": "기타사고"
+ };
+ title = subTypeMap[String(subType)] || "교통사고";
+ } else if (type === "construction") {
+ title = "도로공사";
+ } else if (type === "weather" && row.warning && row.level) {
+ // 날씨 특보: 공백 제거
+ const warning = String(row.warning).trim();
+ const level = String(row.level).trim();
+ title = `${warning} ${level}`;
+ } else {
+ title = row.title || row.제목 || row.name || "알림";
+ }
+
+ // 위치 정보 (UTIC XML 기준) - 공백 제거
+ let location = row.addressJibun || row.addressNew ||
+ row.roadName || row.linkName ||
+ row.subRegion || row.region ||
+ row.location || row.위치 || undefined;
+
+ if (location && typeof location === "string") {
+ location = location.trim();
+ }
+
+ // 설명 생성 (간결하게)
+ let description = "";
+
+ if (row.incidentMsg) {
+ description = row.incidentMsg;
+ } else if (row.eventContent) {
+ description = row.eventContent;
+ } else if (row.period) {
+ description = `발효 기간: ${row.period}`;
+ } else if (row.description || row.설명 || row.content) {
+ description = row.description || row.설명 || row.content;
+ } else {
+ // 설명이 없으면 위치 정보만 표시
+ description = location || "상세 정보 없음";
+ }
+
+ // 타임스탬프
+ const timestamp = row.startDate || row.eventDate ||
+ row.tmFc || row.tmEf ||
+ row.timestamp || row.created_at ||
+ new Date().toISOString();
+
+ const alert: Alert = {
+ id: row.id || row.alert_id || row.incidentId || row.eventId ||
+ row.code || row.subCode || `${sourceName}-${index}-${Date.now()}`,
+ type,
+ severity,
+ title,
+ location,
+ description,
+ timestamp,
+ source: sourceName,
+ };
+
+ console.log(` ✅ Alert ${index}:`, alert);
+ return alert;
+ });
+ }, []);
+
+ const loadMultipleDataSources = useCallback(async () => {
+ if (!dataSources || dataSources.length === 0) {
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+
+ console.log("🔄 RiskAlertTestWidget 데이터 로딩 시작:", dataSources.length, "개 소스");
+
+ try {
+ const results = await Promise.allSettled(
+ dataSources.map(async (source, index) => {
+ console.log(`📡 데이터 소스 ${index + 1} 로딩 중:`, source.name, source.type);
+ if (source.type === "api") {
+ const alerts = await loadRestApiData(source);
+ console.log(`✅ 데이터 소스 ${index + 1} 완료:`, alerts.length, "개 알림");
+ return alerts;
+ } else {
+ const alerts = await loadDatabaseData(source);
+ console.log(`✅ 데이터 소스 ${index + 1} 완료:`, alerts.length, "개 알림");
+ return alerts;
+ }
+ })
+ );
+
+ const allAlerts: Alert[] = [];
+ results.forEach((result, index) => {
+ if (result.status === "fulfilled") {
+ console.log(`✅ 결과 ${index + 1} 병합:`, result.value.length, "개 알림");
+ allAlerts.push(...result.value);
+ } else {
+ console.error(`❌ 결과 ${index + 1} 실패:`, result.reason);
+ }
+ });
+
+ console.log("✅ 총", allAlerts.length, "개 알림 로딩 완료");
+ allAlerts.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
+ setAlerts(allAlerts);
+ setLastRefreshTime(new Date());
+ } catch (err: any) {
+ console.error("❌ 데이터 로딩 실패:", err);
+ setError(err.message || "데이터 로딩 실패");
+ } finally {
+ setLoading(false);
+ }
+ }, [dataSources, loadRestApiData, loadDatabaseData]);
+
+ // 수동 새로고침 핸들러
+ const handleManualRefresh = useCallback(() => {
+ console.log("🔄 수동 새로고침 버튼 클릭");
+ loadMultipleDataSources();
+ }, [loadMultipleDataSources]);
+
+ // 초기 로드
+ useEffect(() => {
+ if (dataSources && dataSources.length > 0) {
+ loadMultipleDataSources();
+ }
+ }, [dataSources, loadMultipleDataSources]);
+
+ // 자동 새로고침
+ useEffect(() => {
+ if (!dataSources || dataSources.length === 0) return;
+
+ // 모든 데이터 소스 중 가장 짧은 refreshInterval 찾기
+ const intervals = dataSources
+ .map((ds) => ds.refreshInterval)
+ .filter((interval): interval is number => typeof interval === "number" && interval > 0);
+
+ if (intervals.length === 0) return;
+
+ const minInterval = Math.min(...intervals);
+ console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`);
+
+ const intervalId = setInterval(() => {
+ console.log("🔄 자동 새로고침 실행");
+ loadMultipleDataSources();
+ }, minInterval * 1000);
+
+ return () => {
+ console.log("⏹️ 자동 새로고침 정리");
+ clearInterval(intervalId);
+ };
+ }, [dataSources, loadMultipleDataSources]);
+
+ const getTypeIcon = (type: AlertType) => {
+ switch (type) {
+ case "accident": return ;
+ case "weather": return ;
+ case "construction": return ;
+ default: return ;
+ }
+ };
+
+ const getSeverityColor = (severity: "high" | "medium" | "low") => {
+ switch (severity) {
+ case "high": return "bg-red-500";
+ case "medium": return "bg-yellow-500";
+ case "low": return "bg-blue-500";
+ }
+ };
+
+ const filteredAlerts = filter === "all" ? alerts : alerts.filter(a => a.type === filter);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
⚠️ {error}
+
+
+
+ );
+ }
+
+ if (!dataSources || dataSources.length === 0) {
+ return (
+
+
+
🚨
+
🧪 리스크/알림 테스트 위젯
+
+
다중 데이터 소스 지원
+
+ - • 여러 REST API 동시 연결
+ - • 여러 Database 동시 연결
+ - • REST API + Database 혼합 가능
+ - • 알림 타입별 필터링
+
+
+
+
⚙️ 설정 방법
+
데이터 소스를 추가하고 저장하세요
+
+
+
+ );
+ }
+
+ return (
+
+ {/* 헤더 */}
+
+
+
+ {element?.customTitle || "리스크/알림 테스트"}
+
+
+ {dataSources?.length || 0}개 데이터 소스 • {alerts.length}개 알림
+ {lastRefreshTime && (
+
+ • {lastRefreshTime.toLocaleTimeString("ko-KR")}
+
+ )}
+
+
+
+
+
+ {/* 컨텐츠 */}
+
+
+
+ {["accident", "weather", "construction"].map((type) => {
+ const count = alerts.filter(a => a.type === type).length;
+ return (
+
+ );
+ })}
+
+
+
+ {filteredAlerts.length === 0 ? (
+
+ ) : (
+ filteredAlerts.map((alert) => (
+
+
+
+ {getTypeIcon(alert.type)}
+
+
+
+
{alert.title}
+
+ {alert.severity === "high" && "긴급"}
+ {alert.severity === "medium" && "주의"}
+ {alert.severity === "low" && "정보"}
+
+
+ {alert.location && (
+
📍 {alert.location}
+ )}
+
{alert.description}
+
+ {new Date(alert.timestamp).toLocaleString("ko-KR")}
+ {alert.source && · {alert.source}}
+
+
+
+
+ ))
+ )}
+
+
+
+ );
+}
+
diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs
index 5fde5ccb..ca804adc 100644
--- a/frontend/next.config.mjs
+++ b/frontend/next.config.mjs
@@ -23,7 +23,7 @@ const nextConfig = {
return [
{
source: "/api/:path*",
- destination: "http://host.docker.internal:8080/api/:path*",
+ destination: "http://localhost:8080/api/:path*",
},
];
},
From 71beae8e245dbdec35fec7048fd82ae391d9e588 Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Tue, 28 Oct 2025 13:42:23 +0900
Subject: [PATCH 05/20] =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend-node/src/services/batchService.ts | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/backend-node/src/services/batchService.ts b/backend-node/src/services/batchService.ts
index 07cdd61c..247b1ab8 100644
--- a/backend-node/src/services/batchService.ts
+++ b/backend-node/src/services/batchService.ts
@@ -449,7 +449,10 @@ export class BatchService {
// 기존 배치 설정 확인 (회사 권한 체크 포함)
const existing = await this.getBatchConfigById(id, userCompanyCode);
if (!existing.success) {
- return existing as ApiResponse;
+ return {
+ success: false,
+ message: existing.message,
+ };
}
const existingConfig = await queryOne(
From 28ecc311283491e3a6f1d2f69808dd76d8cc8160 Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Tue, 28 Oct 2025 15:02:37 +0900
Subject: [PATCH 06/20] =?UTF-8?q?=EC=9C=84=EC=A0=AF=EC=9D=98=20=EC=B5=9C?=
=?UTF-8?q?=EC=86=8C=20=ED=81=AC=EA=B8=B0=EB=A5=BC=201x1=20=EB=A1=9C=20?=
=?UTF-8?q?=EB=B3=80=EA=B2=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../admin/dashboard/CanvasElement.tsx | 6 +++---
.../admin/dashboard/charts/ChartRenderer.tsx | 12 +++++------
.../admin/dashboard/charts/PieChart.tsx | 2 +-
.../admin/dashboard/widgets/AnalogClock.tsx | 18 ++++++++---------
.../admin/dashboard/widgets/DigitalClock.tsx | 8 ++++----
.../dashboard/widgets/CustomMetricWidget.tsx | 20 ++++++++++---------
6 files changed, 34 insertions(+), 32 deletions(-)
diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx
index 33b1d801..fbdbfc73 100644
--- a/frontend/components/admin/dashboard/CanvasElement.tsx
+++ b/frontend/components/admin/dashboard/CanvasElement.tsx
@@ -353,9 +353,9 @@ export function CanvasElement({
let newX = resizeStart.elementX;
let newY = resizeStart.elementY;
- // 최소 크기 설정: 달력은 2x3, 나머지는 2x2
- const minWidthCells = 2;
- const minHeightCells = element.type === "widget" && element.subtype === "calendar" ? 3 : 2;
+ // 최소 크기 설정: 모든 위젯 1x1
+ const minWidthCells = 1;
+ const minHeightCells = 1;
const minWidth = cellSize * minWidthCells;
const minHeight = cellSize * minHeightCells;
diff --git a/frontend/components/admin/dashboard/charts/ChartRenderer.tsx b/frontend/components/admin/dashboard/charts/ChartRenderer.tsx
index 94efd190..3cd5afbe 100644
--- a/frontend/components/admin/dashboard/charts/ChartRenderer.tsx
+++ b/frontend/components/admin/dashboard/charts/ChartRenderer.tsx
@@ -242,12 +242,12 @@ export function ChartRenderer({ element, data, width, height = 200 }: ChartRende
// D3 차트 렌더링
const actualWidth = width !== undefined ? width : containerWidth;
- // 원형 차트는 더 큰 크기가 필요 (최소 400px)
+ // 최소 크기 제약 완화 (1x1 위젯 지원)
const isCircularChart = element.subtype === "pie" || element.subtype === "donut";
- const minWidth = isCircularChart ? 400 : 200;
- const finalWidth = Math.max(actualWidth - 20, minWidth);
- // 원형 차트는 범례 공간을 위해 더 많은 여백 필요
- const finalHeight = Math.max(height - (isCircularChart ? 60 : 20), 300);
+ const minWidth = 35; // 최소 너비 35px
+ const finalWidth = Math.max(actualWidth - 4, minWidth);
+ // 최소 높이도 35px로 설정
+ const finalHeight = Math.max(height - (isCircularChart ? 10 : 4), 35);
console.log("🎨 ChartRenderer:", {
elementSubtype: element.subtype,
@@ -263,7 +263,7 @@ export function ChartRenderer({ element, data, width, height = 200 }: ChartRende
});
return (
-
+
-