다중데이터베이스 연결 가능하게 함, 차트 위젯은 테스트 용도입니다.

This commit is contained in:
leeheejin 2025-10-28 18:58:40 +09:00
parent 0fe2fa9db1
commit 88d71da1a9
4 changed files with 175 additions and 449 deletions

View File

@ -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>

View File

@ -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%]",
)}
>

View File

@ -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"

View File

@ -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>
);
}