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

503 lines
21 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState, useCallback, useEffect } 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 { TrendingUp, 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 === "pie" || chartType === "donut";
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);
console.log("📋 쿼리에서 추출한 테이블명:", tableName);
console.log("📋 원본 쿼리:", query);
if (!tableName) {
console.log("⚠️ 테이블명을 추출할 수 없습니다.");
setDateColumns([]);
return;
}
console.log("🔍 테이블 스키마 조회 중:", tableName);
dashboardApi
.getTableSchema(tableName)
.then((schema) => {
console.log("✅ 스키마 조회 성공:", schema);
console.log("📅 전체 날짜 컬럼:", schema.dateColumns);
console.log("📊 쿼리 결과 컬럼:", availableColumns);
// 원본 테이블의 모든 날짜 컬럼을 표시
// (SELECT에 없어도 WHERE 절에 사용 가능)
setDateColumns(schema.dateColumns);
console.log("✨ 사용 가능한 날짜 필터 컬럼:", schema.dateColumns);
})
.catch((error) => {
console.error("❌ 테이블 스키마 조회 실패:", error);
// 실패 시 빈 배열 (날짜 필터 비활성화)
setDateColumns([]);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, queryResult, dataSourceType]);
return (
<div className="space-y-6">
{/* 데이터 필드 매핑 */}
{queryResult && (
<>
{/* API 응답 미리보기 */}
{queryResult.rows && queryResult.rows.length > 0 && (
<Card className="border-blue-200 bg-blue-50 p-4">
<div className="mb-2 flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-blue-600" />
<h4 className="font-semibold text-blue-900">📋 API </h4>
</div>
<div className="rounded bg-white p-3 text-xs">
<div className="mb-2 text-gray-600"> {queryResult.totalRows} :</div>
<pre className="overflow-x-auto text-gray-800">{JSON.stringify(sampleData, null, 2)}</pre>
</div>
</Card>
)}
{/* 복잡한 타입 경고 */}
{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-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 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}>
{col}
{previewText && <span className="ml-2 text-xs text-gray-500">(: {previewText})</span>}
</SelectItem>
);
})}
</SelectContent>
</Select>
{simpleColumns.length === 0 && (
<p className="text-xs text-red-500"> . JSON Path를 .</p>
)}
</div>
{/* Y축 설정 (다중 선택 가능) */}
<div className="space-y-2">
<Label>
Y축 () -
{!isPieChart && !isApiSource && <span className="ml-1 text-red-500">*</span>}
{(isPieChart || isApiSource) && (
<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">
{/* 숫자 타입 우선 표시 */}
{numericColumns.length > 0 && (
<>
<div className="mb-2 text-xs 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-2 rounded border-l-2 border-green-500 bg-green-50 p-2"
>
<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">
<span className="font-medium">{col}</span>
{sampleData[col] !== undefined && (
<span className="ml-2 text-xs text-gray-600">(: {sampleData[col]})</span>
)}
</Label>
</div>
);
})}
</>
)}
{/* 기타 간단한 타입 */}
{simpleColumns.filter((col) => !numericColumns.includes(col)).length > 0 && (
<>
{numericColumns.length > 0 && <div className="my-2 border-t"></div>}
<div className="mb-2 text-xs 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-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] !== undefined && (
<span className="ml-2 text-xs text-gray-500">
(: {String(sampleData[col]).substring(0, 30)})
</span>
)}
</Label>
</div>
);
})}
</>
)}
</div>
</Card>
{simpleColumns.length === 0 && (
<p className="text-xs text-red-500"> . JSON Path를 .</p>
)}
<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 className="z-[99999]">
<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">
💡 . (: 부서별 , )
</p>
</div>
{/* 그룹핑 필드 (선택사항) */}
<div className="space-y-2">
<Label>
()
<span className="ml-2 text-xs text-gray-500">( )</span>
</Label>
<Select
value={currentConfig.groupBy || undefined}
onValueChange={(value) => updateConfig({ groupBy: value })}
>
<SelectTrigger>
<SelectValue placeholder="없음" />
</SelectTrigger>
<SelectContent className="z-[99999]">
<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>
{/* 날짜 필터 */}
{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>
);
}