ERP-node/frontend/components/admin/dashboard/ChartConfigPanel.tsx

449 lines
19 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-destructive/10">
{col} ({columnTypes[col]})
</Badge>
))}
</div>
</div>
<div className="mt-2 text-xs text-foreground">
<strong> :</strong> JSON Path를 .
<br />
: <code className="rounded bg-muted px-1">main</code> {" "}
<code className="rounded bg-muted px-1">data.items</code>
</div>
</AlertDescription>
</Alert>
)}
{/* 차트 제목 */}
<div className="space-y-1.5">
<Label className="text-xs font-medium text-foreground"> </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-foreground">
X축 ()
<span className="ml-1 text-destructive">*</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-muted-foreground">(: {previewText})</span>}
</SelectItem>
);
})}
</SelectContent>
</Select>
{simpleColumns.length === 0 && (
<p className="text-[11px] text-destructive"> . JSON Path를 .</p>
)}
</div>
{/* Y축 설정 (다중 선택 가능) */}
<div className="space-y-1.5">
<Label className="text-xs font-medium text-foreground">
Y축 () -
{!isPieChart && !isApiSource && <span className="ml-1 text-destructive">*</span>}
{(isPieChart || isApiSource) && (
<span className="ml-1.5 text-[11px] text-muted-foreground">( - + )</span>
)}
</Label>
<div className="max-h-48 overflow-y-auto rounded border border-border bg-muted p-2">
<div className="space-y-1.5">
{/* 숫자 타입 우선 표시 */}
{numericColumns.length > 0 && (
<>
<div className="mb-1.5 text-[11px] font-medium text-success"> ()</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-success bg-success/10 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-foreground">(: {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-foreground"> </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-muted">
<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-muted-foreground">
(: {String(sampleData[col]).substring(0, 30)})
</span>
)}
</Label>
</div>
);
})}
</>
)}
</div>
</div>
{simpleColumns.length === 0 && (
<p className="text-[11px] text-destructive"> . JSON Path를 .</p>
)}
<p className="text-[11px] text-muted-foreground">
: 여러 (: 갤럭시 vs )
</p>
</div>
<Separator />
{/* 집계 함수 */}
<div className="space-y-1.5">
<Label className="text-xs font-medium text-foreground">
<span className="ml-1.5 text-[11px] text-muted-foreground">( )</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-muted-foreground">
. (: 부서별 , )
</p>
</div>
{/* 그룹핑 필드 (선택사항) */}
<div className="space-y-1.5">
<Label className="text-xs font-medium text-foreground">
()
<span className="ml-1.5 text-[11px] text-muted-foreground">( )</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-foreground"> </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-foreground"
: "border-border hover:border-border/80"
}`}
>
{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>
);
}