위젯 사이드바 통일
This commit is contained in:
parent
cff8f39bc3
commit
e086719235
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue