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

630 lines
21 KiB
TypeScript

"use client";
import React, { useState, useCallback, useEffect } from "react";
import {
DashboardElement,
ChartDataSource,
ElementSubtype,
QueryResult,
ListWidgetConfig,
ChartConfig,
CustomMetricConfig,
} from "./types";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { DatabaseConfig } from "./data-sources/DatabaseConfig";
import { ApiConfig } from "./data-sources/ApiConfig";
import { QueryEditor } from "./QueryEditor";
import { ListWidgetSection } from "./widget-sections/ListWidgetSection";
import { ChartConfigSection } from "./widget-sections/ChartConfigSection";
import { CustomMetricSection } from "./widget-sections/CustomMetricSection";
import { MapConfigSection } from "./widget-sections/MapConfigSection";
import { RiskAlertSection } from "./widget-sections/RiskAlertSection";
import MultiDataSourceConfig from "@/components/admin/dashboard/data-sources/MultiDataSourceConfig";
interface WidgetConfigSidebarProps {
element: DashboardElement | null;
isOpen: boolean;
onClose: () => void;
onApply: (element: DashboardElement) => void;
}
// 위젯 분류 헬퍼 함수
const needsDataSource = (subtype: ElementSubtype): boolean => {
// 차트 타입들
const chartTypes = ["bar", "horizontal-bar", "pie", "line", "area", "stacked-bar", "donut", "combo"];
const dataWidgets = [
"list-v2",
"custom-metric-v2",
"chart",
"map-summary-v2",
"risk-alert-v2",
"yard-management-3d",
"todo",
"document",
"work-history",
"transport-stats",
"booking-alert",
"maintenance",
"vehicle-status",
"vehicle-list",
"status-summary",
"delivery-status",
"delivery-status-summary",
"delivery-today-stats",
"cargo-list",
"customer-issues",
"driver-management",
];
return chartTypes.includes(subtype) || dataWidgets.includes(subtype);
};
const getWidgetIcon = (subtype: ElementSubtype): string => {
const iconMap: Record<string, string> = {
"list-v2": "📋",
"custom-metric-v2": "📊",
chart: "📈",
"map-summary-v2": "🗺️",
"risk-alert-v2": "⚠️",
"yard-management-3d": "🏗️",
weather: "🌤️",
exchange: "💱",
calculator: "🧮",
clock: "🕐",
calendar: "📅",
todo: "✅",
document: "📄",
};
return iconMap[subtype] || "🔧";
};
const getWidgetTitle = (subtype: ElementSubtype): string => {
const titleMap: Record<string, string> = {
"list-v2": "리스트 위젯",
"custom-metric-v2": "통계 카드",
chart: "차트",
"map-summary-v2": "지도",
"risk-alert-v2": "리스크 알림",
"yard-management-3d": "야드 관리 3D",
weather: "날씨 위젯",
exchange: "환율 위젯",
calculator: "계산기",
clock: "시계",
calendar: "달력",
todo: "할 일",
document: "문서",
};
return titleMap[subtype] || "위젯";
};
/**
* 통합 위젯 설정 사이드바
* - 모든 위젯 타입에 대한 일관된 설정 UI 제공
* - 일반 탭: 제목, 헤더 표시 설정
* - 데이터 탭: 데이터 소스 및 위젯별 커스텀 설정
*/
export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: WidgetConfigSidebarProps) {
// 일반 설정 state
const [customTitle, setCustomTitle] = useState<string>("");
const [showHeader, setShowHeader] = useState<boolean>(true);
// 데이터 소스 state
const [dataSource, setDataSource] = useState<ChartDataSource>({
type: "database",
connectionType: "current",
refreshInterval: 0,
});
// 다중 데이터 소스 상태 추가
const [dataSources, setDataSources] = useState<ChartDataSource[]>(element?.dataSources || []);
// 쿼리 결과
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
// 리스트 위젯 설정
const [listConfig, setListConfig] = useState<ListWidgetConfig>({
viewMode: "table",
columns: [],
pageSize: 10,
enablePagination: true,
showHeader: true,
stripedRows: true,
compactMode: false,
cardColumns: 3,
});
// 차트 설정
const [chartConfig, setChartConfig] = useState<ChartConfig>({});
// 커스텀 메트릭 설정
const [customMetricConfig, setCustomMetricConfig] = useState<CustomMetricConfig>({});
// 자동 새로고침 간격 (지도 위젯용)
const [refreshInterval, setRefreshInterval] = useState<number>(5);
// 사이드바 열릴 때 초기화
useEffect(() => {
if (isOpen && element) {
setCustomTitle(element.customTitle || "");
setShowHeader(element.showHeader !== false);
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 });
// dataSources는 element.dataSources 또는 chartConfig.dataSources에서 가져옴
setDataSources(element.dataSources || element.chartConfig?.dataSources || []);
setQueryResult(null);
// 자동 새로고침 간격 초기화
setRefreshInterval(element.chartConfig?.refreshInterval ?? 5);
// 리스트 위젯 설정 초기화
if (element.subtype === "list-v2" && element.listConfig) {
setListConfig(element.listConfig);
} else {
setListConfig({
viewMode: "table",
columns: [],
pageSize: 10,
enablePagination: true,
showHeader: true,
stripedRows: true,
compactMode: false,
cardColumns: 3,
});
}
// 차트 설정 초기화
setChartConfig(element.chartConfig || {});
// 커스텀 메트릭 설정 초기화
setCustomMetricConfig(element.customMetricConfig || {});
} else if (!isOpen) {
// 사이드바 닫힐 때 초기화
setCustomTitle("");
setShowHeader(true);
setDataSource({ type: "database", connectionType: "current", refreshInterval: 0 });
setDataSources([]);
setQueryResult(null);
setListConfig({
viewMode: "table",
columns: [],
pageSize: 10,
enablePagination: true,
showHeader: true,
stripedRows: true,
compactMode: false,
cardColumns: 3,
});
setChartConfig({});
setCustomMetricConfig({});
}
}, [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,
});
}
}, []);
// 데이터 소스 업데이트
const handleDataSourceUpdate = useCallback((updates: Partial<ChartDataSource>) => {
setDataSource((prev) => ({ ...prev, ...updates }));
}, []);
// 다중 데이터 소스 변경 핸들러
const handleDataSourcesChange = useCallback((updatedSources: ChartDataSource[]) => {
setDataSources(updatedSources);
}, []);
// 쿼리 테스트 결과 처리
const handleQueryTest = useCallback(
(result: QueryResult) => {
setQueryResult(result);
// 리스트 위젯: 쿼리 결과로 컬럼 자동 생성
if (element?.subtype === "list-v2" && result.columns && result.columns.length > 0) {
const newColumns = result.columns.map((col: string, idx: number) => ({
id: `col_${Date.now()}_${idx}`,
field: col,
label: col,
visible: true,
sortable: true,
filterable: false,
align: "left" as const,
}));
setListConfig((prev) => ({ ...prev, columns: newColumns }));
}
},
[element],
);
// 리스트 설정 변경
const handleListConfigChange = useCallback((updates: Partial<ListWidgetConfig>) => {
setListConfig((prev) => ({ ...prev, ...updates }));
}, []);
// 차트 설정 변경
const handleChartConfigChange = useCallback((config: ChartConfig) => {
setChartConfig(config);
}, []);
// 커스텀 메트릭 설정 변경
const handleCustomMetricConfigChange = useCallback((updates: Partial<CustomMetricConfig>) => {
setCustomMetricConfig((prev) => ({ ...prev, ...updates }));
}, []);
// 적용
const handleApply = useCallback(() => {
if (!element) return;
// 다중 데이터 소스를 사용하는 위젯 체크
const isMultiDataSourceWidget =
element.subtype === "map-summary-v2" ||
element.subtype === "chart" ||
element.subtype === "list-v2" ||
element.subtype === "custom-metric-v2" ||
element.subtype === "risk-alert-v2";
// chartConfig 구성 (위젯 타입별로 다르게 처리)
let finalChartConfig = { ...chartConfig };
if (isMultiDataSourceWidget) {
finalChartConfig = {
...finalChartConfig,
dataSources: dataSources,
};
}
// 지도 위젯인 경우 refreshInterval 추가
if (element.subtype === "map-summary-v2") {
finalChartConfig = {
...finalChartConfig,
refreshInterval,
};
}
const updatedElement: DashboardElement = {
...element,
customTitle: customTitle.trim() || undefined,
showHeader,
// 데이터 소스 처리
...(needsDataSource(element.subtype)
? {
dataSource,
// 다중 데이터 소스 위젯은 dataSources도 포함 (빈 배열도 허용 - 연결 해제)
...(isMultiDataSourceWidget
? {
dataSources: dataSources,
}
: {}),
}
: {}),
// 리스트 위젯 설정
...(element.subtype === "list-v2"
? {
listConfig,
}
: {}),
// 차트 설정 (모든 위젯 공통)
...(needsDataSource(element.subtype)
? {
chartConfig: finalChartConfig,
}
: {}),
// 커스텀 메트릭 설정
...(element.subtype === "custom-metric-v2"
? {
customMetricConfig,
}
: {}),
};
console.log("🔧 [WidgetConfigSidebar] handleApply 호출:", {
subtype: element.subtype,
isMultiDataSourceWidget,
dataSources,
listConfig,
finalChartConfig,
customMetricConfig,
updatedElement,
});
onApply(updatedElement);
onClose();
}, [
element,
customTitle,
showHeader,
dataSource,
dataSources,
listConfig,
chartConfig,
customMetricConfig,
refreshInterval,
onApply,
onClose,
]);
if (!element) return null;
const hasDataTab = needsDataSource(element.subtype);
const widgetIcon = getWidgetIcon(element.subtype);
const widgetTitle = getWidgetTitle(element.subtype);
return (
<div
className={cn(
"bg-muted fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col transition-transform duration-300 ease-in-out",
isOpen ? "translate-x-0" : "translate-x-[-100%]",
)}
>
{/* 헤더 */}
<div className="bg-background flex items-center justify-between 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">{widgetIcon}</span>
</div>
<span className="text-foreground text-xs font-semibold">{widgetTitle} </span>
</div>
<button
onClick={onClose}
className="hover:bg-muted flex h-6 w-6 items-center justify-center rounded transition-colors"
>
<X className="text-muted-foreground h-3.5 w-3.5" />
</button>
</div>
{/* 탭 영역 */}
<Tabs defaultValue="general" className="flex flex-1 flex-col overflow-hidden">
<TabsList className="bg-background mx-3 mt-3 grid h-9 w-auto grid-cols-2">
<TabsTrigger value="general" className="text-xs">
</TabsTrigger>
{hasDataTab && (
<TabsTrigger value="data" className="text-xs">
</TabsTrigger>
)}
</TabsList>
{/* 일반 탭 */}
<TabsContent value="general" className="mt-0 flex-1 overflow-y-auto p-3">
<div className="space-y-3">
{/* 위젯 제목 */}
<div className="bg-background rounded-lg p-3 shadow-sm">
<Label htmlFor="widget-title" className="mb-2 block text-xs font-semibold">
</Label>
<Input
id="widget-title"
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
placeholder={`기본 제목: ${element.title}`}
className="h-9 text-sm"
/>
<p className="text-muted-foreground mt-1.5 text-xs"> </p>
</div>
{/* 헤더 표시 */}
<div className="bg-background rounded-lg p-3 shadow-sm">
<div className="flex items-center justify-between">
<div className="flex-1">
<Label htmlFor="show-header" className="text-xs font-semibold">
</Label>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
<Switch id="show-header" checked={showHeader} onCheckedChange={setShowHeader} />
</div>
</div>
{/* 자동 새로고침 설정 (지도 위젯 전용) */}
{element.subtype === "map-summary-v2" && (
<div className="bg-background rounded-lg p-3 shadow-sm">
<Label htmlFor="refresh-interval" className="mb-2 block text-xs font-semibold">
</Label>
<Select value={refreshInterval.toString()} onValueChange={(value) => setRefreshInterval(parseInt(value))}>
<SelectTrigger id="refresh-interval" className="h-9 text-sm">
<SelectValue placeholder="간격 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0" className="text-sm">
</SelectItem>
<SelectItem value="5" className="text-sm">
5
</SelectItem>
<SelectItem value="10" className="text-sm">
10
</SelectItem>
<SelectItem value="30" className="text-sm">
30
</SelectItem>
<SelectItem value="60" className="text-sm">
1
</SelectItem>
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1.5 text-xs">
</p>
</div>
)}
</div>
</TabsContent>
{/* 데이터 탭 */}
{hasDataTab && (
<TabsContent value="data" className="mt-0 flex-1 overflow-y-auto p-3">
<div className="space-y-3">
{/* 데이터 소스 선택 - 단일 데이터 소스 위젯에만 표시 */}
{!["map-summary-v2", "chart", "risk-alert-v2"].includes(element.subtype) && (
<div className="bg-background rounded-lg p-3 shadow-sm">
<Label className="mb-2 block text-xs font-semibold"> </Label>
<Tabs
value={dataSource.type}
onValueChange={(value) => handleDataSourceTypeChange(value as "database" | "api")}
className="w-full"
>
<TabsList className="bg-muted grid h-8 w-full grid-cols-2 p-0.5">
<TabsTrigger
value="database"
className="data-[state=active]:bg-background h-7 rounded text-xs data-[state=active]:shadow-sm"
>
</TabsTrigger>
<TabsTrigger
value="api"
className="data-[state=active]:bg-background h-7 rounded text-xs 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}
/>
</TabsContent>
<TabsContent value="api" className="mt-2 space-y-2">
<ApiConfig
dataSource={dataSource}
onChange={handleDataSourceUpdate}
onTestResult={handleQueryTest}
/>
</TabsContent>
</Tabs>
</div>
)}
{/* 다중 데이터 소스 설정 */}
{["map-summary-v2", "chart", "risk-alert-v2"].includes(element.subtype) && (
<MultiDataSourceConfig dataSources={dataSources} onChange={handleDataSourcesChange} />
)}
{/* 위젯별 커스텀 섹션 */}
{element.subtype === "list-v2" && (
<ListWidgetSection
queryResult={queryResult}
config={listConfig}
onConfigChange={handleListConfigChange}
/>
)}
{/* 차트 설정 */}
{(element.type === "chart" ||
element.subtype === "chart" ||
["bar", "horizontal-bar", "pie", "line", "area", "stacked-bar", "donut", "combo"].includes(
element.subtype,
)) && (
<ChartConfigSection
queryResult={queryResult}
dataSource={dataSource}
config={chartConfig}
chartType={element.subtype}
onConfigChange={handleChartConfigChange}
/>
)}
{/* 커스텀 메트릭 설정 */}
{element.subtype === "custom-metric-v2" && (
<CustomMetricSection
queryResult={queryResult}
config={customMetricConfig}
onConfigChange={handleCustomMetricConfigChange}
/>
)}
{/* 지도 설정 */}
{element.subtype === "map-summary-v2" && (
<MapConfigSection
queryResult={queryResult}
refreshInterval={element.chartConfig?.refreshInterval || 5}
markerType={element.chartConfig?.markerType || "circle"}
onRefreshIntervalChange={(interval) => {
setChartConfig((prev) => ({
...prev,
refreshInterval: interval,
}));
}}
onMarkerTypeChange={(type) => {
setChartConfig((prev) => ({
...prev,
markerType: type,
}));
}}
/>
)}
{/* 리스크 알림 설정 */}
{element.subtype === "risk-alert-v2" && <RiskAlertSection queryResult={queryResult} />}
</div>
</TabsContent>
)}
</Tabs>
{/* 푸터 */}
<div className="bg-background flex gap-2 border-t p-3">
<Button variant="outline" onClick={onClose} className="h-9 flex-1 text-sm">
</Button>
<Button
onClick={handleApply}
className="h-9 flex-1 text-sm"
disabled={
// 다중 데이터 소스 위젯: 데이터 소스가 있는데 endpoint가 비어있으면 비활성화
// (데이터 소스가 없는 건 OK - 연결 해제하는 경우)
(element?.subtype === "map-summary-v2" ||
element?.subtype === "chart" ||
element?.subtype === "list-v2" ||
element?.subtype === "custom-metric-v2" ||
element?.subtype === "risk-alert-v2") &&
dataSources &&
dataSources.length > 0 &&
dataSources.some(ds => ds.type === "api" && !ds.endpoint)
}
>
</Button>
</div>
</div>
);
}