rest api 기능 구현
This commit is contained in:
@@ -17,6 +17,7 @@ interface ChartConfigPanelProps {
|
||||
queryResult?: QueryResult;
|
||||
onConfigChange: (config: ChartConfig) => void;
|
||||
chartType?: string;
|
||||
dataSourceType?: "database" | "api"; // 데이터 소스 타입
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,11 +26,18 @@ interface ChartConfigPanelProps {
|
||||
* - 차트 스타일 설정
|
||||
* - 실시간 미리보기
|
||||
*/
|
||||
export function ChartConfigPanel({ config, queryResult, onConfigChange, chartType }: ChartConfigPanelProps) {
|
||||
export function ChartConfigPanel({
|
||||
config,
|
||||
queryResult,
|
||||
onConfigChange,
|
||||
chartType,
|
||||
dataSourceType,
|
||||
}: ChartConfigPanelProps) {
|
||||
const [currentConfig, setCurrentConfig] = useState<ChartConfig>(config || {});
|
||||
|
||||
// 원형/도넛 차트는 Y축이 필수가 아님
|
||||
// 원형/도넛 차트 또는 REST API는 Y축이 필수가 아님
|
||||
const isPieChart = chartType === "pie" || chartType === "donut";
|
||||
const isApiSource = dataSourceType === "api";
|
||||
|
||||
// 설정 업데이트
|
||||
const updateConfig = useCallback(
|
||||
@@ -41,15 +49,91 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange, chartTyp
|
||||
[currentConfig, onConfigChange],
|
||||
);
|
||||
|
||||
// 사용 가능한 컬럼 목록
|
||||
// 사용 가능한 컬럼 목록 및 타입 정보
|
||||
const availableColumns = queryResult?.columns || [];
|
||||
const columnTypes = queryResult?.columnTypes || {};
|
||||
const sampleData = queryResult?.rows?.[0] || {};
|
||||
|
||||
// 차트에 사용 가능한 컬럼 필터링
|
||||
const simpleColumns = availableColumns.filter((col) => {
|
||||
const type = columnTypes[col];
|
||||
// number, string, boolean만 허용 (object, array는 제외)
|
||||
return !type || type === "number" || type === "string" || type === "boolean";
|
||||
});
|
||||
|
||||
// 숫자 타입 컬럼만 필터링 (Y축용)
|
||||
const numericColumns = availableColumns.filter((col) => columnTypes[col] === "number");
|
||||
|
||||
// 복잡한 타입의 컬럼 (경고 표시용)
|
||||
const complexColumns = availableColumns.filter((col) => {
|
||||
const type = columnTypes[col];
|
||||
return type === "object" || type === "array";
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 데이터 필드 매핑 */}
|
||||
{queryResult && (
|
||||
<>
|
||||
{/* API 응답 미리보기 */}
|
||||
{queryResult.rows && queryResult.rows.length > 0 && (
|
||||
<Card className="border-blue-200 bg-blue-50 p-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-blue-600" />
|
||||
<h4 className="font-semibold text-blue-900">📋 API 응답 데이터 미리보기</h4>
|
||||
</div>
|
||||
<div className="rounded bg-white p-3 text-xs">
|
||||
<div className="mb-2 text-gray-600">총 {queryResult.totalRows}개 데이터 중 첫 번째 행:</div>
|
||||
<pre className="overflow-x-auto text-gray-800">{JSON.stringify(sampleData, null, 2)}</pre>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 타입 정보 */}
|
||||
{Object.keys(columnTypes).length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="mb-2 text-xs font-medium text-gray-700">컬럼 타입 분석:</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableColumns.map((col) => {
|
||||
const type = columnTypes[col];
|
||||
const isComplex = type === "object" || type === "array";
|
||||
return (
|
||||
<Badge key={col} variant={isComplex ? "destructive" : "secondary"} className="text-xs">
|
||||
{col}: {type}
|
||||
{isComplex && " ⚠️"}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 복잡한 타입 경고 */}
|
||||
{complexColumns.length > 0 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="font-semibold">⚠️ 차트에 사용할 수 없는 컬럼 감지</div>
|
||||
<div className="mt-1 text-sm">
|
||||
다음 컬럼은 객체 또는 배열 타입이라서 차트 축으로 선택할 수 없습니다:
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{complexColumns.map((col) => (
|
||||
<Badge key={col} variant="outline" className="bg-red-50">
|
||||
{col} ({columnTypes[col]})
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-600">
|
||||
💡 <strong>해결 방법:</strong> JSON Path를 사용하여 중첩된 객체 내부의 값을 직접 추출하세요.
|
||||
<br />
|
||||
예: <code className="rounded bg-gray-100 px-1">main</code> 또는{" "}
|
||||
<code className="rounded bg-gray-100 px-1">data.items</code>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 차트 제목 */}
|
||||
<div className="space-y-2">
|
||||
<Label>차트 제목</Label>
|
||||
@@ -74,64 +158,157 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange, chartTyp
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableColumns.map((col) => (
|
||||
<SelectItem key={col} value={col}>
|
||||
{col} {sampleData[col] && `(예: ${sampleData[col]})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
{simpleColumns.map((col) => {
|
||||
const type = columnTypes[col] || "unknown";
|
||||
const preview = sampleData[col];
|
||||
const previewText =
|
||||
preview !== undefined && preview !== null
|
||||
? typeof preview === "object"
|
||||
? JSON.stringify(preview).substring(0, 30)
|
||||
: String(preview).substring(0, 30)
|
||||
: "";
|
||||
|
||||
return (
|
||||
<SelectItem key={col} value={col}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{col}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{type}
|
||||
</Badge>
|
||||
{previewText && <span className="text-xs text-gray-500">(예: {previewText})</span>}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{simpleColumns.length === 0 && (
|
||||
<p className="text-xs text-red-500">⚠️ 사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Y축 설정 (다중 선택 가능) */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Y축 (값) - 여러 개 선택 가능
|
||||
{!isPieChart && <span className="ml-1 text-red-500">*</span>}
|
||||
{isPieChart && <span className="ml-2 text-xs text-gray-500">(선택사항)</span>}
|
||||
{!isPieChart && !isApiSource && <span className="ml-1 text-red-500">*</span>}
|
||||
{(isPieChart || isApiSource) && (
|
||||
<span className="ml-2 text-xs text-gray-500">(선택사항 - 그룹핑+집계 사용 가능)</span>
|
||||
)}
|
||||
</Label>
|
||||
<Card className="max-h-60 overflow-y-auto p-3">
|
||||
<div className="space-y-2">
|
||||
{availableColumns.map((col) => {
|
||||
const isSelected = Array.isArray(currentConfig.yAxis)
|
||||
? currentConfig.yAxis.includes(col)
|
||||
: currentConfig.yAxis === col;
|
||||
{/* 숫자 타입 우선 표시 */}
|
||||
{numericColumns.length > 0 && (
|
||||
<>
|
||||
<div className="mb-2 text-xs font-medium text-green-700">✅ 숫자 타입 (권장)</div>
|
||||
{numericColumns.map((col) => {
|
||||
const isSelected = Array.isArray(currentConfig.yAxis)
|
||||
? currentConfig.yAxis.includes(col)
|
||||
: currentConfig.yAxis === col;
|
||||
|
||||
return (
|
||||
<div key={col} className="flex items-center gap-2 rounded p-2 hover:bg-gray-50">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => {
|
||||
const currentYAxis = Array.isArray(currentConfig.yAxis)
|
||||
? currentConfig.yAxis
|
||||
: currentConfig.yAxis
|
||||
? [currentConfig.yAxis]
|
||||
: [];
|
||||
return (
|
||||
<div
|
||||
key={col}
|
||||
className="flex items-center gap-2 rounded border-l-2 border-green-500 bg-green-50 p-2"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => {
|
||||
const currentYAxis = Array.isArray(currentConfig.yAxis)
|
||||
? currentConfig.yAxis
|
||||
: currentConfig.yAxis
|
||||
? [currentConfig.yAxis]
|
||||
: [];
|
||||
|
||||
let newYAxis: string | string[];
|
||||
if (checked) {
|
||||
newYAxis = [...currentYAxis, col];
|
||||
} else {
|
||||
newYAxis = currentYAxis.filter((c) => c !== col);
|
||||
}
|
||||
let newYAxis: string | string[];
|
||||
if (checked) {
|
||||
newYAxis = [...currentYAxis, col];
|
||||
} else {
|
||||
newYAxis = currentYAxis.filter((c) => c !== col);
|
||||
}
|
||||
|
||||
// 단일 값이면 문자열로, 다중 값이면 배열로
|
||||
if (newYAxis.length === 1) {
|
||||
newYAxis = newYAxis[0];
|
||||
}
|
||||
if (newYAxis.length === 1) {
|
||||
newYAxis = newYAxis[0];
|
||||
}
|
||||
|
||||
updateConfig({ yAxis: newYAxis });
|
||||
}}
|
||||
/>
|
||||
<Label className="flex-1 cursor-pointer text-sm font-normal">
|
||||
{col}
|
||||
{sampleData[col] && <span className="ml-2 text-xs text-gray-500">(예: {sampleData[col]})</span>}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
updateConfig({ yAxis: newYAxis });
|
||||
}}
|
||||
/>
|
||||
<Label className="flex-1 cursor-pointer text-sm font-normal">
|
||||
<span className="font-medium">{col}</span>
|
||||
<Badge variant="outline" className="ml-2 bg-green-100 text-xs">
|
||||
number
|
||||
</Badge>
|
||||
{sampleData[col] !== undefined && (
|
||||
<span className="ml-2 text-xs text-gray-600">(예: {sampleData[col]})</span>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 기타 간단한 타입 */}
|
||||
{simpleColumns.filter((col) => !numericColumns.includes(col)).length > 0 && (
|
||||
<>
|
||||
{numericColumns.length > 0 && <div className="my-2 border-t"></div>}
|
||||
<div className="mb-2 text-xs font-medium text-gray-600">📝 기타 타입</div>
|
||||
{simpleColumns
|
||||
.filter((col) => !numericColumns.includes(col))
|
||||
.map((col) => {
|
||||
const isSelected = Array.isArray(currentConfig.yAxis)
|
||||
? currentConfig.yAxis.includes(col)
|
||||
: currentConfig.yAxis === col;
|
||||
const type = columnTypes[col];
|
||||
|
||||
return (
|
||||
<div key={col} className="flex items-center gap-2 rounded p-2 hover:bg-gray-50">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => {
|
||||
const currentYAxis = Array.isArray(currentConfig.yAxis)
|
||||
? currentConfig.yAxis
|
||||
: currentConfig.yAxis
|
||||
? [currentConfig.yAxis]
|
||||
: [];
|
||||
|
||||
let newYAxis: string | string[];
|
||||
if (checked) {
|
||||
newYAxis = [...currentYAxis, col];
|
||||
} else {
|
||||
newYAxis = currentYAxis.filter((c) => c !== col);
|
||||
}
|
||||
|
||||
if (newYAxis.length === 1) {
|
||||
newYAxis = newYAxis[0];
|
||||
}
|
||||
|
||||
updateConfig({ yAxis: newYAxis });
|
||||
}}
|
||||
/>
|
||||
<Label className="flex-1 cursor-pointer text-sm font-normal">
|
||||
{col}
|
||||
<Badge variant="outline" className="ml-2 text-xs">
|
||||
{type}
|
||||
</Badge>
|
||||
{sampleData[col] !== undefined && (
|
||||
<span className="ml-2 text-xs text-gray-500">
|
||||
(예: {String(sampleData[col]).substring(0, 30)})
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
{simpleColumns.length === 0 && (
|
||||
<p className="text-xs text-red-500">⚠️ 사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500">
|
||||
팁: 여러 항목을 선택하면 비교 차트가 생성됩니다 (예: 갤럭시 vs 아이폰)
|
||||
</p>
|
||||
@@ -279,10 +456,22 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange, chartTyp
|
||||
</Card>
|
||||
|
||||
{/* 필수 필드 확인 */}
|
||||
{(!currentConfig.xAxis || !currentConfig.yAxis) && (
|
||||
{!currentConfig.xAxis && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>X축과 Y축을 모두 설정해야 차트가 표시됩니다.</AlertDescription>
|
||||
<AlertDescription>X축은 필수입니다.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{!isPieChart && !isApiSource && !currentConfig.yAxis && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>Y축을 설정해야 차트가 표시됩니다.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{(isPieChart || isApiSource) && !currentConfig.yAxis && !currentConfig.aggregation && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>Y축 또는 집계 함수(COUNT 등)를 설정해야 차트가 표시됩니다.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -112,12 +112,21 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||
}
|
||||
|
||||
// 저장 가능 여부 확인
|
||||
const isPieChart = element.subtype === "pie" || element.subtype === "donut";
|
||||
const isApiSource = dataSource.type === "api";
|
||||
|
||||
const canSave =
|
||||
currentStep === 2 &&
|
||||
queryResult &&
|
||||
queryResult.rows.length > 0 &&
|
||||
chartConfig.xAxis &&
|
||||
(chartConfig.yAxis || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0));
|
||||
(isPieChart || isApiSource
|
||||
? // 파이/도넛 차트 또는 REST API: Y축 또는 집계 함수 필요
|
||||
chartConfig.yAxis ||
|
||||
(Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0) ||
|
||||
chartConfig.aggregation === "count"
|
||||
: // 일반 차트 (DB): Y축 필수
|
||||
chartConfig.yAxis || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0));
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
@@ -182,6 +191,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
chartType={element.subtype}
|
||||
dataSourceType={dataSource.type}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center">
|
||||
|
||||
@@ -45,7 +45,7 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char
|
||||
|
||||
// REST API vs Database 분기
|
||||
if (element.dataSource.type === "api" && element.dataSource.endpoint) {
|
||||
// REST API
|
||||
// REST API - 백엔드 프록시를 통한 호출 (CORS 우회)
|
||||
const params = new URLSearchParams();
|
||||
if (element.dataSource.queryParams) {
|
||||
Object.entries(element.dataSource.queryParams).forEach(([key, value]) => {
|
||||
@@ -55,27 +55,30 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char
|
||||
});
|
||||
}
|
||||
|
||||
let url = element.dataSource.endpoint;
|
||||
const queryString = params.toString();
|
||||
if (queryString) {
|
||||
url += (url.includes("?") ? "&" : "?") + queryString;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...element.dataSource.headers,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: element.dataSource.endpoint,
|
||||
method: "GET",
|
||||
headers: element.dataSource.headers || {},
|
||||
queryParams: Object.fromEntries(params),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const apiData = await response.json();
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "외부 API 호출 실패");
|
||||
}
|
||||
|
||||
const apiData = result.data;
|
||||
|
||||
// JSON Path 처리
|
||||
let processedData = apiData;
|
||||
@@ -187,7 +190,11 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char
|
||||
}
|
||||
|
||||
// 데이터나 설정이 없으면
|
||||
if (!chartData || !element.chartConfig?.xAxis || !element.chartConfig?.yAxis) {
|
||||
const isPieChart = element.subtype === "pie" || element.subtype === "donut";
|
||||
const isApiSource = element.dataSource?.type === "api";
|
||||
const needsYAxis = !(isPieChart || isApiSource) || (!element.chartConfig?.aggregation && !element.chartConfig?.yAxis);
|
||||
|
||||
if (!chartData || !element.chartConfig?.xAxis || (needsYAxis && !element.chartConfig?.yAxis)) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-gray-500">
|
||||
<div className="text-center">
|
||||
|
||||
@@ -91,30 +91,31 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
||||
});
|
||||
}
|
||||
|
||||
// URL 구성
|
||||
let url = dataSource.endpoint;
|
||||
const queryString = params.toString();
|
||||
if (queryString) {
|
||||
url += (url.includes("?") ? "&" : "?") + queryString;
|
||||
}
|
||||
|
||||
// 헤더 구성
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...dataSource.headers,
|
||||
};
|
||||
|
||||
// 외부 API 직접 호출
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
// 백엔드 프록시를 통한 외부 API 호출 (CORS 우회)
|
||||
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: dataSource.endpoint,
|
||||
method: "GET",
|
||||
headers: dataSource.headers || {},
|
||||
queryParams: Object.fromEntries(params),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const apiData = await response.json();
|
||||
const apiResponse = await response.json();
|
||||
|
||||
if (!apiResponse.success) {
|
||||
throw new Error(apiResponse.message || "외부 API 호출 실패");
|
||||
}
|
||||
|
||||
const apiData = apiResponse.data;
|
||||
|
||||
// JSON Path 처리
|
||||
let data = apiData;
|
||||
@@ -132,18 +133,43 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
||||
// 배열이 아니면 배열로 변환
|
||||
const rows = Array.isArray(data) ? data : [data];
|
||||
|
||||
// 컬럼 추출
|
||||
const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
|
||||
if (rows.length === 0) {
|
||||
throw new Error("API 응답에 데이터가 없습니다");
|
||||
}
|
||||
|
||||
const result: QueryResult = {
|
||||
// 컬럼 추출 및 타입 분석
|
||||
const firstRow = rows[0];
|
||||
const columns = Object.keys(firstRow);
|
||||
|
||||
// 각 컬럼의 타입 분석
|
||||
const columnTypes: Record<string, string> = {};
|
||||
columns.forEach((col) => {
|
||||
const value = firstRow[col];
|
||||
if (value === null || value === undefined) {
|
||||
columnTypes[col] = "null";
|
||||
} else if (Array.isArray(value)) {
|
||||
columnTypes[col] = "array";
|
||||
} else if (typeof value === "object") {
|
||||
columnTypes[col] = "object";
|
||||
} else if (typeof value === "number") {
|
||||
columnTypes[col] = "number";
|
||||
} else if (typeof value === "boolean") {
|
||||
columnTypes[col] = "boolean";
|
||||
} else {
|
||||
columnTypes[col] = "string";
|
||||
}
|
||||
});
|
||||
|
||||
const queryResult: QueryResult = {
|
||||
columns,
|
||||
rows,
|
||||
totalRows: rows.length,
|
||||
executionTime: 0,
|
||||
columnTypes, // 타입 정보 추가
|
||||
};
|
||||
|
||||
setTestResult(result);
|
||||
onTestResult?.(result);
|
||||
setTestResult(queryResult);
|
||||
onTestResult?.(queryResult);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다";
|
||||
setTestError(errorMessage);
|
||||
|
||||
@@ -128,6 +128,7 @@ export interface QueryResult {
|
||||
totalRows: number; // 전체 행 수
|
||||
executionTime: number; // 실행 시간 (ms)
|
||||
error?: string; // 오류 메시지
|
||||
columnTypes?: Record<string, string>; // 각 컬럼의 타입 정보 (number, string, object, array 등)
|
||||
}
|
||||
|
||||
// 시계 위젯 설정
|
||||
|
||||
@@ -8,19 +8,45 @@ export function transformQueryResultToChartData(queryResult: QueryResult, config
|
||||
return null;
|
||||
}
|
||||
|
||||
let rows = queryResult.rows;
|
||||
|
||||
// 그룹핑 처리
|
||||
if (config.groupBy && config.groupBy !== "__none__") {
|
||||
rows = applyGrouping(rows, config.groupBy, config.aggregation, config.yAxis);
|
||||
}
|
||||
|
||||
// X축 라벨 추출
|
||||
const labels = queryResult.rows.map((row) => String(row[config.xAxis!] || ""));
|
||||
const labels = rows.map((row) => String(row[config.xAxis!] || ""));
|
||||
|
||||
// Y축 데이터 추출
|
||||
const yAxisFields = Array.isArray(config.yAxis) ? config.yAxis : config.yAxis ? [config.yAxis] : [];
|
||||
|
||||
// 집계 함수가 COUNT이고 Y축이 없으면 자동으로 count 필드 추가
|
||||
if (config.aggregation === "count" && yAxisFields.length === 0) {
|
||||
const datasets: ChartDataset[] = [
|
||||
{
|
||||
label: "개수",
|
||||
data: rows.map((row) => {
|
||||
const value = row["count"];
|
||||
return typeof value === "number" ? value : parseFloat(String(value)) || 0;
|
||||
}),
|
||||
color: config.colors?.[0],
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets,
|
||||
};
|
||||
}
|
||||
|
||||
if (yAxisFields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 각 Y축 필드에 대해 데이터셋 생성
|
||||
const datasets: ChartDataset[] = yAxisFields.map((field, index) => {
|
||||
const data = queryResult.rows.map((row) => {
|
||||
const data = rows.map((row) => {
|
||||
const value = row[field];
|
||||
return typeof value === "number" ? value : parseFloat(String(value)) || 0;
|
||||
});
|
||||
@@ -38,6 +64,73 @@ export function transformQueryResultToChartData(queryResult: QueryResult, config
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹핑 및 집계 처리
|
||||
*/
|
||||
function applyGrouping(
|
||||
rows: Record<string, any>[],
|
||||
groupByField: string,
|
||||
aggregation?: "sum" | "avg" | "count" | "max" | "min",
|
||||
yAxis?: string | string[],
|
||||
): Record<string, any>[] {
|
||||
// 그룹별로 데이터 묶기
|
||||
const groups = new Map<string, Record<string, any>[]>();
|
||||
|
||||
rows.forEach((row) => {
|
||||
const key = String(row[groupByField] || "");
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, []);
|
||||
}
|
||||
groups.get(key)!.push(row);
|
||||
});
|
||||
|
||||
// 각 그룹에 대해 집계 수행
|
||||
const aggregatedRows: Record<string, any>[] = [];
|
||||
|
||||
groups.forEach((groupRows, key) => {
|
||||
const aggregatedRow: Record<string, any> = {
|
||||
[groupByField]: key,
|
||||
};
|
||||
|
||||
// Y축 필드에 대해 집계
|
||||
const yAxisFields = Array.isArray(yAxis) ? yAxis : yAxis ? [yAxis] : [];
|
||||
|
||||
if (aggregation === "count") {
|
||||
// COUNT: 그룹의 행 개수
|
||||
aggregatedRow["count"] = groupRows.length;
|
||||
} else if (yAxisFields.length > 0) {
|
||||
yAxisFields.forEach((field) => {
|
||||
const values = groupRows.map((row) => {
|
||||
const value = row[field];
|
||||
return typeof value === "number" ? value : parseFloat(String(value)) || 0;
|
||||
});
|
||||
|
||||
switch (aggregation) {
|
||||
case "sum":
|
||||
aggregatedRow[field] = values.reduce((a, b) => a + b, 0);
|
||||
break;
|
||||
case "avg":
|
||||
aggregatedRow[field] = values.reduce((a, b) => a + b, 0) / values.length;
|
||||
break;
|
||||
case "max":
|
||||
aggregatedRow[field] = Math.max(...values);
|
||||
break;
|
||||
case "min":
|
||||
aggregatedRow[field] = Math.min(...values);
|
||||
break;
|
||||
default:
|
||||
// 집계 없으면 첫 번째 값 사용
|
||||
aggregatedRow[field] = values[0];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
aggregatedRows.push(aggregatedRow);
|
||||
});
|
||||
|
||||
return aggregatedRows;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답을 차트 데이터로 변환
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user