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

585 lines
23 KiB
TypeScript
Raw Normal View History

2025-10-22 10:45:10 +09:00
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { DashboardElement, ChartDataSource, ChartConfig, QueryResult } from "./types";
import { QueryEditor } from "./QueryEditor";
import { ChartConfigPanel } from "./ChartConfigPanel";
import { VehicleMapConfigPanel } from "./VehicleMapConfigPanel";
import { MapTestConfigPanel } from "./MapTestConfigPanel";
import { MultiChartConfigPanel } from "./MultiChartConfigPanel";
2025-10-22 10:45:10 +09:00
import { DatabaseConfig } from "./data-sources/DatabaseConfig";
import { ApiConfig } from "./data-sources/ApiConfig";
import MultiDataSourceConfig from "./data-sources/MultiDataSourceConfig";
2025-10-22 13:40:15 +09:00
import { ListWidgetConfigSidebar } from "./widgets/ListWidgetConfigSidebar";
import { YardWidgetConfigSidebar } from "./widgets/YardWidgetConfigSidebar";
2025-10-22 10:58:21 +09:00
import { X } from "lucide-react";
2025-10-22 10:45:10 +09:00
import { cn } from "@/lib/utils";
2025-10-22 10:58:21 +09:00
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import CustomMetricConfigSidebar from "./widgets/custom-metric/CustomMetricConfigSidebar";
2025-10-22 10:45:10 +09:00
interface ElementConfigSidebarProps {
element: DashboardElement | null;
isOpen: boolean;
onClose: () => void;
onApply: (element: DashboardElement) => void;
}
/**
*
* - /
* -
* - "적용"
*/
export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: ElementConfigSidebarProps) {
const [dataSource, setDataSource] = useState<ChartDataSource>({
type: "database",
connectionType: "current",
refreshInterval: 0,
});
const [dataSources, setDataSources] = useState<ChartDataSource[]>([]);
2025-10-22 10:45:10 +09:00
const [chartConfig, setChartConfig] = useState<ChartConfig>({});
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
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(),
);
2025-10-22 10:45:10 +09:00
// 사이드바가 열릴 때 초기화
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);
2025-10-22 10:45:10 +09:00
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 });
// dataSources는 element.dataSources 또는 chartConfig.dataSources에서 로드
// ⚠️ 중요: 없으면 반드시 빈 배열로 초기화
const initialDataSources = element.dataSources || element.chartConfig?.dataSources || [];
console.log("🔄 초기화된 dataSources:", initialDataSources);
setDataSources(initialDataSources);
2025-10-22 10:45:10 +09:00
setChartConfig(element.chartConfig || {});
setQueryResult(null);
setTestResults(new Map()); // 테스트 결과도 초기화
2025-10-22 10:45:10 +09:00
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);
2025-10-22 10:45:10 +09:00
}
}, [isOpen, element]);
// Esc 키로 사이드바 닫기
useEffect(() => {
if (!isOpen) return;
const handleEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleEsc);
return () => window.removeEventListener("keydown", handleEsc);
}, [isOpen, onClose]);
// 데이터 소스 타입 변경
const handleDataSourceTypeChange = useCallback((type: "database" | "api") => {
if (type === "database") {
setDataSource({
type: "database",
connectionType: "current",
refreshInterval: 0,
});
} else {
setDataSource({
type: "api",
method: "GET",
refreshInterval: 0,
});
}
setQueryResult(null);
setChartConfig({});
}, []);
// 데이터 소스 업데이트
const handleDataSourceUpdate = useCallback((updates: Partial<ChartDataSource>) => {
setDataSource((prev) => ({ ...prev, ...updates }));
}, []);
// 차트 설정 변경 처리
const handleChartConfigChange = useCallback(
(newConfig: ChartConfig) => {
setChartConfig(newConfig);
// 🎯 실시간 미리보기: 즉시 부모에게 전달 (map-test 위젯용)
if (element && element.subtype === "map-test" && newConfig.tileMapUrl) {
onApply({
...element,
chartConfig: newConfig,
dataSource: dataSource,
customTitle: customTitle,
showHeader: showHeader,
});
}
},
[element, dataSource, customTitle, showHeader, onApply],
);
2025-10-22 10:45:10 +09:00
// 쿼리 테스트 결과 처리
const handleQueryTest = useCallback((result: QueryResult) => {
setQueryResult(result);
setChartConfig({});
}, []);
// 적용 처리
const handleApply = useCallback(() => {
if (!element) return;
console.log("🔧 적용 버튼 클릭 - dataSource:", dataSource);
2025-10-28 13:40:17 +09:00
console.log("🔧 적용 버튼 클릭 - dataSources:", dataSources);
console.log("🔧 적용 버튼 클릭 - chartConfig:", chartConfig);
// 다중 데이터 소스 위젯 체크
const isMultiDS =
element.subtype === "map-summary-v2" ||
element.subtype === "chart" ||
element.subtype === "list-v2" ||
element.subtype === "custom-metric-v2" ||
element.subtype === "risk-alert-v2";
2025-10-22 10:45:10 +09:00
const updatedElement: DashboardElement = {
...element,
// 다중 데이터 소스 위젯은 dataSources를 chartConfig에 저장
chartConfig: isMultiDS ? { ...chartConfig, dataSources } : chartConfig,
dataSources: isMultiDS ? dataSources : undefined, // 프론트엔드 호환성
dataSource: isMultiDS ? undefined : dataSource,
2025-10-22 10:45:10 +09:00
customTitle: customTitle.trim() || undefined,
showHeader,
};
console.log("🔧 적용할 요소:", updatedElement);
2025-10-22 10:45:10 +09:00
onApply(updatedElement);
// 사이드바는 열린 채로 유지 (연속 수정 가능)
}, [element, dataSource, dataSources, chartConfig, customTitle, showHeader, onApply]);
2025-10-22 10:45:10 +09:00
// 요소가 없으면 렌더링하지 않음
if (!element) return null;
2025-10-22 13:40:15 +09:00
// 리스트 위젯은 별도 사이드바로 처리
if (element.subtype === "list") {
return (
<ListWidgetConfigSidebar
element={element}
isOpen={isOpen}
onClose={onClose}
onApply={(updatedElement) => {
onApply(updatedElement);
}}
/>
);
}
// 야드 위젯은 사이드바로 처리
2025-10-22 13:40:15 +09:00
if (element.subtype === "yard-management-3d") {
return (
<YardWidgetConfigSidebar
2025-10-22 13:40:15 +09:00
element={element}
isOpen={isOpen}
onApply={(updates) => {
onApply({ ...element, ...updates });
2025-10-22 13:40:15 +09:00
}}
onClose={onClose}
2025-10-22 13:40:15 +09:00
/>
);
}
// 사용자 커스텀 카드 위젯은 사이드바로 처리
if (element.subtype === "custom-metric") {
return (
<CustomMetricConfigSidebar
element={element}
isOpen={isOpen}
onClose={onClose}
onApply={(updates) => {
onApply({ ...element, ...updates });
}}
/>
);
}
2025-10-22 10:45:10 +09:00
// 차트 설정이 필요 없는 위젯 (쿼리/API만 필요)
const isSimpleWidget =
element.subtype === "todo" ||
element.subtype === "booking-alert" ||
element.subtype === "maintenance" ||
element.subtype === "document" ||
element.subtype === "risk-alert" ||
element.subtype === "vehicle-status" ||
element.subtype === "vehicle-list" ||
element.subtype === "status-summary" ||
element.subtype === "delivery-status" ||
element.subtype === "delivery-status-summary" ||
element.subtype === "delivery-today-stats" ||
element.subtype === "cargo-list" ||
element.subtype === "customer-issues" ||
element.subtype === "driver-management" ||
element.subtype === "work-history" ||
element.subtype === "transport-stats";
// 자체 기능 위젯 (DB 연결 불필요, 헤더 설정만 가능)
const isSelfContainedWidget =
element.subtype === "weather" || element.subtype === "exchange" || element.subtype === "calculator";
// 지도 위젯 (위도/경도 매핑 필요)
const isMapWidget =
element.subtype === "vehicle-map" || element.subtype === "map-summary" || element.subtype === "map-test";
2025-10-22 10:45:10 +09:00
// 헤더 전용 위젯
const isHeaderOnlyWidget =
element.type === "widget" &&
(element.subtype === "clock" || element.subtype === "calendar" || isSelfContainedWidget);
// 다중 데이터 소스 위젯
const isMultiDataSourceWidget =
element.subtype === "map-summary-v2" ||
element.subtype === "chart" ||
element.subtype === "list-v2" ||
element.subtype === "custom-metric-v2" ||
2025-10-28 13:40:17 +09:00
element.subtype === "status-summary-test" ||
element.subtype === "risk-alert-v2";
2025-10-22 10:45:10 +09:00
// 저장 가능 여부 확인
const isPieChart = element.subtype === "pie" || element.subtype === "donut";
const isApiSource = dataSource.type === "api";
const hasYAxis =
chartConfig.yAxis &&
(typeof chartConfig.yAxis === "string" || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0));
const isTitleChanged = customTitle.trim() !== (element.customTitle || "");
const isHeaderChanged = showHeader !== (element.showHeader !== false);
const canApply =
isTitleChanged ||
isHeaderChanged ||
(isMultiDataSourceWidget
? true // 다중 데이터 소스 위젯은 항상 적용 가능
: isSimpleWidget
? queryResult && queryResult.rows.length > 0
: isMapWidget
? element.subtype === "map-test"
? chartConfig.tileMapUrl || (queryResult && queryResult.rows.length > 0) // 🧪 지도 테스트 위젯: 타일맵 URL 또는 API 데이터
: queryResult && queryResult.rows.length > 0 && chartConfig.latitudeColumn && chartConfig.longitudeColumn
: queryResult &&
queryResult.rows.length > 0 &&
chartConfig.xAxis &&
(isPieChart || isApiSource ? (chartConfig.aggregation === "count" ? true : hasYAxis) : hasYAxis));
2025-10-22 10:45:10 +09:00
return (
<div
className={cn(
2025-10-22 12:48:17 +09:00
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-gray-50 transition-transform duration-300 ease-in-out",
2025-10-22 10:45:10 +09:00
isOpen ? "translate-x-0" : "translate-x-[-100%]",
)}
>
{/* 헤더 */}
2025-10-22 12:48:17 +09:00
<div className="flex items-center justify-between bg-white px-3 py-2 shadow-sm">
<div className="flex items-center gap-2">
<div className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded">
<span className="text-primary text-xs font-bold"></span>
</div>
<span className="text-xs font-semibold text-gray-900">{element.title}</span>
</div>
<button
onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-gray-100"
>
<X className="h-3.5 w-3.5 text-gray-500" />
</button>
2025-10-22 10:45:10 +09:00
</div>
{/* 본문: 스크롤 가능 영역 */}
2025-10-22 12:48:17 +09:00
<div className="flex-1 overflow-y-auto p-3">
{/* 기본 설정 카드 */}
<div className="mb-3 rounded-lg bg-white p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase"> </div>
<div className="space-y-2">
{/* 커스텀 제목 입력 */}
<div>
<input
type="text"
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
placeholder="위젯 제목"
className="focus:border-primary focus:ring-primary/20 h-8 w-full rounded border border-gray-200 bg-gray-50 px-2 text-xs placeholder:text-gray-400 focus:bg-white focus:ring-1 focus:outline-none"
/>
</div>
{/* 헤더 표시 옵션 */}
<label className="flex cursor-pointer items-center gap-2 rounded border border-gray-200 bg-gray-50 px-2 py-1.5 transition-colors hover:border-gray-300">
<input
type="checkbox"
id="showHeader"
checked={showHeader}
onChange={(e) => setShowHeader(e.target.checked)}
className="text-primary focus:ring-primary h-3 w-3 rounded border-gray-300"
/>
<span className="text-xs text-gray-700"> </span>
2025-10-22 10:58:21 +09:00
</label>
2025-10-22 10:45:10 +09:00
</div>
2025-10-22 10:58:21 +09:00
</div>
2025-10-22 10:45:10 +09:00
{/* 다중 데이터 소스 위젯 */}
{isMultiDataSourceWidget && (
<>
<div className="rounded-lg bg-white p-3 shadow-sm">
<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>
{/* 지도 위젯: 타일맵 URL 설정 */}
{element.subtype === "map-summary-v2" && (
<div className="rounded-lg bg-white shadow-sm">
<details className="group">
<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]"> VWorld </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">
<MapTestConfigPanel
config={chartConfig}
queryResult={undefined}
onConfigChange={handleChartConfigChange}
/>
</div>
</details>
</div>
)}
{/* 차트 위젯: 차트 설정 */}
{element.subtype === "chart" && (
<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>
)}
</>
)}
2025-10-22 12:48:17 +09:00
{/* 헤더 전용 위젯이 아닐 때만 데이터 소스 표시 */}
{!isHeaderOnlyWidget && !isMultiDataSourceWidget && (
2025-10-22 12:48:17 +09:00
<div className="rounded-lg bg-white p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase"> </div>
<Tabs
defaultValue={dataSource.type}
onValueChange={(value) => handleDataSourceTypeChange(value as "database" | "api")}
className="w-full"
>
<TabsList className="grid h-7 w-full grid-cols-2 bg-gray-100 p-0.5">
<TabsTrigger
value="database"
className="h-6 rounded text-[11px] data-[state=active]:bg-white data-[state=active]:shadow-sm"
>
</TabsTrigger>
<TabsTrigger
value="api"
className="h-6 rounded text-[11px] data-[state=active]:bg-white data-[state=active]:shadow-sm"
>
REST API
</TabsTrigger>
</TabsList>
<TabsContent value="database" className="mt-2 space-y-2">
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
<QueryEditor
dataSource={dataSource}
onDataSourceChange={handleDataSourceUpdate}
onQueryTest={handleQueryTest}
/>
{/* 차트/지도 설정 */}
{!isSimpleWidget &&
(element.subtype === "map-test" || (queryResult && queryResult.rows.length > 0)) && (
<div className="mt-2">
{isMapWidget ? (
element.subtype === "map-test" ? (
<MapTestConfigPanel
config={chartConfig}
queryResult={queryResult || undefined}
onConfigChange={handleChartConfigChange}
/>
) : (
queryResult &&
queryResult.rows.length > 0 && (
<VehicleMapConfigPanel
config={chartConfig}
queryResult={queryResult}
onConfigChange={handleChartConfigChange}
/>
)
)
) : (
queryResult &&
queryResult.rows.length > 0 && (
<ChartConfigPanel
config={chartConfig}
queryResult={queryResult}
onConfigChange={handleChartConfigChange}
chartType={element.subtype}
dataSourceType={dataSource.type}
query={dataSource.query}
/>
)
)}
</div>
)}
2025-10-22 12:48:17 +09:00
</TabsContent>
<TabsContent value="api" className="mt-2 space-y-2">
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
{/* 차트/지도 설정 */}
{!isSimpleWidget &&
(element.subtype === "map-test" || (queryResult && queryResult.rows.length > 0)) && (
<div className="mt-2">
{isMapWidget ? (
element.subtype === "map-test" ? (
<MapTestConfigPanel
config={chartConfig}
queryResult={queryResult || undefined}
onConfigChange={handleChartConfigChange}
/>
) : (
queryResult &&
queryResult.rows.length > 0 && (
<VehicleMapConfigPanel
config={chartConfig}
queryResult={queryResult}
onConfigChange={handleChartConfigChange}
/>
)
)
) : (
queryResult &&
queryResult.rows.length > 0 && (
<ChartConfigPanel
config={chartConfig}
queryResult={queryResult}
onConfigChange={handleChartConfigChange}
chartType={element.subtype}
dataSourceType={dataSource.type}
query={dataSource.query}
/>
)
)}
</div>
)}
2025-10-22 12:48:17 +09:00
</TabsContent>
</Tabs>
{/* 데이터 로드 상태 */}
{queryResult && (
<div className="mt-2 flex items-center gap-1.5 rounded bg-green-50 px-2 py-1">
<div className="h-1.5 w-1.5 rounded-full bg-green-500" />
<span className="text-[10px] font-medium text-green-700">
{queryResult.rows.length}
</span>
</div>
)}
2025-10-22 10:45:10 +09:00
</div>
)}
</div>
2025-10-22 10:58:21 +09:00
{/* 푸터: 적용 버튼 */}
2025-10-22 12:48:17 +09:00
<div className="flex gap-2 bg-white p-2 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
<button
onClick={onClose}
className="flex-1 rounded bg-gray-100 py-2 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-200"
>
2025-10-22 10:58:21 +09:00
2025-10-22 12:48:17 +09:00
</button>
<button
onClick={handleApply}
disabled={isHeaderOnlyWidget ? false : !canApply}
className="bg-primary hover:bg-primary/90 flex-1 rounded py-2 text-xs font-medium text-white transition-colors disabled:cursor-not-allowed disabled:opacity-50"
>
2025-10-22 10:58:21 +09:00
2025-10-22 12:48:17 +09:00
</button>
2025-10-22 10:45:10 +09:00
</div>
</div>
);
}