453 lines
19 KiB
TypeScript
453 lines
19 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState, useCallback } from "react";
|
||
import { ChartConfig, QueryResult } from "./types";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Card } from "@/components/ui/card";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
import { Separator } from "@/components/ui/separator";
|
||
import { TrendingUp, AlertCircle } from "lucide-react";
|
||
|
||
interface ChartConfigPanelProps {
|
||
config?: ChartConfig;
|
||
queryResult?: QueryResult;
|
||
onConfigChange: (config: ChartConfig) => void;
|
||
chartType?: string;
|
||
dataSourceType?: "database" | "api"; // 데이터 소스 타입
|
||
}
|
||
|
||
/**
|
||
* 차트 설정 패널 컴포넌트
|
||
* - 데이터 필드 매핑 설정
|
||
* - 차트 스타일 설정
|
||
* - 실시간 미리보기
|
||
*/
|
||
export function ChartConfigPanel({
|
||
config,
|
||
queryResult,
|
||
onConfigChange,
|
||
chartType,
|
||
dataSourceType,
|
||
}: ChartConfigPanelProps) {
|
||
const [currentConfig, setCurrentConfig] = useState<ChartConfig>(config || {});
|
||
|
||
// 원형/도넛 차트 또는 REST API는 Y축이 필수가 아님
|
||
const isPieChart = chartType === "pie" || chartType === "donut";
|
||
const isApiSource = dataSourceType === "api";
|
||
|
||
// 설정 업데이트
|
||
const updateConfig = useCallback(
|
||
(updates: Partial<ChartConfig>) => {
|
||
const newConfig = { ...currentConfig, ...updates };
|
||
setCurrentConfig(newConfig);
|
||
onConfigChange(newConfig);
|
||
},
|
||
[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>
|
||
</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>
|
||
<Input
|
||
type="text"
|
||
value={currentConfig.title || ""}
|
||
onChange={(e) => updateConfig({ title: e.target.value })}
|
||
placeholder="차트 제목을 입력하세요"
|
||
/>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
{/* X축 설정 */}
|
||
<div className="space-y-2">
|
||
<Label>
|
||
X축 (카테고리)
|
||
<span className="ml-1 text-red-500">*</span>
|
||
</Label>
|
||
<Select value={currentConfig.xAxis || undefined} onValueChange={(value) => updateConfig({ xAxis: value })}>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="선택하세요" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{simpleColumns.map((col) => {
|
||
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}>
|
||
{col}
|
||
{previewText && <span className="ml-2 text-xs text-gray-500">(예: {previewText})</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 && !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">
|
||
{/* 숫자 타입 우선 표시 */}
|
||
{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 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);
|
||
}
|
||
|
||
if (newYAxis.length === 1) {
|
||
newYAxis = newYAxis[0];
|
||
}
|
||
|
||
updateConfig({ yAxis: newYAxis });
|
||
}}
|
||
/>
|
||
<Label className="flex-1 cursor-pointer text-sm font-normal">
|
||
<span className="font-medium">{col}</span>
|
||
{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;
|
||
|
||
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}
|
||
{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>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
{/* 집계 함수 */}
|
||
<div className="space-y-2">
|
||
<Label>
|
||
집계 함수
|
||
<span className="ml-2 text-xs text-gray-500">(데이터 처리 방식)</span>
|
||
</Label>
|
||
<Select
|
||
value={currentConfig.aggregation || "none"}
|
||
onValueChange={(value) =>
|
||
updateConfig({
|
||
aggregation: value === "none" ? undefined : (value as "sum" | "avg" | "count" | "max" | "min"),
|
||
})
|
||
}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">없음 - SQL에서 집계됨</SelectItem>
|
||
<SelectItem value="sum">합계 (SUM) - 모든 값을 더함</SelectItem>
|
||
<SelectItem value="avg">평균 (AVG) - 평균값 계산</SelectItem>
|
||
<SelectItem value="count">개수 (COUNT) - 데이터 개수</SelectItem>
|
||
<SelectItem value="max">최대값 (MAX) - 가장 큰 값</SelectItem>
|
||
<SelectItem value="min">최소값 (MIN) - 가장 작은 값</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<p className="text-xs text-gray-500">
|
||
💡 그룹핑 필드와 함께 사용하면 자동으로 데이터를 집계합니다. (예: 부서별 개수, 월별 합계)
|
||
</p>
|
||
</div>
|
||
|
||
{/* 그룹핑 필드 (선택사항) */}
|
||
<div className="space-y-2">
|
||
<Label>
|
||
그룹핑 필드 (선택사항)
|
||
<span className="ml-2 text-xs text-gray-500">(같은 값끼리 묶어서 집계)</span>
|
||
</Label>
|
||
<Select
|
||
value={currentConfig.groupBy || undefined}
|
||
onValueChange={(value) => updateConfig({ groupBy: value })}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="없음" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="__none__">없음</SelectItem>
|
||
{availableColumns.map((col) => (
|
||
<SelectItem key={col} value={col}>
|
||
{col}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
{/* 차트 색상 */}
|
||
<div className="space-y-2">
|
||
<Label>차트 색상</Label>
|
||
<div className="grid grid-cols-4 gap-2">
|
||
{[
|
||
["#3B82F6", "#EF4444", "#10B981", "#F59E0B"], // 기본
|
||
["#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"], // 밝은
|
||
["#1F2937", "#374151", "#6B7280", "#9CA3AF"], // 회색
|
||
["#DC2626", "#EA580C", "#CA8A04", "#65A30D"], // 따뜻한
|
||
].map((colorSet, setIdx) => (
|
||
<button
|
||
key={setIdx}
|
||
type="button"
|
||
onClick={() => updateConfig({ colors: colorSet })}
|
||
className={`flex h-8 rounded border-2 transition-colors ${
|
||
JSON.stringify(currentConfig.colors) === JSON.stringify(colorSet)
|
||
? "border-gray-800"
|
||
: "border-gray-300 hover:border-gray-400"
|
||
}`}
|
||
>
|
||
{colorSet.map((color, idx) => (
|
||
<div
|
||
key={idx}
|
||
className="flex-1 first:rounded-l last:rounded-r"
|
||
style={{ backgroundColor: color }}
|
||
/>
|
||
))}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 범례 표시 */}
|
||
<div className="flex items-center gap-2">
|
||
<Checkbox
|
||
id="showLegend"
|
||
checked={currentConfig.showLegend !== false}
|
||
onCheckedChange={(checked) => updateConfig({ showLegend: checked as boolean })}
|
||
/>
|
||
<Label htmlFor="showLegend" className="cursor-pointer font-normal">
|
||
범례 표시
|
||
</Label>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
{/* 설정 미리보기 */}
|
||
<Card className="bg-gray-50 p-4">
|
||
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-gray-700">
|
||
<TrendingUp className="h-4 w-4" />
|
||
설정 미리보기
|
||
</div>
|
||
<div className="space-y-2 text-xs text-gray-600">
|
||
<div className="flex gap-2">
|
||
<span className="font-medium">X축:</span>
|
||
<span>{currentConfig.xAxis || "미설정"}</span>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<span className="font-medium">Y축:</span>
|
||
<span>
|
||
{Array.isArray(currentConfig.yAxis) && currentConfig.yAxis.length > 0
|
||
? `${currentConfig.yAxis.length}개 (${currentConfig.yAxis.join(", ")})`
|
||
: currentConfig.yAxis || "미설정"}
|
||
</span>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<span className="font-medium">집계:</span>
|
||
<span>{currentConfig.aggregation || "없음"}</span>
|
||
</div>
|
||
{currentConfig.groupBy && (
|
||
<div className="flex gap-2">
|
||
<span className="font-medium">그룹핑:</span>
|
||
<span>{currentConfig.groupBy}</span>
|
||
</div>
|
||
)}
|
||
<div className="flex gap-2">
|
||
<span className="font-medium">데이터 행 수:</span>
|
||
<Badge variant="secondary">{queryResult.rows.length}개</Badge>
|
||
</div>
|
||
{Array.isArray(currentConfig.yAxis) && currentConfig.yAxis.length > 1 && (
|
||
<div className="mt-2 text-blue-600">✨ 다중 시리즈 차트가 생성됩니다!</div>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
|
||
{/* 필수 필드 확인 */}
|
||
{!currentConfig.xAxis && (
|
||
<Alert variant="destructive">
|
||
<AlertCircle className="h-4 w-4" />
|
||
<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>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|