위젯 사이드바 통일

This commit is contained in:
dohyeons 2025-10-31 11:02:06 +09:00
parent cff8f39bc3
commit e086719235
11 changed files with 718 additions and 1461 deletions

View File

@ -4,7 +4,7 @@ import React, { useState, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import { DashboardCanvas } from "./DashboardCanvas";
import { DashboardTopMenu } from "./DashboardTopMenu";
import { ElementConfigSidebar } from "./ElementConfigSidebar";
import { WidgetConfigSidebar } from "./WidgetConfigSidebar";
import { DashboardSaveModal } from "./DashboardSaveModal";
import { DashboardElement, ElementType, ElementSubtype } from "./types";
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateBoxSize } from "./gridUtils";
@ -581,8 +581,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
</div>
</div>
{/* 요소 설정 사이드바 (리스트/야드 위젯 포함) */}
<ElementConfigSidebar
{/* 요소 설정 사이드바 (통합) */}
<WidgetConfigSidebar
element={sidebarElement}
isOpen={sidebarOpen}
onClose={handleCloseSidebar}

View File

@ -1,563 +0,0 @@
"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";
import { DatabaseConfig } from "./data-sources/DatabaseConfig";
import { ApiConfig } from "./data-sources/ApiConfig";
import MultiDataSourceConfig from "./data-sources/MultiDataSourceConfig";
import { ListWidgetConfigSidebar } from "./widgets/ListWidgetConfigSidebar";
import { YardWidgetConfigSidebar } from "./widgets/YardWidgetConfigSidebar";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import CustomMetricConfigSidebar from "./widgets/custom-metric/CustomMetricConfigSidebar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
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[]>([]);
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(),
);
// 사이드바가 열릴 때 초기화
useEffect(() => {
if (isOpen && element) {
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 });
// dataSources는 element.dataSources 또는 chartConfig.dataSources에서 로드
// ⚠️ 중요: 없으면 반드시 빈 배열로 초기화
const initialDataSources = element.dataSources || element.chartConfig?.dataSources || [];
setDataSources(initialDataSources);
setChartConfig(element.chartConfig || {});
setQueryResult(null);
setTestResults(new Map()); // 테스트 결과도 초기화
setCustomTitle(element.customTitle || "");
setShowHeader(element.showHeader !== false);
} else if (!isOpen) {
// 사이드바가 닫힐 때 모든 상태 초기화
setDataSource({ type: "database", connectionType: "current", refreshInterval: 0 });
setDataSources([]);
setChartConfig({});
setQueryResult(null);
setTestResults(new Map());
setCustomTitle("");
setShowHeader(true);
}
}, [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-summary-v2 위젯용)
if (element && element.subtype === "map-summary-v2" && newConfig.tileMapUrl) {
onApply({
...element,
chartConfig: newConfig,
dataSource: dataSource,
customTitle: customTitle,
showHeader: showHeader,
});
}
},
[element, dataSource, customTitle, showHeader, onApply],
);
// 쿼리 테스트 결과 처리
const handleQueryTest = useCallback((result: QueryResult) => {
setQueryResult(result);
setChartConfig({});
}, []);
// 적용 처리
const handleApply = useCallback(() => {
if (!element) return;
// 다중 데이터 소스 위젯 체크
const isMultiDS =
element.subtype === "map-summary-v2" ||
element.subtype === "chart" ||
element.subtype === "list-v2" ||
element.subtype === "custom-metric-v2" ||
element.subtype === "risk-alert-v2";
const updatedElement: DashboardElement = {
...element,
// 다중 데이터 소스 위젯은 dataSources를 chartConfig에 저장
chartConfig: isMultiDS ? { ...chartConfig, dataSources } : chartConfig,
dataSources: isMultiDS ? dataSources : undefined, // 프론트엔드 호환성
dataSource: isMultiDS ? undefined : dataSource,
customTitle: customTitle.trim() || undefined,
showHeader,
};
onApply(updatedElement);
// 사이드바는 열린 채로 유지 (연속 수정 가능)
}, [element, dataSource, dataSources, chartConfig, customTitle, showHeader, onApply]);
// 요소가 없으면 렌더링하지 않음
if (!element) return null;
// 리스트 위젯은 별도 사이드바로 처리
if (element.subtype === "list-v2") {
return (
<ListWidgetConfigSidebar
element={element}
isOpen={isOpen}
onClose={onClose}
onApply={(updatedElement) => {
onApply(updatedElement);
}}
/>
);
}
// 야드 위젯은 사이드바로 처리
if (element.subtype === "yard-management-3d") {
return (
<YardWidgetConfigSidebar
element={element}
isOpen={isOpen}
onApply={(updates) => {
onApply({ ...element, ...updates });
}}
onClose={onClose}
/>
);
}
// 사용자 커스텀 카드 위젯은 사이드바로 처리
if (element.subtype === "custom-metric-v2") {
return (
<CustomMetricConfigSidebar
element={element}
isOpen={isOpen}
onClose={onClose}
onApply={(updates) => {
onApply({ ...element, ...updates });
}}
/>
);
}
// 차트 설정이 필요 없는 위젯 (쿼리/API만 필요)
const isSimpleWidget =
element.subtype === "todo" ||
element.subtype === "booking-alert" ||
element.subtype === "maintenance" ||
element.subtype === "document" ||
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-v2";
// 헤더 전용 위젯
const isHeaderOnlyWidget =
element.type === "widget" &&
(element.subtype === "clock" || element.subtype === "calendar" || isSelfContainedWidget);
// 다중 데이터 소스 위젯
const isMultiDataSourceWidget =
(element.subtype as string) === "map-summary-v2" ||
(element.subtype as string) === "chart" ||
(element.subtype as string) === "list-v2" ||
(element.subtype as string) === "custom-metric-v2" ||
(element.subtype as string) === "risk-alert-v2";
// 저장 가능 여부 확인
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-summary-v2"
? 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));
return (
<div
className={cn(
"bg-muted fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-72 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"></span>
</div>
<span className="text-foreground text-xs font-semibold">{element.title}</span>
</div>
<Button onClick={onClose} variant="ghost" size="icon" className="h-6 w-6">
<X className="h-3.5 w-3.5" />
</Button>
</div>
{/* 본문: 스크롤 가능 영역 */}
<div className="flex-1 overflow-y-auto p-3">
{/* 기본 설정 카드 */}
<div className="bg-background mb-3 rounded-lg p-3 shadow-sm">
<div className="text-muted-foreground mb-2 text-[10px] font-semibold tracking-wide uppercase"> </div>
<div className="space-y-2">
{/* 커스텀 제목 입력 */}
<div>
<Input
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
placeholder="위젯 제목"
className="bg-muted focus:bg-background h-8 text-xs"
/>
</div>
{/* 헤더 표시 옵션 */}
<div className="border-border bg-muted flex items-center gap-2 rounded border px-2 py-1.5">
<Checkbox
id="showHeader"
checked={showHeader}
onCheckedChange={(checked) => setShowHeader(checked === true)}
/>
<Label htmlFor="showHeader" className="cursor-pointer text-xs font-normal">
</Label>
</div>
</div>
</div>
{/* 다중 데이터 소스 위젯 */}
{isMultiDataSourceWidget && (
<>
<div className="bg-background rounded-lg p-3 shadow-sm">
<MultiDataSourceConfig
dataSources={dataSources}
onChange={setDataSources}
onTestResult={(result, dataSourceId) => {
// API 테스트 결과를 queryResult로 설정 (차트 설정용)
setQueryResult({
...result,
totalRows: result.rows.length,
executionTime: 0,
});
// 각 데이터 소스의 테스트 결과 저장
setTestResults((prev) => {
const updated = new Map(prev);
updated.set(dataSourceId, result);
return updated;
});
}}
/>
</div>
{/* 지도 위젯: 타일맵 URL 설정 */}
{element.subtype === "map-summary-v2" && (
<div className="bg-background rounded-lg shadow-sm">
<details className="group">
<summary className="hover:bg-muted flex cursor-pointer items-center justify-between p-3">
<div>
<div className="text-muted-foreground text-xs font-semibold tracking-wide 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="bg-background rounded-lg shadow-sm">
<details className="group" open>
<summary className="hover:bg-muted flex cursor-pointer items-center justify-between p-3">
<div>
<div className="text-muted-foreground text-xs font-semibold tracking-wide 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>
)}
</>
)}
{/* 헤더 전용 위젯이 아닐 때만 데이터 소스 표시 */}
{!isHeaderOnlyWidget && !isMultiDataSourceWidget && (
<div className="bg-background rounded-lg p-3 shadow-sm">
<div className="text-muted-foreground mb-2 text-[10px] font-semibold tracking-wide uppercase">
</div>
<Tabs
defaultValue={dataSource.type}
onValueChange={(value) => handleDataSourceTypeChange(value as "database" | "api")}
className="w-full"
>
<TabsList className="bg-muted grid h-7 w-full grid-cols-2 p-0.5">
<TabsTrigger
value="database"
className="data-[state=active]:bg-background h-6 rounded text-[11px] data-[state=active]:shadow-sm"
>
</TabsTrigger>
<TabsTrigger
value="api"
className="data-[state=active]:bg-background h-6 rounded text-[11px] 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-summary-v2" || (queryResult && queryResult.rows.length > 0)) && (
<div className="mt-2">
{isMapWidget ? (
element.subtype === "map-summary-v2" ? (
<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>
)}
</TabsContent>
<TabsContent value="api" className="mt-2 space-y-2">
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
{/* 차트/지도 설정 */}
{!isSimpleWidget &&
(element.subtype === "map-summary-v2" || (queryResult && queryResult.rows.length > 0)) && (
<div className="mt-2">
{isMapWidget ? (
element.subtype === "map-summary-v2" ? (
<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>
)}
</TabsContent>
</Tabs>
{/* 데이터 로드 상태 */}
{queryResult && (
<div className="bg-success/10 mt-2 flex items-center gap-1.5 rounded px-2 py-1">
<div className="bg-success h-1.5 w-1.5 rounded-full" />
<span className="text-success text-[10px] font-medium">{queryResult.rows.length} </span>
</div>
)}
</div>
)}
</div>
{/* 푸터: 적용 버튼 */}
<div className="bg-background flex gap-2 p-2 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
<Button onClick={onClose} variant="outline" className="flex-1 text-xs">
</Button>
<Button onClick={handleApply} disabled={isHeaderOnlyWidget ? false : !canApply} className="flex-1 text-xs">
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,485 @@
"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 { 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";
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 [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>({ metrics: [] });
// 사이드바 열릴 때 초기화
useEffect(() => {
if (isOpen && element) {
setCustomTitle(element.customTitle || "");
setShowHeader(element.showHeader !== false);
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 });
setQueryResult(null);
// 리스트 위젯 설정 초기화
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 || { metrics: [] });
} else if (!isOpen) {
// 사이드바 닫힐 때 초기화
setCustomTitle("");
setShowHeader(true);
setDataSource({ type: "database", connectionType: "current", refreshInterval: 0 });
setQueryResult(null);
setListConfig({
viewMode: "table",
columns: [],
pageSize: 10,
enablePagination: true,
showHeader: true,
stripedRows: true,
compactMode: false,
cardColumns: 3,
});
setChartConfig({});
setCustomMetricConfig({ metrics: [] });
}
}, [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 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 updatedElement: DashboardElement = {
...element,
customTitle: customTitle.trim() || undefined,
showHeader,
// 데이터 소스가 필요한 위젯만 dataSource 포함
...(needsDataSource(element.subtype)
? {
dataSource,
}
: {}),
// 리스트 위젯 설정
...(element.subtype === "list-v2"
? {
listConfig,
}
: {}),
// 차트 설정 (차트 타입이거나 차트 기능이 있는 위젯)
...(element.type === "chart" ||
element.subtype === "chart" ||
["bar", "horizontal-bar", "pie", "line", "area", "stacked-bar", "donut", "combo"].includes(element.subtype)
? {
chartConfig,
}
: {}),
// 커스텀 메트릭 설정
...(element.subtype === "custom-metric-v2"
? {
customMetricConfig,
}
: {}),
};
onApply(updatedElement);
onClose();
}, [element, customTitle, showHeader, dataSource, listConfig, chartConfig, customMetricConfig, 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>
</div>
</TabsContent>
{/* 데이터 탭 */}
{hasDataTab && (
<TabsContent value="data" 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 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>
{/* 위젯별 커스텀 섹션 */}
{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} />}
{/* 리스크 알림 설정 */}
{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">
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,46 @@
"use client";
import React from "react";
import { ChartConfig, QueryResult, ChartDataSource } from "../types";
import { Label } from "@/components/ui/label";
import { ChartConfigPanel } from "../ChartConfigPanel";
interface ChartConfigSectionProps {
queryResult: QueryResult | null;
dataSource: ChartDataSource;
config: ChartConfig;
chartType?: string;
onConfigChange: (config: ChartConfig) => void;
}
/**
*
* - , ,
*/
export function ChartConfigSection({
queryResult,
dataSource,
config,
chartType,
onConfigChange,
}: ChartConfigSectionProps) {
// 쿼리 결과가 없으면 표시하지 않음
if (!queryResult || !queryResult.columns || queryResult.columns.length === 0) {
return null;
}
return (
<div className="rounded-lg bg-background p-3 shadow-sm">
<Label className="mb-2 block text-xs font-semibold"> </Label>
<ChartConfigPanel
config={config}
queryResult={queryResult}
onConfigChange={onConfigChange}
chartType={chartType}
dataSourceType={dataSource.type}
query={dataSource.query}
/>
</div>
);
}

View File

@ -0,0 +1,49 @@
"use client";
import React from "react";
import { CustomMetricConfig, QueryResult } from "../types";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { AlertCircle } from "lucide-react";
interface CustomMetricSectionProps {
queryResult: QueryResult | null;
config: CustomMetricConfig;
onConfigChange: (updates: Partial<CustomMetricConfig>) => void;
}
/**
*
* - , ,
*
* TODO: 상세 UI
*/
export function CustomMetricSection({ queryResult, config, onConfigChange }: CustomMetricSectionProps) {
// 쿼리 결과가 없으면 안내 메시지 표시
if (!queryResult || !queryResult.columns || queryResult.columns.length === 0) {
return (
<div className="rounded-lg bg-background p-3 shadow-sm">
<Label className="mb-2 block text-xs font-semibold"> </Label>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
.
</AlertDescription>
</Alert>
</div>
);
}
return (
<div className="rounded-lg bg-background p-3 shadow-sm">
<Label className="mb-2 block text-xs font-semibold"> </Label>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
UI는 .
</AlertDescription>
</Alert>
</div>
);
}

View File

@ -0,0 +1,41 @@
"use client";
import React from "react";
import { ListWidgetConfig, QueryResult } from "../types";
import { Label } from "@/components/ui/label";
import { UnifiedColumnEditor } from "../widgets/list-widget/UnifiedColumnEditor";
import { ListTableOptions } from "../widgets/list-widget/ListTableOptions";
interface ListWidgetSectionProps {
queryResult: QueryResult | null;
config: ListWidgetConfig;
onConfigChange: (updates: Partial<ListWidgetConfig>) => void;
}
/**
*
* -
* -
*/
export function ListWidgetSection({ queryResult, config, onConfigChange }: ListWidgetSectionProps) {
return (
<div className="space-y-3">
{/* 컬럼 설정 - 쿼리 실행 후에만 표시 */}
{queryResult && queryResult.columns.length > 0 && (
<div className="rounded-lg bg-background p-3 shadow-sm">
<Label className="mb-2 block text-xs font-semibold"> </Label>
<UnifiedColumnEditor queryResult={queryResult} config={config} onConfigChange={onConfigChange} />
</div>
)}
{/* 테이블 옵션 */}
{config.columns.length > 0 && (
<div className="rounded-lg bg-background p-3 shadow-sm">
<Label className="mb-2 block text-xs font-semibold"> </Label>
<ListTableOptions config={config} onChange={onConfigChange} />
</div>
)}
</div>
);
}

View File

@ -0,0 +1,47 @@
"use client";
import React from "react";
import { QueryResult } from "../types";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { AlertCircle } from "lucide-react";
interface MapConfigSectionProps {
queryResult: QueryResult | null;
}
/**
*
* - /
*
* TODO: 상세 UI
*/
export function MapConfigSection({ queryResult }: MapConfigSectionProps) {
// 쿼리 결과가 없으면 안내 메시지 표시
if (!queryResult || !queryResult.columns || queryResult.columns.length === 0) {
return (
<div className="rounded-lg bg-background p-3 shadow-sm">
<Label className="mb-2 block text-xs font-semibold"> </Label>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
.
</AlertDescription>
</Alert>
</div>
);
}
return (
<div className="rounded-lg bg-background p-3 shadow-sm">
<Label className="mb-2 block text-xs font-semibold"> </Label>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
UI는 .
</AlertDescription>
</Alert>
</div>
);
}

View File

@ -0,0 +1,47 @@
"use client";
import React from "react";
import { QueryResult } from "../types";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { AlertCircle } from "lucide-react";
interface RiskAlertSectionProps {
queryResult: QueryResult | null;
}
/**
*
* -
*
* TODO: 상세 UI
*/
export function RiskAlertSection({ queryResult }: RiskAlertSectionProps) {
// 쿼리 결과가 없으면 안내 메시지 표시
if (!queryResult || !queryResult.columns || queryResult.columns.length === 0) {
return (
<div className="rounded-lg bg-background p-3 shadow-sm">
<Label className="mb-2 block text-xs font-semibold"> </Label>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
.
</AlertDescription>
</Alert>
</div>
);
}
return (
<div className="rounded-lg bg-background p-3 shadow-sm">
<Label className="mb-2 block text-xs font-semibold"> </Label>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
UI는 .
</AlertDescription>
</Alert>
</div>
);
}

View File

@ -1,260 +0,0 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { DashboardElement, ChartDataSource, QueryResult, ListWidgetConfig } from "../types";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { DatabaseConfig } from "../data-sources/DatabaseConfig";
import { ApiConfig } from "../data-sources/ApiConfig";
import { QueryEditor } from "../QueryEditor";
import { UnifiedColumnEditor } from "./list-widget/UnifiedColumnEditor";
import { ListTableOptions } from "./list-widget/ListTableOptions";
interface ListWidgetConfigSidebarProps {
element: DashboardElement;
isOpen: boolean;
onClose: () => void;
onApply: (element: DashboardElement) => void;
}
/**
*
*/
export function ListWidgetConfigSidebar({ element, isOpen, onClose, onApply }: ListWidgetConfigSidebarProps) {
const [title, setTitle] = useState(element.title || "📋 리스트");
const [dataSource, setDataSource] = useState<ChartDataSource>(
element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
);
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
const [listConfig, setListConfig] = useState<ListWidgetConfig>(
element.listConfig || {
viewMode: "table",
columns: [],
pageSize: 10,
enablePagination: true,
showHeader: true,
stripedRows: true,
compactMode: false,
cardColumns: 3,
},
);
// 사이드바 열릴 때 초기화
useEffect(() => {
if (isOpen) {
setTitle(element.title || "📋 리스트");
if (element.dataSource) {
setDataSource(element.dataSource);
}
if (element.listConfig) {
setListConfig(element.listConfig);
}
}
}, [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);
}, []);
// 데이터 소스 업데이트
const handleDataSourceUpdate = useCallback((updates: Partial<ChartDataSource>) => {
setDataSource((prev) => ({ ...prev, ...updates }));
}, []);
// 쿼리 실행 결과 처리
const handleQueryTest = useCallback((result: QueryResult) => {
setQueryResult(result);
// 쿼리 실행 시마다 컬럼 설정 초기화 (새로운 쿼리 결과로 덮어쓰기)
const newColumns = result.columns.map((col, idx) => ({
id: `col_${Date.now()}_${idx}`,
field: col,
label: col,
visible: true,
align: "left" as const,
}));
setListConfig((prev) => ({
...prev,
columns: newColumns,
}));
}, []);
// 컬럼 설정 변경
const handleListConfigChange = useCallback((updates: Partial<ListWidgetConfig>) => {
setListConfig((prev) => ({ ...prev, ...updates }));
}, []);
// 적용
const handleApply = useCallback(() => {
const updatedElement: DashboardElement = {
...element,
title,
dataSource,
listConfig,
};
onApply(updatedElement);
}, [element, title, dataSource, listConfig, onApply]);
// 저장 가능 여부
const canApply = listConfig.columns.length > 0 && listConfig.columns.some((col) => col.visible && col.field);
return (
<div
className={cn(
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-muted transition-transform duration-300 ease-in-out",
isOpen ? "translate-x-0" : "translate-x-[-100%]",
)}
>
{/* 헤더 */}
<div className="flex items-center justify-between bg-background 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-foreground"> </span>
</div>
<button
onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-muted"
>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</div>
{/* 본문: 스크롤 가능 영역 */}
<div className="flex-1 overflow-y-auto p-3">
{/* 기본 설정 */}
<div className="mb-3 rounded-lg bg-background p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase"> </div>
<div className="space-y-2">
<div>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
placeholder="리스트 이름"
className="focus:border-primary focus:ring-primary/20 h-8 w-full rounded border border-border bg-muted px-2 text-xs placeholder:text-muted-foreground focus:bg-background focus:ring-1 focus:outline-none"
/>
</div>
</div>
</div>
{/* 데이터 소스 */}
<div className="rounded-lg bg-background p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground 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-muted p-0.5">
<TabsTrigger
value="database"
className="h-6 rounded text-[11px] data-[state=active]:bg-background data-[state=active]:shadow-sm"
>
</TabsTrigger>
<TabsTrigger
value="api"
className="h-6 rounded text-[11px] data-[state=active]:bg-background 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>
{/* 데이터 로드 상태 */}
{queryResult && (
<div className="mt-2 flex items-center gap-1.5 rounded bg-success/10 px-2 py-1">
<div className="h-1.5 w-1.5 rounded-full bg-success" />
<span className="text-[10px] font-medium text-success">{queryResult.rows.length} </span>
</div>
)}
</div>
{/* 컬럼 설정 - 쿼리 실행 후에만 표시 */}
{queryResult && (
<div className="mt-3 rounded-lg bg-background p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase"> </div>
<UnifiedColumnEditor
queryResult={queryResult}
config={listConfig}
onConfigChange={handleListConfigChange}
/>
</div>
)}
{/* 테이블 옵션 - 컬럼이 있을 때만 표시 */}
{listConfig.columns.length > 0 && (
<div className="mt-3 rounded-lg bg-background p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase"> </div>
<ListTableOptions config={listConfig} onConfigChange={handleListConfigChange} />
</div>
)}
</div>
{/* 푸터: 적용 버튼 */}
<div className="flex gap-2 bg-background p-2 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
<button
onClick={onClose}
className="flex-1 rounded bg-muted py-2 text-xs font-medium text-foreground transition-colors hover:bg-muted"
>
</button>
<button
onClick={handleApply}
disabled={!canApply}
className="bg-primary hover:bg-primary/90 flex-1 rounded py-2 text-xs font-medium text-primary-foreground transition-colors disabled:cursor-not-allowed disabled:opacity-50"
>
</button>
</div>
</div>
);
}

View File

@ -1,119 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { DashboardElement } from "../types";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
interface YardWidgetConfigSidebarProps {
element: DashboardElement;
isOpen: boolean;
onClose: () => void;
onApply: (updates: Partial<DashboardElement>) => void;
}
export function YardWidgetConfigSidebar({ element, isOpen, onClose, onApply }: YardWidgetConfigSidebarProps) {
const [customTitle, setCustomTitle] = useState(element.customTitle || "");
const [showHeader, setShowHeader] = useState(element.showHeader !== false);
useEffect(() => {
if (isOpen) {
setCustomTitle(element.customTitle || "");
setShowHeader(element.showHeader !== false);
}
}, [isOpen, element]);
const handleApply = () => {
onApply({
customTitle,
showHeader,
});
onClose();
};
return (
<div
className={cn(
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-muted transition-transform duration-300 ease-in-out",
isOpen ? "translate-x-0" : "translate-x-[-100%]",
)}
>
{/* 헤더 */}
<div className="flex items-center justify-between bg-background 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-foreground"> </span>
</div>
<button
onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-muted"
>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</div>
{/* 컨텐츠 */}
<div className="flex-1 overflow-y-auto p-3">
<div className="space-y-3">
{/* 위젯 제목 */}
<div className="rounded-lg bg-background p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase"> </div>
<Input
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
placeholder="제목을 입력하세요 (비워두면 기본 제목 사용)"
className="h-8 text-xs"
style={{ fontSize: "12px" }}
/>
<p className="mt-1 text-[10px] text-muted-foreground"> 제목: 야드 3D</p>
</div>
{/* 헤더 표시 */}
<div className="rounded-lg bg-background p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase"> </div>
<RadioGroup
value={showHeader ? "show" : "hide"}
onValueChange={(value) => setShowHeader(value === "show")}
className="flex items-center gap-3"
>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="show" id="header-show" className="h-3 w-3" />
<Label htmlFor="header-show" className="cursor-pointer text-[11px] font-normal">
</Label>
</div>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="hide" id="header-hide" className="h-3 w-3" />
<Label htmlFor="header-hide" className="cursor-pointer text-[11px] font-normal">
</Label>
</div>
</RadioGroup>
</div>
</div>
</div>
{/* 푸터 */}
<div className="flex gap-2 bg-background p-2 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
<button
onClick={onClose}
className="flex-1 rounded bg-muted py-2 text-xs font-medium text-foreground transition-colors hover:bg-muted"
>
</button>
<button
onClick={handleApply}
className="bg-primary hover:bg-primary/90 flex-1 rounded py-2 text-xs font-medium text-primary-foreground transition-colors"
>
</button>
</div>
</div>
);
}

View File

@ -1,516 +0,0 @@
"use client";
import React, { useState } from "react";
import { DashboardElement, CustomMetricConfig } from "@/components/admin/dashboard/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { GripVertical, Plus, Trash2, ChevronDown, ChevronUp, X } from "lucide-react";
import { DatabaseConfig } from "../../data-sources/DatabaseConfig";
import { ChartDataSource } from "../../types";
import { ApiConfig } from "../../data-sources/ApiConfig";
import { QueryEditor } from "../../QueryEditor";
import { v4 as uuidv4 } from "uuid";
import { cn } from "@/lib/utils";
interface CustomMetricConfigSidebarProps {
element: DashboardElement;
isOpen: boolean;
onClose: () => void;
onApply: (updates: Partial<DashboardElement>) => void;
}
export default function CustomMetricConfigSidebar({
element,
isOpen,
onClose,
onApply,
}: CustomMetricConfigSidebarProps) {
const [metrics, setMetrics] = useState<CustomMetricConfig["metrics"]>(element.customMetricConfig?.metrics || []);
const [expandedMetric, setExpandedMetric] = useState<string | null>(null);
const [queryColumns, setQueryColumns] = useState<string[]>([]);
const [dataSourceType, setDataSourceType] = useState<"database" | "api">(element.dataSource?.type || "database");
const [dataSource, setDataSource] = useState<ChartDataSource>(
element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const [customTitle, setCustomTitle] = useState<string>(element.customTitle || element.title || "");
const [showHeader, setShowHeader] = useState<boolean>(element.showHeader !== false);
const [groupByMode, setGroupByMode] = useState<boolean>(element.customMetricConfig?.groupByMode || false);
const [groupByDataSource, setGroupByDataSource] = useState<ChartDataSource | undefined>(
element.customMetricConfig?.groupByDataSource,
);
const [groupByQueryColumns, setGroupByQueryColumns] = useState<string[]>([]);
// 쿼리 실행 결과 처리 (일반 지표용)
const handleQueryTest = (result: any) => {
// QueryEditor에서 오는 경우: { success: true, data: { columns: [...], rows: [...] } }
if (result.success && result.data?.columns) {
setQueryColumns(result.data.columns);
}
// ApiConfig에서 오는 경우: { columns: [...], data: [...] } 또는 { success: true, columns: [...] }
else if (result.columns && Array.isArray(result.columns)) {
setQueryColumns(result.columns);
}
// 오류 처리
else {
setQueryColumns([]);
}
};
// 쿼리 실행 결과 처리 (그룹별 카드용)
const handleGroupByQueryTest = (result: any) => {
if (result.success && result.data?.columns) {
setGroupByQueryColumns(result.data.columns);
} else if (result.columns && Array.isArray(result.columns)) {
setGroupByQueryColumns(result.columns);
} else {
setGroupByQueryColumns([]);
}
};
// 메트릭 추가
const addMetric = () => {
const newMetric = {
id: uuidv4(),
field: "",
label: "새 지표",
aggregation: "count" as const,
unit: "",
color: "gray" as const,
decimals: 1,
};
setMetrics([...metrics, newMetric]);
setExpandedMetric(newMetric.id);
};
// 메트릭 삭제
const deleteMetric = (id: string) => {
setMetrics(metrics.filter((m) => m.id !== id));
if (expandedMetric === id) {
setExpandedMetric(null);
}
};
// 메트릭 업데이트
const updateMetric = (id: string, field: string, value: any) => {
setMetrics(metrics.map((m) => (m.id === id ? { ...m, [field]: value } : m)));
};
// 메트릭 순서 변경
// 드래그 앤 드롭 핸들러
const handleDragStart = (index: number) => {
setDraggedIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
setDragOverIndex(index);
};
const handleDrop = (e: React.DragEvent, dropIndex: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === dropIndex) {
setDraggedIndex(null);
setDragOverIndex(null);
return;
}
const newMetrics = [...metrics];
const [draggedItem] = newMetrics.splice(draggedIndex, 1);
newMetrics.splice(dropIndex, 0, draggedItem);
setMetrics(newMetrics);
setDraggedIndex(null);
setDragOverIndex(null);
};
const handleDragEnd = () => {
setDraggedIndex(null);
setDragOverIndex(null);
};
// 데이터 소스 업데이트
const handleDataSourceUpdate = (updates: Partial<ChartDataSource>) => {
const newDataSource = { ...dataSource, ...updates };
setDataSource(newDataSource);
onApply({ dataSource: newDataSource });
};
// 데이터 소스 타입 변경
const handleDataSourceTypeChange = (type: "database" | "api") => {
setDataSourceType(type);
const newDataSource: ChartDataSource =
type === "database"
? { type: "database", connectionType: "current", refreshInterval: 0 }
: { type: "api", method: "GET", refreshInterval: 0 };
setDataSource(newDataSource);
onApply({ dataSource: newDataSource });
setQueryColumns([]);
};
// 그룹별 데이터 소스 업데이트
const handleGroupByDataSourceUpdate = (updates: Partial<ChartDataSource>) => {
const newDataSource = { ...groupByDataSource, ...updates } as ChartDataSource;
setGroupByDataSource(newDataSource);
};
// 저장
const handleSave = () => {
onApply({
customTitle: customTitle,
showHeader: showHeader,
customMetricConfig: {
groupByMode,
groupByDataSource: groupByMode ? groupByDataSource : undefined,
metrics,
},
});
};
if (!isOpen) return null;
return (
<div
className={cn(
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-muted transition-transform duration-300 ease-in-out",
isOpen ? "translate-x-0" : "translate-x-[-100%]",
)}
>
{/* 헤더 */}
<div className="flex items-center justify-between bg-background 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-foreground"> </span>
</div>
<button
onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-muted"
>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</div>
{/* 본문: 스크롤 가능 영역 */}
<div className="flex-1 overflow-y-auto p-3">
<div className="space-y-3">
{/* 헤더 설정 */}
<div className="rounded-lg bg-background p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase"> </div>
<div className="space-y-2">
{/* 제목 입력 */}
<div>
<label className="mb-0.5 block text-[9px] font-medium text-muted-foreground"></label>
<Input
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
placeholder="위젯 제목을 입력하세요"
className="h-8 text-xs"
style={{ fontSize: "12px" }}
/>
</div>
{/* 헤더 표시 여부 */}
<div className="flex items-center justify-between">
<label className="text-[9px] font-medium text-muted-foreground"> </label>
<button
onClick={() => setShowHeader(!showHeader)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
showHeader ? "bg-primary" : "bg-muted"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
showHeader ? "translate-x-5" : "translate-x-0.5"
}`}
/>
</button>
</div>
</div>
</div>
{/* 데이터 소스 타입 선택 */}
<div className="rounded-lg bg-background p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase"> </div>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => handleDataSourceTypeChange("database")}
className={`flex h-16 items-center justify-center rounded border transition-all ${
dataSourceType === "database"
? "border-primary bg-primary/5 text-primary"
: "border-border bg-muted text-foreground hover:border-border"
}`}
>
<span className="text-sm font-medium"></span>
</button>
<button
onClick={() => handleDataSourceTypeChange("api")}
className={`flex h-16 items-center justify-center rounded border transition-all ${
dataSourceType === "api"
? "border-primary bg-primary/5 text-primary"
: "border-border bg-muted text-foreground hover:border-border"
}`}
>
<span className="text-sm font-medium">REST API</span>
</button>
</div>
</div>
{/* 데이터 소스 설정 */}
{dataSourceType === "database" ? (
<>
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
<QueryEditor
dataSource={dataSource}
onDataSourceChange={handleDataSourceUpdate}
onQueryTest={handleQueryTest}
/>
</>
) : (
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
)}
{/* 일반 지표 설정 (항상 표시) */}
<div className="rounded-lg bg-background p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<div className="text-[10px] font-semibold tracking-wide text-muted-foreground uppercase"> </div>
{queryColumns.length > 0 && (
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={addMetric}>
<Plus className="h-3 w-3" />
</Button>
)}
</div>
{queryColumns.length === 0 ? (
<p className="text-xs text-muted-foreground"> </p>
) : (
<div className="space-y-2">
{metrics.length === 0 ? (
<p className="text-xs text-muted-foreground"> </p>
) : (
metrics.map((metric, index) => (
<div
key={metric.id}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={(e) => handleDrop(e, index)}
className={cn(
"rounded-md border bg-background p-2 transition-all",
draggedIndex === index && "opacity-50",
dragOverIndex === index && draggedIndex !== index && "border-primary border-2",
)}
>
{/* 헤더 */}
<div className="flex w-full items-center gap-2">
<div
draggable
onDragStart={() => handleDragStart(index)}
onDragEnd={handleDragEnd}
className="cursor-grab active:cursor-grabbing"
>
<GripVertical className="h-4 w-4 shrink-0 text-muted-foreground" />
</div>
<div className="grid min-w-0 flex-1 grid-cols-[1fr,auto,auto] items-center gap-2">
<span className="truncate text-xs font-medium text-foreground">
{metric.label || "새 지표"}
</span>
<span className="text-[10px] text-muted-foreground">{metric.aggregation.toUpperCase()}</span>
<button
onClick={() => setExpandedMetric(expandedMetric === metric.id ? null : metric.id)}
className="flex items-center justify-center rounded p-0.5 hover:bg-muted"
>
{expandedMetric === metric.id ? (
<ChevronUp className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
)}
</button>
</div>
</div>
{/* 설정 영역 */}
{expandedMetric === metric.id && (
<div className="mt-2 space-y-1.5 border-t border-border pt-2">
{/* 2열 그리드 레이아웃 */}
<div className="grid grid-cols-2 gap-1.5">
{/* 컬럼 */}
<div>
<label className="mb-0.5 block text-[9px] font-medium text-muted-foreground"></label>
<Select
value={metric.field}
onValueChange={(value) => updateMetric(metric.id, "field", value)}
>
<SelectTrigger className="h-6 w-full text-[10px]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{queryColumns.map((col) => (
<SelectItem key={col} value={col}>
{col}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 집계 함수 */}
<div>
<label className="mb-0.5 block text-[9px] font-medium text-muted-foreground"></label>
<Select
value={metric.aggregation}
onValueChange={(value: any) => updateMetric(metric.id, "aggregation", value)}
>
<SelectTrigger className="h-6 w-full text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="count">COUNT</SelectItem>
<SelectItem value="sum">SUM</SelectItem>
<SelectItem value="avg">AVG</SelectItem>
<SelectItem value="min">MIN</SelectItem>
<SelectItem value="max">MAX</SelectItem>
</SelectContent>
</Select>
</div>
{/* 단위 */}
<div>
<label className="mb-0.5 block text-[9px] font-medium text-muted-foreground"></label>
<Input
value={metric.unit}
onChange={(e) => updateMetric(metric.id, "unit", e.target.value)}
className="h-6 w-full text-[10px]"
placeholder="건, %, km"
/>
</div>
{/* 소수점 */}
<div>
<label className="mb-0.5 block text-[9px] font-medium text-muted-foreground"></label>
<Select
value={String(metric.decimals)}
onValueChange={(value) => updateMetric(metric.id, "decimals", parseInt(value))}
>
<SelectTrigger className="h-6 w-full text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[0, 1, 2].map((num) => (
<SelectItem key={num} value={String(num)}>
{num}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 표시 이름 (전체 너비) */}
<div>
<label className="mb-0.5 block text-[9px] font-medium text-muted-foreground"> </label>
<Input
value={metric.label}
onChange={(e) => updateMetric(metric.id, "label", e.target.value)}
className="h-6 w-full text-[10px]"
placeholder="라벨"
/>
</div>
{/* 삭제 버튼 */}
<div className="border-t border-border pt-1.5">
<Button
size="sm"
variant="ghost"
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-6 w-full gap-1 text-[10px]"
onClick={() => deleteMetric(metric.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
)}
</div>
))
)}
</div>
)}
</div>
{/* 그룹별 카드 생성 모드 (항상 표시) */}
<div className="rounded-lg bg-background p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase"> </div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<label className="text-xs font-medium text-foreground"> </label>
<p className="mt-0.5 text-[9px] text-muted-foreground">
</p>
</div>
<button
onClick={() => {
setGroupByMode(!groupByMode);
if (!groupByMode && !groupByDataSource) {
// 그룹별 모드 활성화 시 기본 데이터 소스 초기화
setGroupByDataSource({ type: "database", connectionType: "current", refreshInterval: 0 });
}
}}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
groupByMode ? "bg-primary" : "bg-muted"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
groupByMode ? "translate-x-5" : "translate-x-0.5"
}`}
/>
</button>
</div>
{groupByMode && (
<div className="rounded-md bg-primary/10 p-2 text-[9px] text-primary">
<p className="font-medium">💡 </p>
<ul className="mt-1 space-y-0.5 pl-3 text-[8px]">
<li> 컬럼: 카드 </li>
<li> 컬럼: 카드 </li>
<li> : SELECT status, COUNT(*) FROM drivers GROUP BY status</li>
<li> <strong> </strong> ( )</li>
</ul>
</div>
)}
</div>
</div>
{/* 그룹별 카드 전용 쿼리 (활성화 시에만 표시) */}
{groupByMode && groupByDataSource && (
<div className="rounded-lg bg-background p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">
</div>
<DatabaseConfig dataSource={groupByDataSource} onChange={handleGroupByDataSourceUpdate} />
<QueryEditor
dataSource={groupByDataSource}
onDataSourceChange={handleGroupByDataSourceUpdate}
onQueryTest={handleGroupByQueryTest}
/>
</div>
)}
</div>
</div>
{/* 푸터 */}
<div className="flex gap-2 border-t bg-background p-3 shadow-sm">
<Button variant="outline" className="h-8 flex-1 text-xs" onClick={onClose}>
</Button>
<Button className="focus:ring-primary/20 h-8 flex-1 text-xs" onClick={handleSave}>
</Button>
</div>
</div>
);
}