449 lines
18 KiB
TypeScript
449 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useCallback, useEffect } from "react";
|
|
import { ChartConfig, QueryResult, isCircularChart, isAxisBasedChart } 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 { 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 { AlertCircle } from "lucide-react";
|
|
import { DateFilterPanel } from "./DateFilterPanel";
|
|
import { extractTableNameFromQuery } from "./utils/queryHelpers";
|
|
import { dashboardApi } from "@/lib/api/dashboard";
|
|
|
|
interface ChartConfigPanelProps {
|
|
config?: ChartConfig;
|
|
queryResult?: QueryResult;
|
|
onConfigChange: (config: ChartConfig) => void;
|
|
chartType?: string;
|
|
dataSourceType?: "database" | "api"; // 데이터 소스 타입
|
|
query?: string; // SQL 쿼리 (테이블명 추출용)
|
|
}
|
|
|
|
/**
|
|
* 차트 설정 패널 컴포넌트
|
|
* - 데이터 필드 매핑 설정
|
|
* - 차트 스타일 설정
|
|
* - 실시간 미리보기
|
|
*/
|
|
export function ChartConfigPanel({
|
|
config,
|
|
queryResult,
|
|
onConfigChange,
|
|
chartType,
|
|
dataSourceType,
|
|
query,
|
|
}: ChartConfigPanelProps) {
|
|
const [currentConfig, setCurrentConfig] = useState<ChartConfig>(config || {});
|
|
const [dateColumns, setDateColumns] = useState<string[]>([]);
|
|
|
|
// 원형 차트 또는 REST API는 Y축이 필수가 아님
|
|
const isPieChart = chartType ? isCircularChart(chartType as any) : false;
|
|
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";
|
|
});
|
|
|
|
// 테이블 스키마에서 실제 날짜 컬럼 가져오기
|
|
useEffect(() => {
|
|
if (!query || !queryResult || dataSourceType === "api") {
|
|
// API 소스는 스키마 조회 불가
|
|
setDateColumns([]);
|
|
return;
|
|
}
|
|
|
|
const tableName = extractTableNameFromQuery(query);
|
|
|
|
if (!tableName) {
|
|
setDateColumns([]);
|
|
return;
|
|
}
|
|
dashboardApi
|
|
.getTableSchema(tableName)
|
|
.then((schema) => {
|
|
// 원본 테이블의 모든 날짜 컬럼을 표시
|
|
// (SELECT에 없어도 WHERE 절에 사용 가능)
|
|
setDateColumns(schema.dateColumns);
|
|
})
|
|
.catch(() => {
|
|
// 실패 시 빈 배열 (날짜 필터 비활성화)
|
|
setDateColumns([]);
|
|
});
|
|
}, [query, queryResult, dataSourceType]);
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* 데이터 필드 매핑 */}
|
|
{queryResult && (
|
|
<>
|
|
{/* 복잡한 타입 경고 */}
|
|
{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-1.5">
|
|
<Label className="text-xs font-medium text-gray-700">차트 제목</Label>
|
|
<Input
|
|
type="text"
|
|
value={currentConfig.title || ""}
|
|
onChange={(e) => updateConfig({ title: e.target.value })}
|
|
placeholder="차트 제목을 입력하세요"
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* X축 설정 */}
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-medium text-gray-700">
|
|
X축 (카테고리)
|
|
<span className="ml-1 text-red-500">*</span>
|
|
</Label>
|
|
<Select value={currentConfig.xAxis || undefined} onValueChange={(value) => updateConfig({ xAxis: value })}>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue placeholder="선택하세요" />
|
|
</SelectTrigger>
|
|
<SelectContent className="z-[99999]">
|
|
{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} className="text-xs">
|
|
{col}
|
|
{previewText && <span className="ml-1.5 text-[10px] text-gray-500">(예: {previewText})</span>}
|
|
</SelectItem>
|
|
);
|
|
})}
|
|
</SelectContent>
|
|
</Select>
|
|
{simpleColumns.length === 0 && (
|
|
<p className="text-[11px] text-red-500">사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Y축 설정 (다중 선택 가능) */}
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-medium text-gray-700">
|
|
Y축 (값) - 여러 개 선택 가능
|
|
{!isPieChart && !isApiSource && <span className="ml-1 text-red-500">*</span>}
|
|
{(isPieChart || isApiSource) && (
|
|
<span className="ml-1.5 text-[11px] text-gray-500">(선택사항 - 그룹핑+집계 사용 가능)</span>
|
|
)}
|
|
</Label>
|
|
<div className="max-h-48 overflow-y-auto rounded border border-gray-200 bg-gray-50 p-2">
|
|
<div className="space-y-1.5">
|
|
{/* 숫자 타입 우선 표시 */}
|
|
{numericColumns.length > 0 && (
|
|
<>
|
|
<div className="mb-1.5 text-[11px] 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-1.5 rounded border-green-500 bg-green-50 p-1.5">
|
|
<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-xs font-normal">
|
|
<span className="font-medium">{col}</span>
|
|
{sampleData[col] !== undefined && (
|
|
<span className="ml-1.5 text-[10px] text-gray-600">(예: {sampleData[col]})</span>
|
|
)}
|
|
</Label>
|
|
</div>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
|
|
{/* 기타 간단한 타입 */}
|
|
{simpleColumns.filter((col) => !numericColumns.includes(col)).length > 0 && (
|
|
<>
|
|
{numericColumns.length > 0 && <div className="my-1.5 border-t"></div>}
|
|
<div className="mb-1.5 text-[11px] 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-1.5 rounded p-1.5 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-xs font-normal">
|
|
{col}
|
|
{sampleData[col] !== undefined && (
|
|
<span className="ml-1.5 text-[10px] text-gray-500">
|
|
(예: {String(sampleData[col]).substring(0, 30)})
|
|
</span>
|
|
)}
|
|
</Label>
|
|
</div>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{simpleColumns.length === 0 && (
|
|
<p className="text-[11px] text-red-500">사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
|
)}
|
|
<p className="text-[11px] text-gray-500">
|
|
팁: 여러 항목을 선택하면 비교 차트가 생성됩니다 (예: 갤럭시 vs 아이폰)
|
|
</p>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* 집계 함수 */}
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-medium text-gray-700">
|
|
집계 함수
|
|
<span className="ml-1.5 text-[11px] 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 className="h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent className="z-[99999]">
|
|
<SelectItem value="none" className="text-xs">
|
|
없음 - SQL에서 집계됨
|
|
</SelectItem>
|
|
<SelectItem value="sum" className="text-xs">
|
|
합계 (SUM) - 모든 값을 더함
|
|
</SelectItem>
|
|
<SelectItem value="avg" className="text-xs">
|
|
평균 (AVG) - 평균값 계산
|
|
</SelectItem>
|
|
<SelectItem value="count" className="text-xs">
|
|
개수 (COUNT) - 데이터 개수
|
|
</SelectItem>
|
|
<SelectItem value="max" className="text-xs">
|
|
최대값 (MAX) - 가장 큰 값
|
|
</SelectItem>
|
|
<SelectItem value="min" className="text-xs">
|
|
최소값 (MIN) - 가장 작은 값
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-[11px] text-gray-500">
|
|
그룹핑 필드와 함께 사용하면 자동으로 데이터를 집계합니다. (예: 부서별 개수, 월별 합계)
|
|
</p>
|
|
</div>
|
|
|
|
{/* 그룹핑 필드 (선택사항) */}
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-medium text-gray-700">
|
|
그룹핑 필드 (선택사항)
|
|
<span className="ml-1.5 text-[11px] text-gray-500">(같은 값끼리 묶어서 집계)</span>
|
|
</Label>
|
|
<Select
|
|
value={currentConfig.groupBy || undefined}
|
|
onValueChange={(value) => updateConfig({ groupBy: value })}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue placeholder="없음" />
|
|
</SelectTrigger>
|
|
<SelectContent className="z-[99999]">
|
|
<SelectItem value="__none__" className="text-xs">
|
|
없음
|
|
</SelectItem>
|
|
{availableColumns.map((col) => (
|
|
<SelectItem key={col} value={col} className="text-xs">
|
|
{col}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* 차트 색상 */}
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-medium text-gray-700">차트 색상</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 />
|
|
|
|
{/* 날짜 필터 */}
|
|
{dateColumns.length > 0 && (
|
|
<DateFilterPanel config={currentConfig} dateColumns={dateColumns} onChange={updateConfig} />
|
|
)}
|
|
|
|
{/* 필수 필드 확인 */}
|
|
{!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>
|
|
);
|
|
}
|