차트 구현 phase1 완료

This commit is contained in:
dohyeons
2025-10-14 13:59:54 +09:00
parent 2050a22656
commit e667ee7106
9 changed files with 1485 additions and 502 deletions

View File

@@ -1,7 +1,16 @@
'use client';
"use client";
import React, { useState, useCallback } from 'react';
import { ChartConfig, QueryResult } from './types';
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;
@@ -19,27 +28,32 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC
const [currentConfig, setCurrentConfig] = useState<ChartConfig>(config || {});
// 설정 업데이트
const updateConfig = useCallback((updates: Partial<ChartConfig>) => {
const newConfig = { ...currentConfig, ...updates };
setCurrentConfig(newConfig);
onConfigChange(newConfig);
}, [currentConfig, onConfigChange]);
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-4">
<h4 className="text-lg font-semibold text-gray-800"> </h4>
<div className="space-y-6">
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-blue-600" />
<h4 className="text-lg font-semibold text-gray-800"> </h4>
</div>
{/* 쿼리 결과가 없을 때 */}
{!queryResult && (
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="text-yellow-800 text-sm">
💡 SQL .
</div>
</div>
<Alert>
<TrendingUp className="h-4 w-4" />
<AlertDescription> SQL .</AlertDescription>
</Alert>
)}
{/* 데이터 필드 매핑 */}
@@ -47,154 +61,157 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC
<>
{/* 차트 제목 */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700"> </label>
<input
<Label> </Label>
<Input
type="text"
value={currentConfig.title || ''}
value={currentConfig.title || ""}
onChange={(e) => updateConfig({ title: e.target.value })}
placeholder="차트 제목을 입력하세요"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
</div>
<Separator />
{/* X축 설정 */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
<Label>
X축 ()
<span className="text-red-500 ml-1">*</span>
</label>
<select
value={currentConfig.xAxis || ''}
onChange={(e) => updateConfig({ xAxis: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value=""></option>
{availableColumns.map((col) => (
<option key={col} value={col}>
{col} {sampleData[col] && `(예: ${sampleData[col]})`}
</option>
))}
</select>
<span className="ml-1 text-red-500">*</span>
</Label>
<Select value={currentConfig.xAxis || ""} 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 className="block text-sm font-medium text-gray-700">
<Label>
Y축 () -
<span className="text-red-500 ml-1">*</span>
</label>
<div className="space-y-2 max-h-60 overflow-y-auto border border-gray-300 rounded-lg p-2 bg-white">
{availableColumns.map((col) => {
const isSelected = Array.isArray(currentConfig.yAxis)
? currentConfig.yAxis.includes(col)
: currentConfig.yAxis === col;
return (
<label
key={col}
className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded cursor-pointer"
>
<input
type="checkbox"
checked={isSelected}
onChange={(e) => {
const currentYAxis = Array.isArray(currentConfig.yAxis)
? currentConfig.yAxis
: currentConfig.yAxis ? [currentConfig.yAxis] : [];
let newYAxis: string | string[];
if (e.target.checked) {
newYAxis = [...currentYAxis, col];
} else {
newYAxis = currentYAxis.filter(c => c !== col);
}
// 단일 값이면 문자열로, 다중 값이면 배열로
if (newYAxis.length === 1) {
newYAxis = newYAxis[0];
}
updateConfig({ yAxis: newYAxis });
}}
className="rounded"
/>
<span className="text-sm flex-1">
{col}
{sampleData[col] && (
<span className="text-gray-500 text-xs ml-2">
(: {sampleData[col]})
</span>
)}
</span>
</label>
);
})}
</div>
<div className="text-xs text-gray-500">
💡 : 여러 (: 갤럭시 vs )
</div>
<span className="ml-1 text-red-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 className="block text-sm font-medium text-gray-700">
<Label>
<span className="text-gray-500 text-xs ml-2">( )</span>
</label>
<select
value={currentConfig.aggregation || 'sum'}
onChange={(e) => updateConfig({ aggregation: e.target.value as any })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
<span className="ml-2 text-xs text-gray-500">( )</span>
</Label>
<Select
value={currentConfig.aggregation || "sum"}
onValueChange={(value) => updateConfig({ aggregation: value as any })}
>
<option value="sum"> (SUM) - </option>
<option value="avg"> (AVG) - </option>
<option value="count"> (COUNT) - </option>
<option value="max"> (MAX) - </option>
<option value="min"> (MIN) - </option>
</select>
<div className="text-xs text-gray-500">
💡 .
SQL .
</div>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<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 className="block text-sm font-medium text-gray-700">
()
</label>
<select
value={currentConfig.groupBy || ''}
onChange={(e) => updateConfig({ groupBy: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value=""></option>
{availableColumns.map((col) => (
<option key={col} value={col}>
{col}
</option>
))}
</select>
<Label> ()</Label>
<Select value={currentConfig.groupBy || ""} onValueChange={(value) => updateConfig({ groupBy: value })}>
<SelectTrigger>
<SelectValue placeholder="없음" />
</SelectTrigger>
<SelectContent>
<SelectItem value=""></SelectItem>
{availableColumns.map((col) => (
<SelectItem key={col} value={col}>
{col}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Separator />
{/* 차트 색상 */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700"> </label>
<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'], // 따뜻한
["#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={`
h-8 rounded border-2 flex
${JSON.stringify(currentConfig.colors) === JSON.stringify(colorSet)
? 'border-gray-800' : 'border-gray-300'}
`}
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
@@ -210,50 +227,63 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC
{/* 범례 표시 */}
<div className="flex items-center gap-2">
<input
type="checkbox"
<Checkbox
id="showLegend"
checked={currentConfig.showLegend !== false}
onChange={(e) => updateConfig({ showLegend: e.target.checked })}
className="rounded"
onCheckedChange={(checked) => updateConfig({ showLegend: checked as boolean })}
/>
<label htmlFor="showLegend" className="text-sm text-gray-700">
<Label htmlFor="showLegend" className="cursor-pointer font-normal">
</label>
</Label>
</div>
<Separator />
{/* 설정 미리보기 */}
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-2">📋 </div>
<div className="text-xs text-muted-foreground space-y-1">
<div><strong>X축:</strong> {currentConfig.xAxis || '미설정'}</div>
<div>
<strong>Y축:</strong>{' '}
{Array.isArray(currentConfig.yAxis)
? `${currentConfig.yAxis.length}개 (${currentConfig.yAxis.join(', ')})`
: currentConfig.yAxis || '미설정'
}
<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}개 (${currentConfig.yAxis.join(", ")})`
: currentConfig.yAxis || "미설정"}
</span>
</div>
<div className="flex gap-2">
<span className="font-medium">:</span>
<span>{currentConfig.aggregation || "sum"}</span>
</div>
<div><strong>:</strong> {currentConfig.aggregation || 'sum'}</div>
{currentConfig.groupBy && (
<div><strong>:</strong> {currentConfig.groupBy}</div>
)}
<div><strong> :</strong> {queryResult.rows.length}</div>
{Array.isArray(currentConfig.yAxis) && currentConfig.yAxis.length > 1 && (
<div className="text-primary mt-2">
!
<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>
</div>
</Card>
{/* 필수 필드 확인 */}
{(!currentConfig.xAxis || !currentConfig.yAxis) && (
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
<div className="text-red-800 text-sm">
X축과 Y축을 .
</div>
</div>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>X축과 Y축을 .</AlertDescription>
</Alert>
)}
</>
)}