293 lines
12 KiB
TypeScript
293 lines
12 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 { Settings, TrendingUp, AlertCircle } from "lucide-react";
|
|
|
|
interface ChartConfigPanelProps {
|
|
config?: ChartConfig;
|
|
queryResult?: QueryResult;
|
|
onConfigChange: (config: ChartConfig) => void;
|
|
chartType?: string;
|
|
}
|
|
|
|
/**
|
|
* 차트 설정 패널 컴포넌트
|
|
* - 데이터 필드 매핑 설정
|
|
* - 차트 스타일 설정
|
|
* - 실시간 미리보기
|
|
*/
|
|
export function ChartConfigPanel({ config, queryResult, onConfigChange, chartType }: ChartConfigPanelProps) {
|
|
const [currentConfig, setCurrentConfig] = useState<ChartConfig>(config || {});
|
|
|
|
// 원형/도넛 차트는 Y축이 필수가 아님
|
|
const isPieChart = chartType === "pie" || chartType === "donut";
|
|
|
|
// 설정 업데이트
|
|
const updateConfig = useCallback(
|
|
(updates: Partial<ChartConfig>) => {
|
|
const newConfig = { ...currentConfig, ...updates };
|
|
setCurrentConfig(newConfig);
|
|
onConfigChange(newConfig);
|
|
},
|
|
[currentConfig, onConfigChange],
|
|
);
|
|
|
|
// 사용 가능한 컬럼 목록
|
|
const availableColumns = queryResult?.columns || [];
|
|
const sampleData = queryResult?.rows?.[0] || {};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* 데이터 필드 매핑 */}
|
|
{queryResult && (
|
|
<>
|
|
{/* 차트 제목 */}
|
|
<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>
|
|
{availableColumns.map((col) => (
|
|
<SelectItem key={col} value={col}>
|
|
{col} {sampleData[col] && `(예: ${sampleData[col]})`}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</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>}
|
|
</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;
|
|
|
|
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] && <span className="ml-2 text-xs text-gray-500">(예: {sampleData[col]})</span>}
|
|
</Label>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</Card>
|
|
<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">
|
|
집계 함수는 현재 쿼리 결과에 적용되지 않습니다. SQL 쿼리에서 직접 집계하는 것을 권장합니다.
|
|
</p>
|
|
</div>
|
|
|
|
{/* 그룹핑 필드 (선택사항) */}
|
|
<div className="space-y-2">
|
|
<Label>그룹핑 필드 (선택사항)</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 || !currentConfig.yAxis) && (
|
|
<Alert variant="destructive">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription>X축과 Y축을 모두 설정해야 차트가 표시됩니다.</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|