테스트 위젯 원본 승격 전 세이브

This commit is contained in:
leeheejin
2025-10-28 17:40:48 +09:00
parent fb73ee2878
commit 81458549af
17 changed files with 2404 additions and 306 deletions

View File

@@ -908,7 +908,7 @@ export function CanvasElement({
<ListTestWidget element={element} />
</div>
) : element.type === "widget" && element.subtype === "custom-metric-test" ? (
// 🧪 테스트용 커스텀 메트릭 위젯 (다중 데이터 소스)
// 🧪 통계 카드 (다중 데이터 소스)
<div className="widget-interactive-area h-full w-full">
<CustomMetricTestWidget element={element} />
</div>

View File

@@ -181,15 +181,15 @@ export function DashboardTopMenu({
<SelectValue placeholder="위젯 추가" />
</SelectTrigger>
<SelectContent className="z-[99999]">
<SelectGroup>
<SelectLabel>🧪 ( )</SelectLabel>
<SelectItem value="map-test-v2">🧪 V2</SelectItem>
<SelectItem value="chart-test">🧪 </SelectItem>
<SelectItem value="list-test">🧪 </SelectItem>
<SelectItem value="custom-metric-test">🧪 </SelectItem>
<SelectItem value="status-summary-test">🧪 </SelectItem>
<SelectItem value="risk-alert-test">🧪 / </SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel>🧪 ( )</SelectLabel>
<SelectItem value="map-test-v2">🧪 V2</SelectItem>
<SelectItem value="chart-test">🧪 </SelectItem>
<SelectItem value="list-test">🧪 </SelectItem>
<SelectItem value="custom-metric-test"> </SelectItem>
{/* <SelectItem value="status-summary-test">🧪 상태 요약 테스트</SelectItem> */}
<SelectItem value="risk-alert-test">🧪 / </SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel> </SelectLabel>
<SelectItem value="list"> </SelectItem>
@@ -197,7 +197,7 @@ export function DashboardTopMenu({
<SelectItem value="yard-management-3d"> 3D</SelectItem>
{/* <SelectItem value="transport-stats">커스텀 통계 카드</SelectItem> */}
<SelectItem value="map-summary"> </SelectItem>
<SelectItem value="map-test">🧪 (REST API)</SelectItem>
{/* <SelectItem value="map-test">🧪 지도 테스트 (REST API)</SelectItem> */}
{/* <SelectItem value="status-summary">커스텀 상태 카드</SelectItem> */}
</SelectGroup>
<SelectGroup>

View File

@@ -6,6 +6,7 @@ import { QueryEditor } from "./QueryEditor";
import { ChartConfigPanel } from "./ChartConfigPanel";
import { VehicleMapConfigPanel } from "./VehicleMapConfigPanel";
import { MapTestConfigPanel } from "./MapTestConfigPanel";
import { MultiChartConfigPanel } from "./MultiChartConfigPanel";
import { DatabaseConfig } from "./data-sources/DatabaseConfig";
import { ApiConfig } from "./data-sources/ApiConfig";
import MultiDataSourceConfig from "./data-sources/MultiDataSourceConfig";
@@ -41,16 +42,41 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
const [customTitle, setCustomTitle] = useState<string>("");
const [showHeader, setShowHeader] = useState<boolean>(true);
// 멀티 데이터 소스의 테스트 결과 저장 (ChartTestWidget용)
const [testResults, setTestResults] = useState<Map<string, { columns: string[]; rows: Record<string, unknown>[] }>>(
new Map(),
);
// 사이드바가 열릴 때 초기화
useEffect(() => {
if (isOpen && element) {
console.log("🔄 ElementConfigSidebar 초기화 - element.id:", element.id);
console.log("🔄 element.dataSources:", element.dataSources);
console.log("🔄 element.chartConfig?.dataSources:", element.chartConfig?.dataSources);
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 });
// dataSources는 element.dataSources 또는 chartConfig.dataSources에서 로드
setDataSources(element.dataSources || element.chartConfig?.dataSources || []);
// ⚠️ 중요: 없으면 반드시 빈 배열로 초기화
const initialDataSources = element.dataSources || element.chartConfig?.dataSources || [];
console.log("🔄 초기화된 dataSources:", initialDataSources);
setDataSources(initialDataSources);
setChartConfig(element.chartConfig || {});
setQueryResult(null);
setTestResults(new Map()); // 테스트 결과도 초기화
setCustomTitle(element.customTitle || "");
setShowHeader(element.showHeader !== false);
} else if (!isOpen) {
// 사이드바가 닫힐 때 모든 상태 초기화
console.log("🧹 ElementConfigSidebar 닫힘 - 상태 초기화");
setDataSource({ type: "database", connectionType: "current", refreshInterval: 0 });
setDataSources([]);
setChartConfig({});
setQueryResult(null);
setTestResults(new Map());
setCustomTitle("");
setShowHeader(true);
}
}, [isOpen, element]);
@@ -127,10 +153,10 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
console.log("🔧 적용 버튼 클릭 - chartConfig:", chartConfig);
// 다중 데이터 소스 위젯 체크
const isMultiDS =
element.subtype === "map-test-v2" ||
element.subtype === "chart-test" ||
element.subtype === "list-test" ||
const isMultiDS =
element.subtype === "map-test-v2" ||
element.subtype === "chart-test" ||
element.subtype === "list-test" ||
element.subtype === "custom-metric-test" ||
element.subtype === "risk-alert-test";
@@ -227,10 +253,10 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
(element.subtype === "clock" || element.subtype === "calendar" || isSelfContainedWidget);
// 다중 데이터 소스 테스트 위젯
const isMultiDataSourceWidget =
element.subtype === "map-test-v2" ||
element.subtype === "chart-test" ||
element.subtype === "list-test" ||
const isMultiDataSourceWidget =
element.subtype === "map-test-v2" ||
element.subtype === "chart-test" ||
element.subtype === "list-test" ||
element.subtype === "custom-metric-test" ||
element.subtype === "status-summary-test" ||
element.subtype === "risk-alert-test";
@@ -321,7 +347,27 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
{isMultiDataSourceWidget && (
<>
<div className="rounded-lg bg-white p-3 shadow-sm">
<MultiDataSourceConfig dataSources={dataSources} onChange={setDataSources} />
<MultiDataSourceConfig
dataSources={dataSources}
onChange={setDataSources}
onTestResult={(result, dataSourceId) => {
// API 테스트 결과를 queryResult로 설정 (차트 설정용)
setQueryResult({
...result,
totalRows: result.rows.length,
executionTime: 0,
});
console.log("📊 API 테스트 결과 수신:", result, "데이터 소스 ID:", dataSourceId);
// ChartTestWidget용: 각 데이터 소스의 테스트 결과 저장
setTestResults((prev) => {
const updated = new Map(prev);
updated.set(dataSourceId, result);
console.log("📊 테스트 결과 저장:", dataSourceId, result);
return updated;
});
}}
/>
</div>
{/* 지도 테스트 V2: 타일맵 URL 설정 */}
@@ -354,6 +400,40 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
</details>
</div>
)}
{/* 차트 테스트: 차트 설정 */}
{element.subtype === "chart-test" && (
<div className="rounded-lg bg-white shadow-sm">
<details className="group" open>
<summary className="flex cursor-pointer items-center justify-between p-3 hover:bg-gray-50">
<div>
<div className="text-xs font-semibold tracking-wide text-gray-500 uppercase"> </div>
<div className="text-muted-foreground mt-0.5 text-[10px]">
{testResults.size > 0
? `${testResults.size}개 데이터 소스 • X축, Y축, 차트 타입 설정`
: "먼저 데이터 소스를 추가하고 API 테스트를 실행하세요"}
</div>
</div>
<svg
className="h-4 w-4 transition-transform group-open:rotate-180"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div className="border-t p-3">
<MultiChartConfigPanel
config={chartConfig}
dataSources={dataSources}
testResults={testResults}
onConfigChange={handleChartConfigChange}
/>
</div>
</details>
</div>
)}
</>
)}

View File

@@ -0,0 +1,327 @@
"use client";
import React, { useState, useEffect } from "react";
import { ChartConfig, ChartDataSource } from "./types";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Trash2 } from "lucide-react";
interface MultiChartConfigPanelProps {
config: ChartConfig;
dataSources: ChartDataSource[];
testResults: Map<string, { columns: string[]; rows: Record<string, unknown>[] }>; // 각 데이터 소스의 테스트 결과
onConfigChange: (config: ChartConfig) => void;
}
export function MultiChartConfigPanel({
config,
dataSources,
testResults,
onConfigChange,
}: MultiChartConfigPanelProps) {
const [chartType, setChartType] = useState<string>(config.chartType || "line");
const [mergeMode, setMergeMode] = useState<boolean>(config.mergeMode || false);
const [dataSourceConfigs, setDataSourceConfigs] = useState<
Array<{
dataSourceId: string;
xAxis: string;
yAxis: string[];
label?: string;
}>
>(config.dataSourceConfigs || []);
// 데이터 소스별 사용 가능한 컬럼
const getColumnsForDataSource = (dataSourceId: string): string[] => {
const result = testResults.get(dataSourceId);
return result?.columns || [];
};
// 데이터 소스별 숫자 컬럼
const getNumericColumnsForDataSource = (dataSourceId: string): string[] => {
const result = testResults.get(dataSourceId);
if (!result || !result.rows || result.rows.length === 0) return [];
const firstRow = result.rows[0];
return Object.keys(firstRow).filter((key) => {
const value = firstRow[key];
return typeof value === "number" || !isNaN(Number(value));
});
};
// 차트 타입 변경
const handleChartTypeChange = (type: string) => {
setChartType(type);
onConfigChange({
...config,
chartType: type,
mergeMode,
dataSourceConfigs,
});
};
// 병합 모드 변경
const handleMergeModeChange = (checked: boolean) => {
setMergeMode(checked);
onConfigChange({
...config,
chartType,
mergeMode: checked,
dataSourceConfigs,
});
};
// 데이터 소스 설정 추가
const handleAddDataSourceConfig = (dataSourceId: string) => {
const columns = getColumnsForDataSource(dataSourceId);
const numericColumns = getNumericColumnsForDataSource(dataSourceId);
const newConfig = {
dataSourceId,
xAxis: columns[0] || "",
yAxis: numericColumns.length > 0 ? [numericColumns[0]] : [],
label: dataSources.find((ds) => ds.id === dataSourceId)?.name || "",
};
const updated = [...dataSourceConfigs, newConfig];
setDataSourceConfigs(updated);
onConfigChange({
...config,
chartType,
mergeMode,
dataSourceConfigs: updated,
});
};
// 데이터 소스 설정 삭제
const handleRemoveDataSourceConfig = (dataSourceId: string) => {
const updated = dataSourceConfigs.filter((c) => c.dataSourceId !== dataSourceId);
setDataSourceConfigs(updated);
onConfigChange({
...config,
chartType,
mergeMode,
dataSourceConfigs: updated,
});
};
// X축 변경
const handleXAxisChange = (dataSourceId: string, xAxis: string) => {
const updated = dataSourceConfigs.map((c) => (c.dataSourceId === dataSourceId ? { ...c, xAxis } : c));
setDataSourceConfigs(updated);
onConfigChange({
...config,
chartType,
mergeMode,
dataSourceConfigs: updated,
});
};
// Y축 변경
const handleYAxisChange = (dataSourceId: string, yAxis: string) => {
const updated = dataSourceConfigs.map((c) => (c.dataSourceId === dataSourceId ? { ...c, yAxis: [yAxis] } : c));
setDataSourceConfigs(updated);
onConfigChange({
...config,
chartType,
mergeMode,
dataSourceConfigs: updated,
});
};
// 🆕 개별 차트 타입 변경
const handleIndividualChartTypeChange = (dataSourceId: string, chartType: "bar" | "line" | "area") => {
const updated = dataSourceConfigs.map((c) => (c.dataSourceId === dataSourceId ? { ...c, chartType } : c));
setDataSourceConfigs(updated);
onConfigChange({
...config,
chartType: "mixed", // 혼합 모드로 설정
mergeMode,
dataSourceConfigs: updated,
});
};
// 설정되지 않은 데이터 소스 (테스트 완료된 것만)
const availableDataSources = dataSources.filter(
(ds) => testResults.has(ds.id!) && !dataSourceConfigs.some((c) => c.dataSourceId === ds.id),
);
return (
<div className="space-y-4">
{/* 차트 타입 선택 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select value={chartType} onValueChange={handleChartTypeChange}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="차트 타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="line"> </SelectItem>
<SelectItem value="bar"> </SelectItem>
<SelectItem value="area"> </SelectItem>
<SelectItem value="pie"> </SelectItem>
<SelectItem value="donut"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 데이터 병합 모드 */}
{dataSourceConfigs.length > 1 && (
<div className="bg-muted/50 flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label className="text-xs font-medium"> </Label>
<p className="text-muted-foreground text-[10px]"> / </p>
</div>
<Switch checked={mergeMode} onCheckedChange={handleMergeModeChange} aria-label="데이터 병합 모드" />
</div>
)}
{/* 데이터 소스별 설정 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label>
{availableDataSources.length > 0 && (
<Select onValueChange={handleAddDataSourceConfig}>
<SelectTrigger className="h-7 w-32 text-xs">
<SelectValue placeholder="추가" />
</SelectTrigger>
<SelectContent>
{availableDataSources.map((ds) => (
<SelectItem key={ds.id} value={ds.id!} className="text-xs">
{ds.name || ds.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{dataSourceConfigs.length === 0 ? (
<div className="rounded-lg border border-dashed p-4 text-center">
<p className="text-muted-foreground text-xs">
API <br />
</p>
</div>
) : (
dataSourceConfigs.map((dsConfig) => {
const dataSource = dataSources.find((ds) => ds.id === dsConfig.dataSourceId);
const columns = getColumnsForDataSource(dsConfig.dataSourceId);
const numericColumns = getNumericColumnsForDataSource(dsConfig.dataSourceId);
return (
<div key={dsConfig.dataSourceId} className="bg-muted/50 space-y-3 rounded-lg border p-3">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h5 className="text-xs font-semibold">{dataSource?.name || dsConfig.dataSourceId}</h5>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveDataSourceConfig(dsConfig.dataSourceId)}
className="h-6 w-6 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* X축 */}
<div className="space-y-1.5">
<Label className="text-xs">X축 (/)</Label>
<Select
value={dsConfig.xAxis}
onValueChange={(value) => handleXAxisChange(dsConfig.dataSourceId, value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="X축 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Y축 */}
<div className="space-y-1.5">
<Label className="text-xs">Y축 ()</Label>
<Select
value={dsConfig.yAxis[0] || ""}
onValueChange={(value) => handleYAxisChange(dsConfig.dataSourceId, value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Y축 선택" />
</SelectTrigger>
<SelectContent>
{numericColumns.map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 🆕 개별 차트 타입 (병합 모드가 아닐 때만) */}
{!mergeMode && (
<div className="space-y-1.5">
<Label className="text-xs"> </Label>
<Select
value={dsConfig.chartType || "line"}
onValueChange={(value) =>
handleIndividualChartTypeChange(dsConfig.dataSourceId, value as "bar" | "line" | "area")
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="차트 타입" />
</SelectTrigger>
<SelectContent>
<SelectItem value="bar" className="text-xs">
📊
</SelectItem>
<SelectItem value="line" className="text-xs">
📈
</SelectItem>
<SelectItem value="area" className="text-xs">
📉
</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
);
})
)}
</div>
{/* 안내 메시지 */}
{dataSourceConfigs.length > 0 && (
<div className="rounded-lg bg-blue-50 p-3">
<p className="text-xs text-blue-900">
{mergeMode ? (
<>
🔗 {dataSourceConfigs.length} / .
<br />
<span className="text-[10px]">
중요: X축/Y축 .
<br />
.
<br />
💡 "컬럼 매핑" .
</span>
</>
) : (
<>
💡 {dataSourceConfigs.length} .
<br /> (//) .
</>
)}
</p>
</div>
)}
</div>
);
}

View File

@@ -557,6 +557,55 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
</p>
</div>
{/* 지도 색상 설정 (MapTestWidgetV2 전용) */}
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
<h5 className="text-xs font-semibold">🎨 </h5>
{/* 색상 팔레트 */}
<div className="space-y-2">
<Label className="text-xs"></Label>
<div className="grid grid-cols-4 gap-2">
{[
{ name: "파랑", marker: "#3b82f6", polygon: "#3b82f6" },
{ name: "빨강", marker: "#ef4444", polygon: "#ef4444" },
{ name: "초록", marker: "#10b981", polygon: "#10b981" },
{ name: "노랑", marker: "#f59e0b", polygon: "#f59e0b" },
{ name: "보라", marker: "#8b5cf6", polygon: "#8b5cf6" },
{ name: "주황", marker: "#f97316", polygon: "#f97316" },
{ name: "청록", marker: "#06b6d4", polygon: "#06b6d4" },
{ name: "분홍", marker: "#ec4899", polygon: "#ec4899" },
].map((color) => {
const isSelected = dataSource.markerColor === color.marker;
return (
<button
key={color.name}
type="button"
onClick={() => onChange({
markerColor: color.marker,
polygonColor: color.polygon,
polygonOpacity: 0.5,
})}
className={`flex h-16 flex-col items-center justify-center gap-1 rounded-md border-2 transition-all hover:scale-105 ${
isSelected
? "border-primary bg-primary/10 shadow-md"
: "border-border bg-background hover:border-primary/50"
}`}
>
<div
className="h-6 w-6 rounded-full border-2 border-white shadow-sm"
style={{ backgroundColor: color.marker }}
/>
<span className="text-[10px] font-medium">{color.name}</span>
</button>
);
})}
</div>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
</div>
{/* 테스트 버튼 */}
<div className="space-y-2 border-t pt-4">
<Button
@@ -745,6 +794,103 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
)}
</div>
)}
{/* 컬럼 매핑 (API 테스트 성공 후에만 표시) */}
{testResult?.success && availableColumns.length > 0 && (
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
<div className="flex items-center justify-between">
<div>
<h5 className="text-xs font-semibold">🔄 ()</h5>
<p className="text-[10px] text-muted-foreground mt-0.5">
</p>
</div>
{dataSource.columnMapping && Object.keys(dataSource.columnMapping).length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => onChange({ columnMapping: {} })}
className="h-7 text-xs"
>
</Button>
)}
</div>
{/* 매핑 목록 */}
{dataSource.columnMapping && Object.keys(dataSource.columnMapping).length > 0 && (
<div className="space-y-2">
{Object.entries(dataSource.columnMapping).map(([original, mapped]) => (
<div key={original} className="flex items-center gap-2">
{/* 원본 컬럼 (읽기 전용) */}
<Input
value={original}
disabled
className="h-8 flex-1 text-xs bg-muted"
/>
{/* 화살표 */}
<span className="text-muted-foreground text-xs"></span>
{/* 표시 이름 (편집 가능) */}
<Input
value={mapped}
onChange={(e) => {
const newMapping = { ...dataSource.columnMapping };
newMapping[original] = e.target.value;
onChange({ columnMapping: newMapping });
}}
placeholder="표시 이름"
className="h-8 flex-1 text-xs"
/>
{/* 삭제 버튼 */}
<Button
variant="ghost"
size="sm"
onClick={() => {
const newMapping = { ...dataSource.columnMapping };
delete newMapping[original];
onChange({ columnMapping: newMapping });
}}
className="h-8 w-8 p-0"
>
<XCircle className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{/* 매핑 추가 */}
<Select
value=""
onValueChange={(col) => {
const newMapping = { ...dataSource.columnMapping } || {};
newMapping[col] = col; // 기본값은 원본과 동일
onChange({ columnMapping: newMapping });
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택하여 매핑 추가" />
</SelectTrigger>
<SelectContent>
{availableColumns
.filter(col => !dataSource.columnMapping || !dataSource.columnMapping[col])
.map(col => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))
}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
💡
</p>
</div>
)}
</div>
);
}

View File

@@ -20,11 +20,13 @@ import MultiDatabaseConfig from "./MultiDatabaseConfig";
interface MultiDataSourceConfigProps {
dataSources: ChartDataSource[];
onChange: (dataSources: ChartDataSource[]) => void;
onTestResult?: (result: { columns: string[]; rows: any[] }, dataSourceId: string) => void;
}
export default function MultiDataSourceConfig({
dataSources = [],
onChange,
onTestResult,
}: MultiDataSourceConfigProps) {
const [activeTab, setActiveTab] = useState<string>(
dataSources.length > 0 ? dataSources[0].id || "0" : "new"
@@ -258,12 +260,24 @@ export default function MultiDataSourceConfig({
onTestResult={(data) => {
setPreviewData(data);
setShowPreview(true);
// 부모로 테스트 결과 전달 (차트 설정용)
if (onTestResult && data.length > 0 && ds.id) {
const columns = Object.keys(data[0]);
onTestResult({ columns, rows: data }, ds.id);
}
}}
/>
) : (
<MultiDatabaseConfig
dataSource={ds}
onChange={(updates) => handleUpdateDataSource(ds.id!, updates)}
onTestResult={(data) => {
// 부모로 테스트 결과 전달 (차트 설정용)
if (onTestResult && data.length > 0 && ds.id) {
const columns = Object.keys(data[0]);
onTestResult({ columns, rows: data }, ds.id);
}
}}
/>
)}
</TabsContent>

View File

@@ -13,6 +13,7 @@ import { Loader2, CheckCircle, XCircle } from "lucide-react";
interface MultiDatabaseConfigProps {
dataSource: ChartDataSource;
onChange: (updates: Partial<ChartDataSource>) => void;
onTestResult?: (data: any[]) => void;
}
interface ExternalConnection {
@@ -21,7 +22,7 @@ interface ExternalConnection {
type: string;
}
export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatabaseConfigProps) {
export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult }: MultiDatabaseConfigProps) {
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string; rowCount?: number } | null>(null);
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
@@ -122,6 +123,11 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
message: "쿼리 실행 성공",
rowCount,
});
// 부모로 테스트 결과 전달 (차트 설정용)
if (onTestResult && rows && rows.length > 0) {
onTestResult(rows);
}
} else {
setTestResult({ success: false, message: result.message || "쿼리 실행 실패" });
}
@@ -166,6 +172,11 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
message: "쿼리 실행 성공",
rowCount: result.rowCount || 0,
});
// 부모로 테스트 결과 전달 (차트 설정용)
if (onTestResult && result.rows && result.rows.length > 0) {
onTestResult(result.rows);
}
}
} catch (error: any) {
setTestResult({ success: false, message: error.message || "네트워크 오류" });
@@ -240,9 +251,61 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
{/* SQL 쿼리 */}
<div className="space-y-2">
<Label htmlFor={`query-\${dataSource.id}`} className="text-xs">
SQL *
</Label>
<div className="flex items-center justify-between">
<Label htmlFor={`query-\${dataSource.id}`} className="text-xs">
SQL *
</Label>
<Select onValueChange={(value) => {
const samples = {
users: `SELECT
dept_name as 부서명,
COUNT(*) as 회원수
FROM user_info
WHERE dept_name IS NOT NULL
GROUP BY dept_name
ORDER BY 회원수 DESC`,
dept: `SELECT
dept_code as 부서코드,
dept_name as 부서명,
location_name as 위치,
TO_CHAR(regdate, 'YYYY-MM-DD') as 등록일
FROM dept_info
ORDER BY dept_code`,
usersByDate: `SELECT
DATE_TRUNC('month', regdate)::date as 월,
COUNT(*) as 신규사용자수
FROM user_info
WHERE regdate >= CURRENT_DATE - INTERVAL '12 months'
GROUP BY DATE_TRUNC('month', regdate)
ORDER BY 월`,
usersByPosition: `SELECT
position_name as 직급,
COUNT(*) as 인원수
FROM user_info
WHERE position_name IS NOT NULL
GROUP BY position_name
ORDER BY 인원수 DESC`,
deptHierarchy: `SELECT
COALESCE(parent_dept_code, '최상위') as 상위부서코드,
COUNT(*) as 하위부서수
FROM dept_info
GROUP BY parent_dept_code
ORDER BY 하위부서수 DESC`,
};
onChange({ query: samples[value as keyof typeof samples] || "" });
}}>
<SelectTrigger className="h-7 w-32 text-xs">
<SelectValue placeholder="샘플 쿼리" />
</SelectTrigger>
<SelectContent>
<SelectItem value="users" className="text-xs"> </SelectItem>
<SelectItem value="dept" className="text-xs"> </SelectItem>
<SelectItem value="usersByDate" className="text-xs"> </SelectItem>
<SelectItem value="usersByPosition" className="text-xs"> </SelectItem>
<SelectItem value="deptHierarchy" className="text-xs"> </SelectItem>
</SelectContent>
</Select>
</div>
<Textarea
id={`query-\${dataSource.id}`}
value={dataSource.query || ""}
@@ -251,7 +314,7 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
className="min-h-[120px] font-mono text-xs"
/>
<p className="text-[10px] text-muted-foreground">
SELECT
SELECT . .
</p>
</div>
@@ -283,6 +346,55 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
</p>
</div>
{/* 지도 색상 설정 (MapTestWidgetV2 전용) */}
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
<h5 className="text-xs font-semibold">🎨 </h5>
{/* 색상 팔레트 */}
<div className="space-y-2">
<Label className="text-xs"></Label>
<div className="grid grid-cols-4 gap-2">
{[
{ name: "파랑", marker: "#3b82f6", polygon: "#3b82f6" },
{ name: "빨강", marker: "#ef4444", polygon: "#ef4444" },
{ name: "초록", marker: "#10b981", polygon: "#10b981" },
{ name: "노랑", marker: "#f59e0b", polygon: "#f59e0b" },
{ name: "보라", marker: "#8b5cf6", polygon: "#8b5cf6" },
{ name: "주황", marker: "#f97316", polygon: "#f97316" },
{ name: "청록", marker: "#06b6d4", polygon: "#06b6d4" },
{ name: "분홍", marker: "#ec4899", polygon: "#ec4899" },
].map((color) => {
const isSelected = dataSource.markerColor === color.marker;
return (
<button
key={color.name}
type="button"
onClick={() => onChange({
markerColor: color.marker,
polygonColor: color.polygon,
polygonOpacity: 0.5,
})}
className={`flex h-16 flex-col items-center justify-center gap-1 rounded-md border-2 transition-all hover:scale-105 ${
isSelected
? "border-primary bg-primary/10 shadow-md"
: "border-border bg-background hover:border-primary/50"
}`}
>
<div
className="h-6 w-6 rounded-full border-2 border-white shadow-sm"
style={{ backgroundColor: color.marker }}
/>
<span className="text-[10px] font-medium">{color.name}</span>
</button>
);
})}
</div>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
</div>
{/* 테스트 버튼 */}
<div className="space-y-2 border-t pt-4">
<Button
@@ -476,6 +588,103 @@ export default function MultiDatabaseConfig({ dataSource, onChange }: MultiDatab
)}
</div>
)}
{/* 컬럼 매핑 (쿼리 테스트 성공 후에만 표시) */}
{testResult?.success && availableColumns.length > 0 && (
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
<div className="flex items-center justify-between">
<div>
<h5 className="text-xs font-semibold">🔄 ()</h5>
<p className="text-[10px] text-muted-foreground mt-0.5">
</p>
</div>
{dataSource.columnMapping && Object.keys(dataSource.columnMapping).length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => onChange({ columnMapping: {} })}
className="h-7 text-xs"
>
</Button>
)}
</div>
{/* 매핑 목록 */}
{dataSource.columnMapping && Object.keys(dataSource.columnMapping).length > 0 && (
<div className="space-y-2">
{Object.entries(dataSource.columnMapping).map(([original, mapped]) => (
<div key={original} className="flex items-center gap-2">
{/* 원본 컬럼 (읽기 전용) */}
<Input
value={original}
disabled
className="h-8 flex-1 text-xs bg-muted"
/>
{/* 화살표 */}
<span className="text-muted-foreground text-xs"></span>
{/* 표시 이름 (편집 가능) */}
<Input
value={mapped}
onChange={(e) => {
const newMapping = { ...dataSource.columnMapping };
newMapping[original] = e.target.value;
onChange({ columnMapping: newMapping });
}}
placeholder="표시 이름"
className="h-8 flex-1 text-xs"
/>
{/* 삭제 버튼 */}
<Button
variant="ghost"
size="sm"
onClick={() => {
const newMapping = { ...dataSource.columnMapping };
delete newMapping[original];
onChange({ columnMapping: newMapping });
}}
className="h-8 w-8 p-0"
>
<XCircle className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{/* 매핑 추가 */}
<Select
value=""
onValueChange={(col) => {
const newMapping = { ...dataSource.columnMapping } || {};
newMapping[col] = col; // 기본값은 원본과 동일
onChange({ columnMapping: newMapping });
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택하여 매핑 추가" />
</SelectTrigger>
<SelectContent>
{availableColumns
.filter(col => !dataSource.columnMapping || !dataSource.columnMapping[col])
.map(col => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))
}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
💡
</p>
</div>
)}
</div>
);
}

View File

@@ -23,12 +23,12 @@ export type ElementSubtype =
| "vehicle-list" // (구버전 - 호환용)
| "vehicle-map" // (구버전 - 호환용)
| "map-summary" // 범용 지도 카드 (통합)
| "map-test" // 🧪 지도 테스트 위젯 (REST API 지원)
// | "map-test" // 🧪 지도 테스트 위젯 (REST API 지원) - V2로 대체
| "map-test-v2" // 🧪 지도 테스트 V2 (다중 데이터 소스)
| "chart-test" // 🧪 차트 테스트 (다중 데이터 소스)
| "list-test" // 🧪 리스트 테스트 (다중 데이터 소스)
| "custom-metric-test" // 🧪 커스텀 메트릭 테스트 (다중 데이터 소스)
| "status-summary-test" // 🧪 상태 요약 테스트 (다중 데이터 소스)
| "custom-metric-test" // 🧪 통계 카드 (다중 데이터 소스)
// | "status-summary-test" // 🧪 상태 요약 테스트 (CustomMetricTest로 대체 가능)
| "risk-alert-test" // 🧪 리스크/알림 테스트 (다중 데이터 소스)
| "delivery-status"
| "status-summary" // 범용 상태 카드 (통합)
@@ -154,7 +154,15 @@ export interface ChartDataSource {
lastExecuted?: string; // 마지막 실행 시간
lastError?: string; // 마지막 오류 메시지
mapDisplayType?: "auto" | "marker" | "polygon"; // 지도 표시 방식 (auto: 자동, marker: 마커, polygon: 영역)
// 지도 색상 설정 (MapTestWidgetV2용)
markerColor?: string; // 마커 색상 (예: "#ff0000")
polygonColor?: string; // 폴리곤 색상 (예: "#0000ff")
polygonOpacity?: number; // 폴리곤 투명도 (0.0 ~ 1.0, 기본값: 0.5)
// 컬럼 매핑 (다중 데이터 소스 통합용)
columnMapping?: Record<string, string>; // { 원본컬럼: 표시이름 } (예: { "name": "product" })
// 메트릭 설정 (CustomMetricTestWidget용)
selectedColumns?: string[]; // 표시할 컬럼 선택 (빈 배열이면 전체 표시)
}
@@ -163,7 +171,18 @@ export interface ChartConfig {
// 다중 데이터 소스 (테스트 위젯용)
dataSources?: ChartDataSource[]; // 여러 데이터 소스 (REST API + Database 혼합 가능)
// 축 매핑
// 멀티 차트 설정 (ChartTestWidget용)
chartType?: string; // 차트 타입 (line, bar, pie, etc.)
mergeMode?: boolean; // 데이터 병합 모드 (여러 데이터 소스를 하나의 라인/바로 합침)
dataSourceConfigs?: Array<{
dataSourceId: string; // 데이터 소스 ID
xAxis: string; // X축 필드명
yAxis: string[]; // Y축 필드명 배열
label?: string; // 데이터 소스 라벨
chartType?: "bar" | "line" | "area"; // 🆕 각 데이터 소스별 차트 타입 (바/라인/영역 혼합 가능)
}>;
// 축 매핑 (단일 데이터 소스용)
xAxis?: string; // X축 필드명
yAxis?: string | string[]; // Y축 필드명 (다중 가능)