diff --git a/frontend/components/admin/dashboard/DashboardTopMenu.tsx b/frontend/components/admin/dashboard/DashboardTopMenu.tsx index 96fb5c62..a1ca1349 100644 --- a/frontend/components/admin/dashboard/DashboardTopMenu.tsx +++ b/frontend/components/admin/dashboard/DashboardTopMenu.tsx @@ -152,6 +152,7 @@ export function DashboardTopMenu({ )}
+ {/* 차트 선택 */} -

- 설정한 간격마다 자동으로 데이터를 다시 불러옵니다 -

{/* 지도 색상 설정 (MapTestWidgetV2 전용) */} -
-
🎨 지도 색상 선택
+
+
🎨 지도 색상
{/* 색상 팔레트 */} -
- -
- {[ - { name: "파랑", marker: "#3b82f6", polygon: "#3b82f6" }, - { name: "빨강", marker: "#ef4444", polygon: "#ef4444" }, - { name: "초록", marker: "#10b981", polygon: "#10b981" }, - { name: "노랑", marker: "#f59e0b", polygon: "#f59e0b" }, - { name: "보라", marker: "#8b5cf6", polygon: "#8b5cf6" }, - { name: "주황", marker: "#f97316", polygon: "#f97316" }, - { name: "청록", marker: "#06b6d4", polygon: "#06b6d4" }, - { name: "분홍", marker: "#ec4899", polygon: "#ec4899" }, - ].map((color) => { - const isSelected = dataSource.markerColor === color.marker; - return ( - - ); - })} -
-

- 선택한 색상이 마커와 폴리곤에 모두 적용됩니다 -

+
+ {[ + { name: "파랑", marker: "#3b82f6", polygon: "#3b82f6" }, + { name: "빨강", marker: "#ef4444", polygon: "#ef4444" }, + { name: "초록", marker: "#10b981", polygon: "#10b981" }, + { name: "노랑", marker: "#f59e0b", polygon: "#f59e0b" }, + { name: "보라", marker: "#8b5cf6", polygon: "#8b5cf6" }, + { name: "주황", marker: "#f97316", polygon: "#f97316" }, + { name: "청록", marker: "#06b6d4", polygon: "#06b6d4" }, + { name: "분홍", marker: "#ec4899", polygon: "#ec4899" }, + ].map((color) => { + const isSelected = dataSource.markerColor === color.marker; + return ( + + ); + })}
{/* 테스트 버튼 */} -
+
@@ -462,7 +450,7 @@ ORDER BY 하위부서수 DESC`, variant="outline" size="sm" onClick={() => onChange({ selectedColumns: [] })} - className="h-7 text-xs" + className="h-6 px-2 text-xs" > 해제 @@ -475,12 +463,12 @@ ORDER BY 하위부서수 DESC`, placeholder="컬럼 검색..." value={columnSearchTerm} onChange={(e) => setColumnSearchTerm(e.target.value)} - className="h-8 text-xs" + className="h-7 text-xs" /> )} {/* 컬럼 카드 그리드 */} -
+
{availableColumns .filter(col => !columnSearchTerm || @@ -526,7 +514,7 @@ ORDER BY 하위부서수 DESC`, onChange({ selectedColumns: newSelected }); }} className={` - relative flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-all + relative flex items-start gap-2 rounded-lg border p-2 cursor-pointer transition-all ${isSelected ? "border-primary bg-primary/5 shadow-sm" : "border-border bg-card hover:border-primary/50 hover:bg-muted/50" diff --git a/frontend/components/dashboard/widgets/ChartTestWidget.tsx b/frontend/components/dashboard/widgets/ChartTestWidget.tsx index 362ad8cd..412e4961 100644 --- a/frontend/components/dashboard/widgets/ChartTestWidget.tsx +++ b/frontend/components/dashboard/widgets/ChartTestWidget.tsx @@ -1,27 +1,11 @@ "use client"; -import React, { useEffect, useState, useCallback, useMemo } from "react"; -import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types"; +import React, { useEffect, useState, useCallback, useMemo, useRef } from "react"; +import { DashboardElement, ChartDataSource, ChartData } from "@/components/admin/dashboard/types"; import { Button } from "@/components/ui/button"; import { Loader2, RefreshCw } from "lucide-react"; import { applyColumnMapping } from "@/lib/utils/columnMapping"; -import { - LineChart, - Line, - BarChart, - Bar, - PieChart, - Pie, - Cell, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - Legend, - ResponsiveContainer, - ComposedChart, // 🆕 바/라인/영역 혼합 차트 - Area, // 🆕 영역 차트 -} from "recharts"; +import { Chart } from "@/components/admin/dashboard/charts/Chart"; interface ChartTestWidgetProps { element: DashboardElement; @@ -34,16 +18,32 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [lastRefreshTime, setLastRefreshTime] = useState(null); + const containerRef = useRef(null); + const [containerSize, setContainerSize] = useState({ width: 600, height: 400 }); - console.log("🧪 ChartTestWidget 렌더링!", element); + console.log("🧪 ChartTestWidget 렌더링 (D3 기반)!", element); const dataSources = useMemo(() => { return element?.dataSources || element?.chartConfig?.dataSources; }, [element?.dataSources, element?.chartConfig?.dataSources]); + // 컨테이너 크기 측정 + useEffect(() => { + const updateSize = () => { + if (containerRef.current) { + const width = containerRef.current.offsetWidth || 600; + const height = containerRef.current.offsetHeight || 400; + setContainerSize({ width, height }); + } + }; + + updateSize(); + window.addEventListener("resize", updateSize); + return () => window.removeEventListener("resize", updateSize); + }, []); + // 다중 데이터 소스 로딩 const loadMultipleDataSources = useCallback(async () => { - // dataSources는 element.dataSources 또는 chartConfig.dataSources에서 로드 const dataSources = element?.dataSources || element?.chartConfig?.dataSources; if (!dataSources || dataSources.length === 0) { @@ -51,16 +51,15 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) { return; } - console.log(`🔄 \${dataSources.length}개의 데이터 소스 로딩 시작...`); + 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}" 로딩 중...`); + console.log(`📡 데이터 소스 "${source.name || source.id}" 로딩 중...`); if (source.type === "api") { return await loadRestApiData(source); @@ -70,25 +69,24 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) { return []; } catch (err: any) { - console.error(`❌ 데이터 소스 "\${source.name || source.id}" 로딩 실패:`, err); + 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}`, + _source: dataSources[index].name || dataSources[index].id || `소스 ${index + 1}`, })); allData.push(...sourceData); } }); - console.log(`✅ 총 \${allData.length}개의 데이터 로딩 완료`); + console.log(`✅ 총 ${allData.length}개의 데이터 로딩 완료`); setData(allData); setLastRefreshTime(new Date()); } catch (err: any) { @@ -97,9 +95,9 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) { } finally { setLoading(false); } - }, [element?.dataSources]); + }, [element?.dataSources, element?.chartConfig?.dataSources]); - // 수동 새로고침 핸들러 + // 수동 새로고침 const handleManualRefresh = useCallback(() => { console.log("🔄 수동 새로고침 버튼 클릭"); loadMultipleDataSources(); @@ -142,7 +140,7 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) { }); if (!response.ok) { - throw new Error(`API 호출 실패: \${response.status}`); + throw new Error(`API 호출 실패: ${response.status}`); } const result = await response.json(); @@ -159,8 +157,6 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) { } const rows = Array.isArray(apiData) ? apiData : [apiData]; - - // 컬럼 매핑 적용 return applyColumnMapping(rows, source.columnMapping); }; @@ -172,11 +168,9 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) { let result; if (source.connectionType === "external" && source.externalConnectionId) { - // 외부 DB (ExternalDbConnectionAPI 사용) const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection"); result = await ExternalDbConnectionAPI.executeQuery(parseInt(source.externalConnectionId), source.query); } else { - // 현재 DB (dashboardApi.executeQuery 사용) const { dashboardApi } = await import("@/lib/api/dashboard"); try { @@ -196,25 +190,7 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) { } const rows = result.rows || result.data || []; - - console.log("💾 내부 DB 쿼리 결과:", { - hasRows: !!rows, - rowCount: rows.length, - hasColumns: rows.length > 0 && Object.keys(rows[0]).length > 0, - columnCount: rows.length > 0 ? Object.keys(rows[0]).length : 0, - firstRow: rows[0], - }); - - // 컬럼 매핑 적용 - const mappedRows = applyColumnMapping(rows, source.columnMapping); - - console.log("✅ 매핑 후:", { - columns: mappedRows.length > 0 ? Object.keys(mappedRows[0]) : [], - rowCount: mappedRows.length, - firstMappedRow: mappedRows[0], - }); - - return mappedRows; + return applyColumnMapping(rows, source.columnMapping); }; // 초기 로드 @@ -253,372 +229,133 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) { const mergeMode = chartConfig.mergeMode || false; const dataSourceConfigs = chartConfig.dataSourceConfigs || []; - // 멀티 데이터 소스 차트 렌더링 - const renderChart = () => { - if (data.length === 0) { - return ( -
-

데이터가 없습니다

-
- ); + // 데이터를 D3 Chart 컴포넌트 형식으로 변환 + const chartData = useMemo((): ChartData | null => { + if (data.length === 0 || dataSourceConfigs.length === 0) { + return null; } - if (dataSourceConfigs.length === 0) { - return ( -
-

- 차트 설정에서 데이터 소스를 추가하고 -
- X축, Y축을 설정해주세요 -

-
- ); - } + const labels = new Set(); + const datasets: any[] = []; - // 병합 모드: 여러 데이터 소스를 하나의 라인/바로 합침 + // 병합 모드: 여러 데이터 소스를 하나로 합침 if (mergeMode && dataSourceConfigs.length > 1) { - const chartData: any[] = []; - const allXValues = new Set(); - - // 첫 번째 데이터 소스의 설정을 기준으로 사용 const baseConfig = dataSourceConfigs[0]; const xAxisField = baseConfig.xAxis; const yAxisField = baseConfig.yAxis[0]; - // 모든 데이터 소스에서 데이터 수집 (X축 값 기준) + // X축 값 수집 dataSourceConfigs.forEach((dsConfig) => { - const sourceName = dataSources.find((ds) => ds.id === dsConfig.dataSourceId)?.name; + const sourceName = dataSources?.find((ds) => ds.id === dsConfig.dataSourceId)?.name; const sourceData = data.filter((item) => item._source === sourceName); - sourceData.forEach((item) => { - const xValue = item[xAxisField]; - if (xValue !== undefined) { - allXValues.add(String(xValue)); + if (item[xAxisField] !== undefined) { + labels.add(String(item[xAxisField])); } }); }); - // X축 값별로 Y축 값 합산 - allXValues.forEach((xValue) => { - const dataPoint: any = { _xValue: xValue }; - let totalYValue = 0; - + // 데이터 병합 + const mergedData: number[] = []; + labels.forEach((label) => { + let totalValue = 0; dataSourceConfigs.forEach((dsConfig) => { - const sourceName = dataSources.find((ds) => ds.id === dsConfig.dataSourceId)?.name; + const sourceName = dataSources?.find((ds) => ds.id === dsConfig.dataSourceId)?.name; const sourceData = data.filter((item) => item._source === sourceName); - const matchingItem = sourceData.find((item) => String(item[xAxisField]) === xValue); - + const matchingItem = sourceData.find((item) => String(item[xAxisField]) === label); if (matchingItem && yAxisField) { - const yValue = parseFloat(matchingItem[yAxisField]) || 0; - totalYValue += yValue; + totalValue += parseFloat(matchingItem[yAxisField]) || 0; + } + }); + mergedData.push(totalValue); + }); + + datasets.push({ + label: yAxisField, + data: mergedData, + color: COLORS[0], + }); + } else { + // 일반 모드: 각 데이터 소스를 별도로 표시 + dataSourceConfigs.forEach((dsConfig, index) => { + const sourceName = dataSources?.find((ds) => ds.id === dsConfig.dataSourceId)?.name || `소스 ${index + 1}`; + const sourceData = data.filter((item) => item._source === sourceName); + + // X축 값 수집 + sourceData.forEach((item) => { + const xValue = item[dsConfig.xAxis]; + if (xValue !== undefined) { + labels.add(String(xValue)); } }); - dataPoint[yAxisField] = totalYValue; - chartData.push(dataPoint); + // Y축 데이터 수집 + const yField = dsConfig.yAxis[0]; + const dataValues: number[] = []; + + labels.forEach((label) => { + const matchingItem = sourceData.find((item) => String(item[dsConfig.xAxis]) === label); + dataValues.push(matchingItem && yField ? parseFloat(matchingItem[yField]) || 0 : 0); + }); + + datasets.push({ + label: dsConfig.label || sourceName, + data: dataValues, + color: COLORS[index % COLORS.length], + }); }); - - console.log("🔗 병합 모드 차트 데이터:", chartData); - - // 병합 모드 차트 렌더링 - switch (chartType) { - case "line": - return ( - - - - - - - - - - - ); - - case "bar": - return ( - - - - - - - - - - - ); - - case "area": - return ( - - - - - - - - - - - ); - - default: - return ( -
-

병합 모드는 라인, 바, 영역 차트만 지원합니다

-
- ); - } } - // 일반 모드: 각 데이터 소스를 별도의 라인/바로 표시 - const chartData: any[] = []; - const allXValues = new Set(); - - // 1단계: 모든 X축 값 수집 - dataSourceConfigs.forEach((dsConfig) => { - const sourceData = data.filter((item) => { - const sourceName = dataSources.find((ds) => ds.id === dsConfig.dataSourceId)?.name; - return item._source === sourceName; - }); - - sourceData.forEach((item) => { - const xValue = item[dsConfig.xAxis]; - if (xValue !== undefined) { - allXValues.add(String(xValue)); - } - }); - }); - - // 2단계: X축 값별로 데이터 병합 - allXValues.forEach((xValue) => { - const dataPoint: any = { _xValue: xValue }; - - dataSourceConfigs.forEach((dsConfig, index) => { - const sourceName = dataSources.find((ds) => ds.id === dsConfig.dataSourceId)?.name || `소스 ${index + 1}`; - const sourceData = data.filter((item) => item._source === sourceName); - const matchingItem = sourceData.find((item) => String(item[dsConfig.xAxis]) === xValue); - - if (matchingItem && dsConfig.yAxis.length > 0) { - const yField = dsConfig.yAxis[0]; - dataPoint[`${sourceName}_${yField}`] = matchingItem[yField]; - } - }); - - chartData.push(dataPoint); - }); - - console.log("📊 일반 모드 차트 데이터:", chartData); - console.log("📊 데이터 소스 설정:", dataSourceConfigs); - - // 🆕 혼합 차트 타입 감지 (각 데이터 소스마다 다른 차트 타입이 설정된 경우) - const isMixedChart = dataSourceConfigs.some((dsConfig) => dsConfig.chartType); - const effectiveChartType = isMixedChart ? "mixed" : chartType; - - // 차트 타입별 렌더링 - switch (effectiveChartType) { - case "mixed": - case "line": - case "bar": - case "area": - // 🆕 ComposedChart 사용 (바/라인/영역 혼합 가능) - return ( - - - - - - - - {dataSourceConfigs.map((dsConfig, index) => { - const sourceName = - dataSources.find((ds) => ds.id === dsConfig.dataSourceId)?.name || `소스 ${index + 1}`; - const yField = dsConfig.yAxis[0]; - const dataKey = `${sourceName}_${yField}`; - const label = dsConfig.label || sourceName; - const color = COLORS[index % COLORS.length]; - - // 개별 차트 타입 또는 전역 차트 타입 사용 - const individualChartType = dsConfig.chartType || chartType; - - // 차트 타입에 따라 다른 컴포넌트 렌더링 - switch (individualChartType) { - case "bar": - return ; - case "area": - return ( - - ); - case "line": - default: - return ( - - ); - } - })} - - - ); - - case "pie": - case "donut": - // 파이 차트는 첫 번째 데이터 소스만 사용 - if (dataSourceConfigs.length > 0) { - const firstConfig = dataSourceConfigs[0]; - const sourceName = dataSources.find((ds) => ds.id === firstConfig.dataSourceId)?.name; - - // 해당 데이터 소스의 데이터만 필터링 - const sourceData = data.filter((item) => item._source === sourceName); - - console.log("🍩 도넛/파이 차트 데이터:", { - sourceName, - totalData: data.length, - filteredData: sourceData.length, - firstConfig, - sampleItem: sourceData[0], - }); - - // 파이 차트용 데이터 변환 - const pieData = sourceData.map((item) => ({ - name: String(item[firstConfig.xAxis] || "Unknown"), - value: Number(item[firstConfig.yAxis[0]]) || 0, - })); - - console.log("🍩 변환된 파이 데이터:", pieData); - console.log("🍩 첫 번째 데이터:", pieData[0]); - console.log("🍩 데이터 타입 체크:", { - firstValue: pieData[0]?.value, - valueType: typeof pieData[0]?.value, - isNumber: typeof pieData[0]?.value === "number", - }); - - if (pieData.length === 0) { - return ( -
-

파이 차트에 표시할 데이터가 없습니다.

-
- ); - } - - // value가 모두 0인지 체크 - const totalValue = pieData.reduce((sum, item) => sum + (item.value || 0), 0); - if (totalValue === 0) { - return ( -
-

모든 값이 0입니다. Y축 필드를 확인해주세요.

-
- ); - } - - return ( - - - `${entry.name}: ${entry.value}`} - labelLine={true} - fill="#8884d8" - > - {pieData.map((entry, index) => ( - - ))} - - - - - - ); - } - return ( -
-

파이 차트를 표시하려면 데이터 소스를 설정하세요.

-
- ); - - default: - return ( -
-

지원하지 않는 차트 타입: {chartType}

-
- ); - } - }; + return { + labels: Array.from(labels), + datasets, + }; + }, [data, dataSourceConfigs, mergeMode, dataSources]); return ( -
-
-
-

{element?.customTitle || "차트"}

-

- {dataSources?.length || 0}개 데이터 소스 • {data.length}개 데이터 - {lastRefreshTime && • {lastRefreshTime.toLocaleTimeString("ko-KR")}} -

-
-
- - {loading && } -
-
- -
+
+ {/* 차트 영역 - 전체 공간 사용 */} +
{error ? (

{error}

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

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

+ ) : loading && data.length === 0 ? ( +
+
+ +

데이터 로딩 중...

+
+
+ ) : !chartData ? ( +
+

+ 차트 설정에서 데이터 소스를 추가하고 +
+ X축, Y축을 설정해주세요 +

+
) : ( - renderChart() + )}
- {data.length > 0 && ( -
총 {data.length}개 데이터 표시 중
- )} + {/* 푸터 - 주석 처리 (공간 확보) */} + {/* {data.length > 0 && ( +
+ 총 {data.length.toLocaleString()}개 데이터 표시 중 +
+ )} */}
); }