diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index dd3d08ce..363e2cba 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -78,6 +78,16 @@ const ChartTestWidget = dynamic(() => import("@/components/dashboard/widgets/Cha loading: () =>
로딩 중...
, }); +const ListTestWidget = dynamic(() => import("@/components/dashboard/widgets/ListTestWidget").then((mod) => ({ default: mod.ListTestWidget })), { + ssr: false, + loading: () =>
로딩 중...
, +}); + +const CustomMetricTestWidget = dynamic(() => import("@/components/dashboard/widgets/CustomMetricTestWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + // 범용 상태 요약 위젯 (차량, 배송 등 모든 상태 위젯 통합) const StatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/StatusSummaryWidget"), { ssr: false, @@ -884,6 +894,16 @@ export function CanvasElement({
+ ) : element.type === "widget" && element.subtype === "list-test" ? ( + // 🧪 테스트용 리스트 위젯 (다중 데이터 소스) +
+ +
+ ) : element.type === "widget" && element.subtype === "custom-metric-test" ? ( + // 🧪 테스트용 커스텀 메트릭 위젯 (다중 데이터 소스) +
+ +
) : element.type === "widget" && element.subtype === "vehicle-map" ? ( // 차량 위치 지도 위젯 렌더링 (구버전 - 호환용)
diff --git a/frontend/components/admin/dashboard/DashboardTopMenu.tsx b/frontend/components/admin/dashboard/DashboardTopMenu.tsx index 283f0918..cc2668cb 100644 --- a/frontend/components/admin/dashboard/DashboardTopMenu.tsx +++ b/frontend/components/admin/dashboard/DashboardTopMenu.tsx @@ -185,6 +185,8 @@ export function DashboardTopMenu({ 🧪 테스트 위젯 (다중 데이터 소스) 🧪 지도 테스트 V2 🧪 차트 테스트 + 🧪 리스트 테스트 + 🧪 커스텀 메트릭 테스트 데이터 위젯 diff --git a/frontend/components/admin/dashboard/ElementConfigSidebar.tsx b/frontend/components/admin/dashboard/ElementConfigSidebar.tsx index c0d02ddb..1abe263d 100644 --- a/frontend/components/admin/dashboard/ElementConfigSidebar.tsx +++ b/frontend/components/admin/dashboard/ElementConfigSidebar.tsx @@ -222,7 +222,11 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem (element.subtype === "clock" || element.subtype === "calendar" || isSelfContainedWidget); // 다중 데이터 소스 테스트 위젯 - const isMultiDataSourceWidget = element.subtype === "map-test-v2" || element.subtype === "chart-test"; + const isMultiDataSourceWidget = + element.subtype === "map-test-v2" || + element.subtype === "chart-test" || + element.subtype === "list-test" || + element.subtype === "custom-metric-test"; // 저장 가능 여부 확인 const isPieChart = element.subtype === "pie" || element.subtype === "donut"; diff --git a/frontend/components/admin/dashboard/data-sources/MultiDataSourceConfig.tsx b/frontend/components/admin/dashboard/data-sources/MultiDataSourceConfig.tsx index 2d92836e..e0b39220 100644 --- a/frontend/components/admin/dashboard/data-sources/MultiDataSourceConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/MultiDataSourceConfig.tsx @@ -4,10 +4,16 @@ 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 { Plus, Trash2, Database, Globe } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import MultiApiConfig from "./MultiApiConfig"; import MultiDatabaseConfig from "./MultiDatabaseConfig"; @@ -25,18 +31,20 @@ export default function MultiDataSourceConfig({ ); const [previewData, setPreviewData] = useState([]); const [showPreview, setShowPreview] = useState(false); + const [showAddMenu, setShowAddMenu] = useState(false); - // 새 데이터 소스 추가 - const handleAddDataSource = () => { + // 새 데이터 소스 추가 (타입 지정) + const handleAddDataSource = (type: "api" | "database") => { const newId = Date.now().toString(); const newSource: ChartDataSource = { id: newId, - name: `데이터 소스 ${dataSources.length + 1}`, - type: "api", + name: `${type === "api" ? "REST API" : "Database"} ${dataSources.length + 1}`, + type, }; onChange([...dataSources, newSource]); setActiveTab(newId); + setShowAddMenu(false); }; // 데이터 소스 삭제 diff --git a/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx b/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx index 63af568d..cf1efaa5 100644 --- a/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx @@ -65,28 +65,35 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab 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; + // dashboardApi 사용 (인증 토큰 자동 포함) + const { dashboardApi } = await import("@/lib/api/dashboard"); + + if (dataSource.connectionType === "external" && dataSource.externalConnectionId) { + // 외부 DB + const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection"); + const result = await ExternalDbConnectionAPI.executeQuery( + parseInt(dataSource.externalConnectionId), + dataSource.query + ); + + if (result.success && result.data) { + const rowCount = Array.isArray(result.data.rows) ? result.data.rows.length : 0; + setTestResult({ + success: true, + message: "쿼리 실행 성공", + rowCount, + }); + } else { + setTestResult({ success: false, message: result.message || "쿼리 실행 실패" }); + } + } else { + // 현재 DB + const result = await dashboardApi.executeQuery(dataSource.query); setTestResult({ success: true, message: "쿼리 실행 성공", - rowCount, + rowCount: result.rowCount || 0, }); - } else { - setTestResult({ success: false, message: result.message || "쿼리 실행 실패" }); } } catch (error: any) { setTestResult({ success: false, message: error.message || "네트워크 오류" }); diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index e30995bc..ed615762 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -26,6 +26,8 @@ export type ElementSubtype = | "map-test" // 🧪 지도 테스트 위젯 (REST API 지원) | "map-test-v2" // 🧪 지도 테스트 V2 (다중 데이터 소스) | "chart-test" // 🧪 차트 테스트 (다중 데이터 소스) + | "list-test" // 🧪 리스트 테스트 (다중 데이터 소스) + | "custom-metric-test" // 🧪 커스텀 메트릭 테스트 (다중 데이터 소스) | "delivery-status" | "status-summary" // 범용 상태 카드 (통합) // | "list-summary" // 범용 목록 카드 (다른 분 작업 중 - 임시 주석) @@ -153,6 +155,9 @@ export interface ChartDataSource { } export interface ChartConfig { + // 다중 데이터 소스 (테스트 위젯용) + dataSources?: ChartDataSource[]; // 여러 데이터 소스 (REST API + Database 혼합 가능) + // 축 매핑 xAxis?: string; // X축 필드명 yAxis?: string | string[]; // Y축 필드명 (다중 가능) diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index 52cdee88..c4878e0a 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -12,6 +12,8 @@ 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 CustomMetricTestWidget = dynamic(() => import("./widgets/CustomMetricTestWidget"), { 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 }); @@ -85,6 +87,10 @@ function renderWidget(element: DashboardElement) { return ; case "chart-test": return ; + case "list-test": + return ; + case "custom-metric-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 3a27d039..b445c48e 100644 --- a/frontend/components/dashboard/widgets/ChartTestWidget.tsx +++ b/frontend/components/dashboard/widgets/ChartTestWidget.tsx @@ -34,7 +34,8 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) { // 다중 데이터 소스 로딩 const loadMultipleDataSources = useCallback(async () => { - const dataSources = element?.dataSources; + // dataSources는 element.dataSources 또는 chartConfig.dataSources에서 로드 + const dataSources = element?.dataSources || element?.chartConfig?.dataSources; if (!dataSources || dataSources.length === 0) { console.log("⚠️ 데이터 소스가 없습니다."); @@ -174,10 +175,11 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) { }; useEffect(() => { - if (element?.dataSources && element.dataSources.length > 0) { + const dataSources = element?.dataSources || element?.chartConfig?.dataSources; + if (dataSources && dataSources.length > 0) { loadMultipleDataSources(); } - }, [element?.dataSources, loadMultipleDataSources]); + }, [element?.dataSources, element?.chartConfig?.dataSources, loadMultipleDataSources]); const chartType = element?.subtype || "line"; const chartConfig = element?.chartConfig || {}; @@ -265,7 +267,7 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) { {element?.customTitle || "차트 테스트 (다중 데이터 소스)"}

- {element?.dataSources?.length || 0}개 데이터 소스 연결됨 + {(element?.dataSources || element?.chartConfig?.dataSources)?.length || 0}개 데이터 소스 연결됨

{loading && } @@ -276,7 +278,7 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {

{error}

- ) : !element?.dataSources || element.dataSources.length === 0 ? ( + ) : !(element?.dataSources || element?.chartConfig?.dataSources) || (element?.dataSources || element?.chartConfig?.dataSources)?.length === 0 ? (

데이터 소스를 연결해주세요 diff --git a/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx new file mode 100644 index 00000000..b0f9122c --- /dev/null +++ b/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx @@ -0,0 +1,283 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types"; +import { Loader2 } from "lucide-react"; + +interface CustomMetricTestWidgetProps { + element: DashboardElement; +} + +// 집계 함수 실행 +const calculateMetric = (rows: any[], field: string, aggregation: string): number => { + if (rows.length === 0) return 0; + + switch (aggregation) { + case "count": + return rows.length; + case "sum": { + return rows.reduce((sum, row) => sum + (parseFloat(row[field]) || 0), 0); + } + case "avg": { + const sum = rows.reduce((s, row) => s + (parseFloat(row[field]) || 0), 0); + return rows.length > 0 ? sum / rows.length : 0; + } + case "min": { + return Math.min(...rows.map((row) => parseFloat(row[field]) || 0)); + } + case "max": { + return Math.max(...rows.map((row) => parseFloat(row[field]) || 0)); + } + default: + return 0; + } +}; + +// 색상 스타일 매핑 +const colorMap = { + indigo: { bg: "bg-indigo-50", text: "text-indigo-600", border: "border-indigo-200" }, + green: { bg: "bg-green-50", text: "text-green-600", border: "border-green-200" }, + blue: { bg: "bg-blue-50", text: "text-blue-600", border: "border-blue-200" }, + purple: { bg: "bg-purple-50", text: "text-purple-600", border: "border-purple-200" }, + orange: { bg: "bg-orange-50", text: "text-orange-600", border: "border-orange-200" }, + gray: { bg: "bg-gray-50", text: "text-gray-600", border: "border-gray-200" }, +}; + +/** + * 커스텀 메트릭 테스트 위젯 (다중 데이터 소스 지원) + * - 여러 REST API 연결 가능 + * - 여러 Database 연결 가능 + * - REST API + Database 혼합 가능 + * - 데이터 자동 병합 후 집계 + */ +export default function CustomMetricTestWidget({ element }: CustomMetricTestWidgetProps) { + const [metrics, setMetrics] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + console.log("🧪 CustomMetricTestWidget 렌더링!", element); + + const metricConfig = element?.customMetricConfig?.metrics || []; + + // 다중 데이터 소스 로딩 + const loadMultipleDataSources = useCallback(async () => { + const dataSources = element?.dataSources || element?.chartConfig?.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 allRows: any[] = []; + results.forEach((result) => { + if (result.status === "fulfilled" && Array.isArray(result.value)) { + allRows.push(...result.value); + } + }); + + console.log(`✅ 총 ${allRows.length}개의 행 로딩 완료`); + + // 메트릭 계산 + const calculatedMetrics = metricConfig.map((metric) => ({ + ...metric, + value: calculateMetric(allRows, metric.field, metric.aggregation), + })); + + setMetrics(calculatedMetrics); + } catch (err) { + setError(err instanceof Error ? err.message : "데이터 로딩 실패"); + } finally { + setLoading(false); + } + }, [element?.dataSources, element?.chartConfig?.dataSources, metricConfig]); + + // REST API 데이터 로딩 + const loadRestApiData = async (source: ChartDataSource): Promise => { + if (!source.endpoint) { + throw new Error("API endpoint가 없습니다."); + } + + const params = new URLSearchParams(); + if (source.queryParams) { + Object.entries(source.queryParams).forEach(([key, value]) => { + if (key && value) { + params.append(key, String(value)); + } + }); + } + + 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: source.headers || {}, + queryParams: Object.fromEntries(params), + }), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const result = await response.json(); + + if (!result.success) { + throw new Error(result.message || "외부 API 호출 실패"); + } + + let processedData = result.data; + + // JSON Path 처리 + if (source.jsonPath) { + const paths = source.jsonPath.split("."); + for (const path of paths) { + if (processedData && typeof processedData === "object" && path in processedData) { + processedData = processedData[path]; + } else { + throw new Error(`JSON Path "${source.jsonPath}"에서 데이터를 찾을 수 없습니다`); + } + } + } + + return Array.isArray(processedData) ? processedData : [processedData]; + }; + + // Database 데이터 로딩 + const loadDatabaseData = async (source: ChartDataSource): Promise => { + if (!source.query) { + throw new Error("SQL 쿼리가 없습니다."); + } + + if (source.connectionType === "external" && source.externalConnectionId) { + // 외부 DB + 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 resultData.rows; + } else { + // 현재 DB + const { dashboardApi } = await import("@/lib/api/dashboard"); + const result = await dashboardApi.executeQuery(source.query); + + return result.rows; + } + }; + + // 초기 로드 + useEffect(() => { + const dataSources = element?.dataSources || element?.chartConfig?.dataSources; + if (dataSources && dataSources.length > 0 && metricConfig.length > 0) { + loadMultipleDataSources(); + } + }, [element?.dataSources, element?.chartConfig?.dataSources, loadMultipleDataSources, metricConfig]); + + // 메트릭 카드 렌더링 + const renderMetricCard = (metric: any, index: number) => { + const color = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray; + const formattedValue = metric.value.toLocaleString(undefined, { + minimumFractionDigits: metric.decimals || 0, + maximumFractionDigits: metric.decimals || 0, + }); + + return ( +

+
+
+

{metric.label}

+

+ {formattedValue} + {metric.unit && {metric.unit}} +

+
+
+
+ ); + }; + + return ( +
+ {/* 헤더 */} +
+
+

+ {element?.customTitle || "커스텀 메트릭 (다중 데이터 소스)"} +

+

+ {(element?.dataSources || element?.chartConfig?.dataSources)?.length || 0}개 데이터 소스 연결됨 +

+
+ {loading && } +
+ + {/* 컨텐츠 */} +
+ {error ? ( +
+

{error}

+
+ ) : !(element?.dataSources || element?.chartConfig?.dataSources) || (element?.dataSources || element?.chartConfig?.dataSources)?.length === 0 ? ( +
+

+ 데이터 소스를 연결해주세요 +

+
+ ) : metricConfig.length === 0 ? ( +
+

+ 메트릭을 설정해주세요 +

+
+ ) : ( +
+ {metrics.map((metric, index) => renderMetricCard(metric, index))} +
+ )} +
+
+ ); +} + diff --git a/frontend/components/dashboard/widgets/ListTestWidget.tsx b/frontend/components/dashboard/widgets/ListTestWidget.tsx new file mode 100644 index 00000000..b5dceead --- /dev/null +++ b/frontend/components/dashboard/widgets/ListTestWidget.tsx @@ -0,0 +1,353 @@ +"use client"; + +import React, { useState, useEffect, useCallback } 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"; + +interface ListTestWidgetProps { + element: DashboardElement; +} + +interface QueryResult { + columns: string[]; + rows: Record[]; + totalRows: number; + executionTime: number; +} + +/** + * 리스트 테스트 위젯 (다중 데이터 소스 지원) + * - 여러 REST API 연결 가능 + * - 여러 Database 연결 가능 + * - REST API + Database 혼합 가능 + * - 데이터 자동 병합 + */ +export function ListTestWidget({ element }: ListTestWidgetProps) { + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + + console.log("🧪 ListTestWidget 렌더링!", element); + + const config = element.listConfig || { + columnMode: "auto", + viewMode: "table", + columns: [], + pageSize: 10, + enablePagination: true, + showHeader: true, + stripedRows: true, + compactMode: false, + cardColumns: 3, + }; + + // 다중 데이터 소스 로딩 + const loadMultipleDataSources = useCallback(async () => { + const dataSources = element?.dataSources || element?.chartConfig?.dataSources; + + if (!dataSources || dataSources.length === 0) { + console.log("⚠️ 데이터 소스가 없습니다."); + return; + } + + console.log(`🔄 ${dataSources.length}개의 데이터 소스 로딩 시작...`); + setIsLoading(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 { columns: [], rows: [] }; + } catch (err: any) { + console.error(`❌ 데이터 소스 "${source.name || source.id}" 로딩 실패:`, err); + return { columns: [], rows: [] }; + } + }) + ); + + // 성공한 데이터만 병합 + const allColumns = new Set(); + const allRows: Record[] = []; + + results.forEach((result, index) => { + if (result.status === "fulfilled") { + const { columns, rows } = result.value; + + // 컬럼 수집 + columns.forEach((col: string) => allColumns.add(col)); + + // 행 병합 (소스 정보 추가) + const sourceName = dataSources[index].name || dataSources[index].id || `소스 ${index + 1}`; + rows.forEach((row: any) => { + allRows.push({ + ...row, + _source: sourceName, + }); + }); + } + }); + + const finalColumns = Array.from(allColumns); + + // _source 컬럼을 맨 앞으로 + const sortedColumns = finalColumns.includes("_source") + ? ["_source", ...finalColumns.filter((c) => c !== "_source")] + : finalColumns; + + setData({ + columns: sortedColumns, + rows: allRows, + totalRows: allRows.length, + executionTime: 0, + }); + + console.log(`✅ 총 ${allRows.length}개의 행 로딩 완료`); + } catch (err) { + setError(err instanceof Error ? err.message : "데이터 로딩 실패"); + } finally { + setIsLoading(false); + } + }, [element?.dataSources, element?.chartConfig?.dataSources]); + + // REST API 데이터 로딩 + const loadRestApiData = async (source: ChartDataSource): Promise<{ columns: string[]; rows: any[] }> => { + if (!source.endpoint) { + throw new Error("API endpoint가 없습니다."); + } + + const params = new URLSearchParams(); + if (source.queryParams) { + Object.entries(source.queryParams).forEach(([key, value]) => { + if (key && value) { + params.append(key, String(value)); + } + }); + } + + 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: source.headers || {}, + queryParams: Object.fromEntries(params), + }), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const result = await response.json(); + + if (!result.success) { + throw new Error(result.message || "외부 API 호출 실패"); + } + + let processedData = result.data; + + // JSON Path 처리 + if (source.jsonPath) { + const paths = source.jsonPath.split("."); + for (const path of paths) { + if (processedData && typeof processedData === "object" && path in processedData) { + processedData = processedData[path]; + } else { + throw new Error(`JSON Path "${source.jsonPath}"에서 데이터를 찾을 수 없습니다`); + } + } + } + + const rows = Array.isArray(processedData) ? processedData : [processedData]; + const columns = rows.length > 0 ? Object.keys(rows[0]) : []; + + return { columns, rows }; + }; + + // Database 데이터 로딩 + const loadDatabaseData = async (source: ChartDataSource): Promise<{ columns: string[]; rows: any[] }> => { + if (!source.query) { + throw new Error("SQL 쿼리가 없습니다."); + } + + if (source.connectionType === "external" && source.externalConnectionId) { + // 외부 DB + 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 { + columns: string[]; + rows: Record[]; + }; + + return { + columns: resultData.columns, + rows: resultData.rows, + }; + } else { + // 현재 DB + const { dashboardApi } = await import("@/lib/api/dashboard"); + const result = await dashboardApi.executeQuery(source.query); + + return { + columns: result.columns, + rows: result.rows, + }; + } + }; + + // 초기 로드 + useEffect(() => { + const dataSources = element?.dataSources || element?.chartConfig?.dataSources; + if (dataSources && dataSources.length > 0) { + loadMultipleDataSources(); + } + }, [element?.dataSources, element?.chartConfig?.dataSources, loadMultipleDataSources]); + + // 페이지네이션 + const pageSize = config.pageSize || 10; + const totalPages = data ? Math.ceil(data.totalRows / pageSize) : 0; + const startIndex = (currentPage - 1) * pageSize; + const endIndex = startIndex + pageSize; + const paginatedRows = data?.rows.slice(startIndex, endIndex) || []; + + // 테이블 뷰 + const renderTable = () => ( +
+ + {config.showHeader && ( + + + {data?.columns.map((col) => ( + + {col} + + ))} + + + )} + + {paginatedRows.map((row, idx) => ( + + {data?.columns.map((col) => ( + + {String(row[col] ?? "")} + + ))} + + ))} + +
+
+ ); + + // 카드 뷰 + const renderCards = () => ( +
+ {paginatedRows.map((row, idx) => ( + + {data?.columns.map((col) => ( +
+ {col}: + {String(row[col] ?? "")} +
+ ))} +
+ ))} +
+ ); + + return ( +
+ {/* 헤더 */} +
+
+

+ {element?.customTitle || "리스트 테스트 (다중 데이터 소스)"} +

+

+ {(element?.dataSources || element?.chartConfig?.dataSources)?.length || 0}개 데이터 소스 연결됨 +

+
+ {isLoading && } +
+ + {/* 컨텐츠 */} +
+ {error ? ( +
+

{error}

+
+ ) : !(element?.dataSources || element?.chartConfig?.dataSources) || (element?.dataSources || element?.chartConfig?.dataSources)?.length === 0 ? ( +
+

+ 데이터 소스를 연결해주세요 +

+
+ ) : !data || data.rows.length === 0 ? ( +
+

+ 데이터가 없습니다 +

+
+ ) : config.viewMode === "card" ? ( + renderCards() + ) : ( + renderTable() + )} +
+ + {/* 페이지네이션 */} + {config.enablePagination && data && data.rows.length > 0 && totalPages > 1 && ( +
+
+ 총 {data.totalRows}개 항목 (페이지 {currentPage}/{totalPages}) +
+
+ + +
+
+ )} +
+ ); +} + diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index b56eeccd..500f379f 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState, useCallback } from "react"; +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"; @@ -64,10 +64,14 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { console.log("🧪 MapTestWidgetV2 렌더링!", element); console.log("📍 마커:", markers.length, "🔷 폴리곤:", polygons.length); + // dataSources를 useMemo로 추출 (circular reference 방지) + const dataSources = useMemo(() => { + return element?.dataSources || element?.chartConfig?.dataSources; + }, [element?.dataSources, element?.chartConfig?.dataSources]); + // 다중 데이터 소스 로딩 const loadMultipleDataSources = useCallback(async () => { - // dataSources는 element.dataSources 또는 chartConfig.dataSources에서 로드 - const dataSources = element?.dataSources || element?.chartConfig?.dataSources; + const dataSourcesList = dataSources; if (!dataSources || dataSources.length === 0) { console.log("⚠️ 데이터 소스가 없습니다."); @@ -138,10 +142,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { } finally { setLoading(false); } - }, [element?.dataSources]); + }, [dataSources]); // REST API 데이터 로딩 const loadRestApiData = async (source: ChartDataSource): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => { + console.log(`🌐 REST API 데이터 로딩 시작:`, source.name, `mapDisplayType:`, source.mapDisplayType); + if (!source.endpoint) { throw new Error("API endpoint가 없습니다."); } @@ -200,7 +206,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const parsedData = parseTextData(data.text); if (parsedData.length > 0) { console.log(`✅ CSV 파싱 성공: ${parsedData.length}개 행`); - return convertToMapData(parsedData, source.name || source.id || "API"); + return convertToMapData(parsedData, source.name || source.id || "API", source.mapDisplayType); } } @@ -220,34 +226,38 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // Database 데이터 로딩 const loadDatabaseData = async (source: ChartDataSource): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => { + console.log(`💾 Database 데이터 로딩 시작:`, source.name, `mapDisplayType:`, source.mapDisplayType); + 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, - }), - }); + let rows: any[] = []; - if (!response.ok) { - throw new Error(`데이터베이스 쿼리 실패: ${response.status}`); + if (source.connectionType === "external" && source.externalConnectionId) { + // 외부 DB + 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[]; + }; + + rows = resultData.rows; + } else { + // 현재 DB + const { dashboardApi } = await import("@/lib/api/dashboard"); + const result = await dashboardApi.executeQuery(source.query); + + rows = result.rows; } - - const result = await response.json(); - - if (!result.success) { - throw new Error(result.message || "쿼리 실패"); - } - - const rows = result.data || []; // 마커와 폴리곤으로 변환 (mapDisplayType 전달) return convertToMapData(rows, source.name || source.id || "Database", source.mapDisplayType); @@ -311,7 +321,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 데이터를 마커와 폴리곤으로 변환 const convertToMapData = (rows: any[], sourceName: string, mapDisplayType?: "auto" | "marker" | "polygon"): { markers: MarkerData[]; polygons: PolygonData[] } => { - console.log(`🔄 ${sourceName} 데이터 변환 시작:`, rows.length, "개 행, 표시 방식:", mapDisplayType || "auto"); + console.log(`🔄 ${sourceName} 데이터 변환 시작:`, rows.length, "개 행"); + console.log(` 📌 mapDisplayType:`, mapDisplayType, `(타입: ${typeof mapDisplayType})`); if (rows.length === 0) return { markers: [], polygons: [] }; @@ -398,7 +409,28 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { } } - if (lat !== undefined && lng !== undefined) { + // mapDisplayType이 "polygon"이면 무조건 폴리곤으로 처리 + if (mapDisplayType === "polygon") { + const regionName = row.name || row.subRegion || row.region || row.area; + if (regionName) { + console.log(` 🔷 강제 폴리곤 모드: ${regionName} → 폴리곤으로 추가 (GeoJSON 매칭)`); + polygons.push({ + id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, + name: regionName, + coordinates: [], // GeoJSON에서 좌표를 가져올 것 + status: row.status || row.level, + description: row.description || JSON.stringify(row, null, 2), + source: sourceName, + color: getColorByStatus(row.status || row.level), + }); + } else { + console.log(` ⚠️ 강제 폴리곤 모드지만 지역명 없음 - 스킵`); + } + return; // 폴리곤으로 처리했으므로 마커로는 추가하지 않음 + } + + // 위도/경도가 있고 marker 모드가 아니면 마커로 처리 + if (lat !== undefined && lng !== undefined && mapDisplayType !== "polygon") { console.log(` → 마커로 처리: (${lat}, ${lng})`); markers.push({ id: `${sourceName}-marker-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성 @@ -681,7 +713,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { setMarkers([]); setPolygons([]); } - }, [JSON.stringify(element?.dataSources || element?.chartConfig?.dataSources), loadMultipleDataSources]); + }, [dataSources, loadMultipleDataSources]); // 타일맵 URL (chartConfig에서 가져오기) const tileMapUrl = element?.chartConfig?.tileMapUrl || @@ -741,12 +773,45 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { { - const regionName = feature?.properties?.CTP_KOR_NM || feature?.properties?.SIG_KOR_NM; - const matchingPolygon = polygons.find(p => - p.name === regionName || - p.name?.includes(regionName) || - regionName?.includes(p.name) - ); + const ctpName = feature?.properties?.CTP_KOR_NM; // 시/도명 (예: 경상북도) + const sigName = feature?.properties?.SIG_KOR_NM; // 시/군/구명 (예: 군위군) + + // 폴리곤 매칭 (시/군/구명 우선, 없으면 시/도명) + const matchingPolygon = polygons.find(p => { + if (!p.name) return false; + + // 정확한 매칭 + if (p.name === sigName) { + console.log(`✅ 정확 매칭: ${p.name} === ${sigName}`); + return true; + } + if (p.name === ctpName) { + console.log(`✅ 정확 매칭: ${p.name} === ${ctpName}`); + return true; + } + + // 부분 매칭 (GeoJSON 지역명에 폴리곤 이름이 포함되는지) + if (sigName && sigName.includes(p.name)) { + console.log(`✅ 부분 매칭: ${sigName} includes ${p.name}`); + return true; + } + if (ctpName && ctpName.includes(p.name)) { + console.log(`✅ 부분 매칭: ${ctpName} includes ${p.name}`); + return true; + } + + // 역방향 매칭 (폴리곤 이름에 GeoJSON 지역명이 포함되는지) + if (sigName && p.name.includes(sigName)) { + console.log(`✅ 역방향 매칭: ${p.name} includes ${sigName}`); + return true; + } + if (ctpName && p.name.includes(ctpName)) { + console.log(`✅ 역방향 매칭: ${p.name} includes ${ctpName}`); + return true; + } + + return false; + }); if (matchingPolygon) { return { @@ -763,12 +828,18 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { }; }} onEachFeature={(feature: any, layer: any) => { - const regionName = feature?.properties?.CTP_KOR_NM || feature?.properties?.SIG_KOR_NM; - const matchingPolygon = polygons.find(p => - p.name === regionName || - p.name?.includes(regionName) || - regionName?.includes(p.name) - ); + const ctpName = feature?.properties?.CTP_KOR_NM; + const sigName = feature?.properties?.SIG_KOR_NM; + + const matchingPolygon = polygons.find(p => { + if (!p.name) return false; + if (p.name === sigName || p.name === ctpName) return true; + if (sigName && sigName.includes(p.name)) return true; + if (ctpName && ctpName.includes(p.name)) return true; + if (sigName && p.name.includes(sigName)) return true; + if (ctpName && p.name.includes(ctpName)) return true; + return false; + }); if (matchingPolygon) { layer.bindPopup(`