Files
vexplor/frontend/components/admin/dashboard/ChartConfigPanel.tsx
2025-10-14 16:49:57 +09:00

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