다중데이터베이스 연결 가능하게 함, 차트 위젯은 테스트 용도입니다.
This commit is contained in:
parent
0fe2fa9db1
commit
88d71da1a9
|
|
@ -152,6 +152,7 @@ export function DashboardTopMenu({
|
|||
)}
|
||||
|
||||
<div className="h-6 w-px bg-gray-300" />
|
||||
|
||||
{/* 차트 선택 */}
|
||||
<Select value={chartValue} onValueChange={handleChartSelect}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
|
|
@ -184,7 +185,7 @@ export function DashboardTopMenu({
|
|||
<SelectGroup>
|
||||
<SelectLabel>데이터 위젯</SelectLabel>
|
||||
<SelectItem value="map-summary-v2">지도</SelectItem>
|
||||
{/* <SelectItem value="chart">차트</SelectItem> */}
|
||||
<SelectItem value="chart">차트</SelectItem>
|
||||
<SelectItem value="list-v2">리스트</SelectItem>
|
||||
<SelectItem value="custom-metric-v2">통계 카드</SelectItem>
|
||||
<SelectItem value="risk-alert-v2">리스크/알림</SelectItem>
|
||||
|
|
|
|||
|
|
@ -291,7 +291,7 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-gray-50 transition-transform duration-300 ease-in-out",
|
||||
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-72 flex-col bg-gray-50 transition-transform duration-300 ease-in-out",
|
||||
isOpen ? "translate-x-0" : "translate-x-[-100%]",
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -186,11 +186,11 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border p-4">
|
||||
<h5 className="text-sm font-semibold">Database 설정</h5>
|
||||
<div className="space-y-2 rounded-lg border p-3">
|
||||
<h5 className="text-xs font-semibold">Database 설정</h5>
|
||||
|
||||
{/* 커넥션 타입 */}
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">데이터베이스 연결</Label>
|
||||
<RadioGroup
|
||||
value={dataSource.connectionType || "current"}
|
||||
|
|
@ -311,15 +311,12 @@ ORDER BY 하위부서수 DESC`,
|
|||
value={dataSource.query || ""}
|
||||
onChange={(e) => onChange({ query: e.target.value })}
|
||||
placeholder="SELECT * FROM table_name WHERE ..."
|
||||
className="min-h-[120px] font-mono text-xs"
|
||||
className="min-h-[80px] font-mono text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
SELECT 쿼리만 허용됩니다. 샘플 쿼리를 선택하여 빠르게 시작할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 자동 새로고침 설정 */}
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`refresh-${dataSource.id}`} className="text-xs">
|
||||
자동 새로고침 간격
|
||||
</Label>
|
||||
|
|
@ -341,62 +338,53 @@ ORDER BY 하위부서수 DESC`,
|
|||
<SelectItem value="3600">1시간마다</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
설정한 간격마다 자동으로 데이터를 다시 불러옵니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 지도 색상 설정 (MapTestWidgetV2 전용) */}
|
||||
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
|
||||
<h5 className="text-xs font-semibold">🎨 지도 색상 선택</h5>
|
||||
<div className="space-y-2 rounded-lg border bg-muted/30 p-2">
|
||||
<h5 className="text-xs font-semibold">🎨 지도 색상</h5>
|
||||
|
||||
{/* 색상 팔레트 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">색상</Label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{[
|
||||
{ name: "파랑", marker: "#3b82f6", polygon: "#3b82f6" },
|
||||
{ name: "빨강", marker: "#ef4444", polygon: "#ef4444" },
|
||||
{ name: "초록", marker: "#10b981", polygon: "#10b981" },
|
||||
{ name: "노랑", marker: "#f59e0b", polygon: "#f59e0b" },
|
||||
{ name: "보라", marker: "#8b5cf6", polygon: "#8b5cf6" },
|
||||
{ name: "주황", marker: "#f97316", polygon: "#f97316" },
|
||||
{ name: "청록", marker: "#06b6d4", polygon: "#06b6d4" },
|
||||
{ name: "분홍", marker: "#ec4899", polygon: "#ec4899" },
|
||||
].map((color) => {
|
||||
const isSelected = dataSource.markerColor === color.marker;
|
||||
return (
|
||||
<button
|
||||
key={color.name}
|
||||
type="button"
|
||||
onClick={() => onChange({
|
||||
markerColor: color.marker,
|
||||
polygonColor: color.polygon,
|
||||
polygonOpacity: 0.5,
|
||||
})}
|
||||
className={`flex h-16 flex-col items-center justify-center gap-1 rounded-md border-2 transition-all hover:scale-105 ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/10 shadow-md"
|
||||
: "border-border bg-background hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="h-6 w-6 rounded-full border-2 border-white shadow-sm"
|
||||
style={{ backgroundColor: color.marker }}
|
||||
/>
|
||||
<span className="text-[10px] font-medium">{color.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
선택한 색상이 마커와 폴리곤에 모두 적용됩니다
|
||||
</p>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{[
|
||||
{ name: "파랑", marker: "#3b82f6", polygon: "#3b82f6" },
|
||||
{ name: "빨강", marker: "#ef4444", polygon: "#ef4444" },
|
||||
{ name: "초록", marker: "#10b981", polygon: "#10b981" },
|
||||
{ name: "노랑", marker: "#f59e0b", polygon: "#f59e0b" },
|
||||
{ name: "보라", marker: "#8b5cf6", polygon: "#8b5cf6" },
|
||||
{ name: "주황", marker: "#f97316", polygon: "#f97316" },
|
||||
{ name: "청록", marker: "#06b6d4", polygon: "#06b6d4" },
|
||||
{ name: "분홍", marker: "#ec4899", polygon: "#ec4899" },
|
||||
].map((color) => {
|
||||
const isSelected = dataSource.markerColor === color.marker;
|
||||
return (
|
||||
<button
|
||||
key={color.name}
|
||||
type="button"
|
||||
onClick={() => onChange({
|
||||
markerColor: color.marker,
|
||||
polygonColor: color.polygon,
|
||||
polygonOpacity: 0.5,
|
||||
})}
|
||||
className={`flex h-12 flex-col items-center justify-center gap-0.5 rounded-md border-2 transition-all hover:scale-105 ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/10 shadow-md"
|
||||
: "border-border bg-background hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="h-5 w-5 rounded-full border-2 border-white shadow-sm"
|
||||
style={{ backgroundColor: color.marker }}
|
||||
/>
|
||||
<span className="text-[9px] font-medium">{color.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테스트 버튼 */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<div className="space-y-2 border-t pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
@ -439,13 +427,13 @@ ORDER BY 하위부서수 DESC`,
|
|||
|
||||
{/* 컬럼 선택 (메트릭 위젯용) - 개선된 UI */}
|
||||
{availableColumns.length > 0 && (
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<div className="space-y-2 border-t pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-sm font-semibold">메트릭 컬럼 선택</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
<Label className="text-xs font-semibold">메트릭 컬럼 선택</Label>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||
{dataSource.selectedColumns && dataSource.selectedColumns.length > 0
|
||||
? `${dataSource.selectedColumns.length}개 컬럼 선택됨`
|
||||
? `${dataSource.selectedColumns.length}개 선택됨`
|
||||
: "모든 컬럼 표시"}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -454,7 +442,7 @@ ORDER BY 하위부서수 DESC`,
|
|||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onChange({ selectedColumns: availableColumns })}
|
||||
className="h-7 text-xs"
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
전체
|
||||
</Button>
|
||||
|
|
@ -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"
|
||||
>
|
||||
해제
|
||||
</Button>
|
||||
|
|
@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 컬럼 카드 그리드 */}
|
||||
<div className="grid grid-cols-1 gap-2 max-h-80 overflow-y-auto">
|
||||
<div className="grid grid-cols-1 gap-1.5 max-h-60 overflow-y-auto">
|
||||
{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"
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(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 (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">데이터가 없습니다</p>
|
||||
</div>
|
||||
);
|
||||
// 데이터를 D3 Chart 컴포넌트 형식으로 변환
|
||||
const chartData = useMemo((): ChartData | null => {
|
||||
if (data.length === 0 || dataSourceConfigs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (dataSourceConfigs.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
차트 설정에서 데이터 소스를 추가하고
|
||||
<br />
|
||||
X축, Y축을 설정해주세요
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const labels = new Set<string>();
|
||||
const datasets: any[] = [];
|
||||
|
||||
// 병합 모드: 여러 데이터 소스를 하나의 라인/바로 합침
|
||||
// 병합 모드: 여러 데이터 소스를 하나로 합침
|
||||
if (mergeMode && dataSourceConfigs.length > 1) {
|
||||
const chartData: any[] = [];
|
||||
const allXValues = new Set<string>();
|
||||
|
||||
// 첫 번째 데이터 소스의 설정을 기준으로 사용
|
||||
const baseConfig = dataSourceConfigs[0];
|
||||
const xAxisField = baseConfig.xAxis;
|
||||
const yAxisField = baseConfig.yAxis[0];
|
||||
|
||||
// 모든 데이터 소스에서 데이터 수집 (X축 값 기준)
|
||||
// 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 (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="_xValue" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey={yAxisField} name={yAxisField} stroke={COLORS[0]} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
||||
case "bar":
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="_xValue" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey={yAxisField} name={yAxisField} fill={COLORS[0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
||||
case "area":
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="_xValue" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={yAxisField}
|
||||
name={yAxisField}
|
||||
stroke={COLORS[0]}
|
||||
fill={COLORS[0]}
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">병합 모드는 라인, 바, 영역 차트만 지원합니다</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 일반 모드: 각 데이터 소스를 별도의 라인/바로 표시
|
||||
const chartData: any[] = [];
|
||||
const allXValues = new Set<string>();
|
||||
|
||||
// 1단계: 모든 X축 값 수집
|
||||
dataSourceConfigs.forEach((dsConfig) => {
|
||||
const sourceData = data.filter((item) => {
|
||||
const sourceName = dataSources.find((ds) => ds.id === dsConfig.dataSourceId)?.name;
|
||||
return item._source === sourceName;
|
||||
});
|
||||
|
||||
sourceData.forEach((item) => {
|
||||
const xValue = item[dsConfig.xAxis];
|
||||
if (xValue !== undefined) {
|
||||
allXValues.add(String(xValue));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 2단계: X축 값별로 데이터 병합
|
||||
allXValues.forEach((xValue) => {
|
||||
const dataPoint: any = { _xValue: xValue };
|
||||
|
||||
dataSourceConfigs.forEach((dsConfig, index) => {
|
||||
const sourceName = dataSources.find((ds) => ds.id === dsConfig.dataSourceId)?.name || `소스 ${index + 1}`;
|
||||
const sourceData = data.filter((item) => item._source === sourceName);
|
||||
const matchingItem = sourceData.find((item) => String(item[dsConfig.xAxis]) === xValue);
|
||||
|
||||
if (matchingItem && dsConfig.yAxis.length > 0) {
|
||||
const yField = dsConfig.yAxis[0];
|
||||
dataPoint[`${sourceName}_${yField}`] = matchingItem[yField];
|
||||
}
|
||||
});
|
||||
|
||||
chartData.push(dataPoint);
|
||||
});
|
||||
|
||||
console.log("📊 일반 모드 차트 데이터:", chartData);
|
||||
console.log("📊 데이터 소스 설정:", dataSourceConfigs);
|
||||
|
||||
// 🆕 혼합 차트 타입 감지 (각 데이터 소스마다 다른 차트 타입이 설정된 경우)
|
||||
const isMixedChart = dataSourceConfigs.some((dsConfig) => dsConfig.chartType);
|
||||
const effectiveChartType = isMixedChart ? "mixed" : chartType;
|
||||
|
||||
// 차트 타입별 렌더링
|
||||
switch (effectiveChartType) {
|
||||
case "mixed":
|
||||
case "line":
|
||||
case "bar":
|
||||
case "area":
|
||||
// 🆕 ComposedChart 사용 (바/라인/영역 혼합 가능)
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="_xValue" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
{dataSourceConfigs.map((dsConfig, index) => {
|
||||
const sourceName =
|
||||
dataSources.find((ds) => ds.id === dsConfig.dataSourceId)?.name || `소스 ${index + 1}`;
|
||||
const yField = dsConfig.yAxis[0];
|
||||
const dataKey = `${sourceName}_${yField}`;
|
||||
const label = dsConfig.label || sourceName;
|
||||
const color = COLORS[index % COLORS.length];
|
||||
|
||||
// 개별 차트 타입 또는 전역 차트 타입 사용
|
||||
const individualChartType = dsConfig.chartType || chartType;
|
||||
|
||||
// 차트 타입에 따라 다른 컴포넌트 렌더링
|
||||
switch (individualChartType) {
|
||||
case "bar":
|
||||
return <Bar key={dsConfig.dataSourceId} dataKey={dataKey} name={label} fill={color} />;
|
||||
case "area":
|
||||
return (
|
||||
<Area
|
||||
key={dsConfig.dataSourceId}
|
||||
type="monotone"
|
||||
dataKey={dataKey}
|
||||
name={label}
|
||||
stroke={color}
|
||||
fill={color}
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
);
|
||||
case "line":
|
||||
default:
|
||||
return (
|
||||
<Line
|
||||
key={dsConfig.dataSourceId}
|
||||
type="monotone"
|
||||
dataKey={dataKey}
|
||||
name={label}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
||||
case "pie":
|
||||
case "donut":
|
||||
// 파이 차트는 첫 번째 데이터 소스만 사용
|
||||
if (dataSourceConfigs.length > 0) {
|
||||
const firstConfig = dataSourceConfigs[0];
|
||||
const sourceName = dataSources.find((ds) => ds.id === firstConfig.dataSourceId)?.name;
|
||||
|
||||
// 해당 데이터 소스의 데이터만 필터링
|
||||
const sourceData = data.filter((item) => item._source === sourceName);
|
||||
|
||||
console.log("🍩 도넛/파이 차트 데이터:", {
|
||||
sourceName,
|
||||
totalData: data.length,
|
||||
filteredData: sourceData.length,
|
||||
firstConfig,
|
||||
sampleItem: sourceData[0],
|
||||
});
|
||||
|
||||
// 파이 차트용 데이터 변환
|
||||
const pieData = sourceData.map((item) => ({
|
||||
name: String(item[firstConfig.xAxis] || "Unknown"),
|
||||
value: Number(item[firstConfig.yAxis[0]]) || 0,
|
||||
}));
|
||||
|
||||
console.log("🍩 변환된 파이 데이터:", pieData);
|
||||
console.log("🍩 첫 번째 데이터:", pieData[0]);
|
||||
console.log("🍩 데이터 타입 체크:", {
|
||||
firstValue: pieData[0]?.value,
|
||||
valueType: typeof pieData[0]?.value,
|
||||
isNumber: typeof pieData[0]?.value === "number",
|
||||
});
|
||||
|
||||
if (pieData.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">파이 차트에 표시할 데이터가 없습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// value가 모두 0인지 체크
|
||||
const totalValue = pieData.reduce((sum, item) => sum + (item.value || 0), 0);
|
||||
if (totalValue === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">모든 값이 0입니다. Y축 필드를 확인해주세요.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius="70%"
|
||||
innerRadius={chartType === "donut" ? "45%" : 0}
|
||||
label={(entry) => `${entry.name}: ${entry.value}`}
|
||||
labelLine={true}
|
||||
fill="#8884d8"
|
||||
>
|
||||
{pieData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend verticalAlign="bottom" height={36} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">파이 차트를 표시하려면 데이터 소스를 설정하세요.</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">지원하지 않는 차트 타입: {chartType}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
return {
|
||||
labels: Array.from(labels),
|
||||
datasets,
|
||||
};
|
||||
}, [data, dataSourceConfigs, mergeMode, dataSources]);
|
||||
|
||||
return (
|
||||
<div className="bg-background flex h-full w-full flex-col">
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{element?.customTitle || "차트"}</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{dataSources?.length || 0}개 데이터 소스 • {data.length}개 데이터
|
||||
{lastRefreshTime && <span className="ml-2">• {lastRefreshTime.toLocaleTimeString("ko-KR")}</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleManualRefresh}
|
||||
disabled={loading}
|
||||
className="h-8 gap-2 text-xs"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-4">
|
||||
<div className="flex h-full w-full flex-col bg-white">
|
||||
{/* 차트 영역 - 전체 공간 사용 */}
|
||||
<div ref={containerRef} className="flex-1 overflow-hidden p-2">
|
||||
{error ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-destructive text-sm">{error}</p>
|
||||
</div>
|
||||
) : !(element?.dataSources || element?.chartConfig?.dataSources) ||
|
||||
(element?.dataSources || element?.chartConfig?.dataSources)?.length === 0 ? (
|
||||
) : !dataSources || dataSources.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">데이터 소스를 연결해주세요</p>
|
||||
</div>
|
||||
) : loading && data.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="mx-auto h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground mt-2 text-xs">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : !chartData ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-center text-sm">
|
||||
차트 설정에서 데이터 소스를 추가하고
|
||||
<br />
|
||||
X축, Y축을 설정해주세요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
renderChart()
|
||||
<Chart
|
||||
chartType={chartType as any}
|
||||
data={chartData}
|
||||
config={chartConfig}
|
||||
width={containerSize.width - 16}
|
||||
height={containerSize.height - 16}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data.length > 0 && (
|
||||
<div className="text-muted-foreground border-t p-2 text-xs">총 {data.length}개 데이터 표시 중</div>
|
||||
)}
|
||||
{/* 푸터 - 주석 처리 (공간 확보) */}
|
||||
{/* {data.length > 0 && (
|
||||
<div className="text-muted-foreground border-t px-3 py-1.5 text-[10px]">
|
||||
총 {data.length.toLocaleString()}개 데이터 표시 중
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue