2025-10-22 10:45:10 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import React, { useState, useCallback, useEffect } from "react";
|
|
|
|
|
import { DashboardElement, ChartDataSource, ChartConfig, QueryResult } from "./types";
|
|
|
|
|
import { QueryEditor } from "./QueryEditor";
|
|
|
|
|
import { ChartConfigPanel } from "./ChartConfigPanel";
|
|
|
|
|
import { VehicleMapConfigPanel } from "./VehicleMapConfigPanel";
|
|
|
|
|
import { DatabaseConfig } from "./data-sources/DatabaseConfig";
|
|
|
|
|
import { ApiConfig } from "./data-sources/ApiConfig";
|
2025-10-22 13:40:15 +09:00
|
|
|
import { ListWidgetConfigSidebar } from "./widgets/ListWidgetConfigSidebar";
|
2025-10-22 15:29:57 +09:00
|
|
|
import { YardWidgetConfigSidebar } from "./widgets/YardWidgetConfigSidebar";
|
2025-10-22 10:58:21 +09:00
|
|
|
import { X } from "lucide-react";
|
2025-10-22 10:45:10 +09:00
|
|
|
import { cn } from "@/lib/utils";
|
2025-10-22 10:58:21 +09:00
|
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
2025-10-22 10:45:10 +09:00
|
|
|
|
|
|
|
|
interface ElementConfigSidebarProps {
|
|
|
|
|
element: DashboardElement | null;
|
|
|
|
|
isOpen: boolean;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
onApply: (element: DashboardElement) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 요소 설정 사이드바 컴포넌트
|
|
|
|
|
* - 왼쪽에서 슬라이드 인/아웃
|
|
|
|
|
* - 캔버스 위에 오버레이
|
|
|
|
|
* - "적용" 버튼으로 명시적 저장
|
|
|
|
|
*/
|
|
|
|
|
export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: ElementConfigSidebarProps) {
|
|
|
|
|
const [dataSource, setDataSource] = useState<ChartDataSource>({
|
|
|
|
|
type: "database",
|
|
|
|
|
connectionType: "current",
|
|
|
|
|
refreshInterval: 0,
|
|
|
|
|
});
|
|
|
|
|
const [chartConfig, setChartConfig] = useState<ChartConfig>({});
|
|
|
|
|
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
|
|
|
|
const [customTitle, setCustomTitle] = useState<string>("");
|
|
|
|
|
const [showHeader, setShowHeader] = useState<boolean>(true);
|
|
|
|
|
|
|
|
|
|
// 사이드바가 열릴 때 초기화
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (isOpen && element) {
|
|
|
|
|
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 });
|
|
|
|
|
setChartConfig(element.chartConfig || {});
|
|
|
|
|
setQueryResult(null);
|
|
|
|
|
setCustomTitle(element.customTitle || "");
|
|
|
|
|
setShowHeader(element.showHeader !== false);
|
|
|
|
|
}
|
|
|
|
|
}, [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);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 쿼리 테스트 결과 처리
|
|
|
|
|
const handleQueryTest = useCallback((result: QueryResult) => {
|
|
|
|
|
setQueryResult(result);
|
|
|
|
|
setChartConfig({});
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 적용 처리
|
|
|
|
|
const handleApply = useCallback(() => {
|
|
|
|
|
if (!element) return;
|
|
|
|
|
|
|
|
|
|
const updatedElement: DashboardElement = {
|
|
|
|
|
...element,
|
|
|
|
|
dataSource,
|
|
|
|
|
chartConfig,
|
|
|
|
|
customTitle: customTitle.trim() || undefined,
|
|
|
|
|
showHeader,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
onApply(updatedElement);
|
|
|
|
|
// 사이드바는 열린 채로 유지 (연속 수정 가능)
|
|
|
|
|
}, [element, dataSource, chartConfig, customTitle, showHeader, onApply]);
|
|
|
|
|
|
|
|
|
|
// 요소가 없으면 렌더링하지 않음
|
|
|
|
|
if (!element) return null;
|
|
|
|
|
|
2025-10-22 13:40:15 +09:00
|
|
|
// 리스트 위젯은 별도 사이드바로 처리
|
|
|
|
|
if (element.subtype === "list") {
|
|
|
|
|
return (
|
|
|
|
|
<ListWidgetConfigSidebar
|
|
|
|
|
element={element}
|
|
|
|
|
isOpen={isOpen}
|
|
|
|
|
onClose={onClose}
|
|
|
|
|
onApply={(updatedElement) => {
|
|
|
|
|
onApply(updatedElement);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-22 15:29:57 +09:00
|
|
|
// 야드 위젯은 사이드바로 처리
|
2025-10-22 13:40:15 +09:00
|
|
|
if (element.subtype === "yard-management-3d") {
|
|
|
|
|
return (
|
2025-10-22 15:29:57 +09:00
|
|
|
<YardWidgetConfigSidebar
|
2025-10-22 13:40:15 +09:00
|
|
|
element={element}
|
|
|
|
|
isOpen={isOpen}
|
2025-10-22 15:29:57 +09:00
|
|
|
onApply={(updates) => {
|
|
|
|
|
onApply({ ...element, ...updates });
|
2025-10-22 13:40:15 +09:00
|
|
|
}}
|
2025-10-22 15:29:57 +09:00
|
|
|
onClose={onClose}
|
2025-10-22 13:40:15 +09:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-22 10:45:10 +09:00
|
|
|
// 차트 설정이 필요 없는 위젯 (쿼리/API만 필요)
|
|
|
|
|
const isSimpleWidget =
|
|
|
|
|
element.subtype === "todo" ||
|
|
|
|
|
element.subtype === "booking-alert" ||
|
|
|
|
|
element.subtype === "maintenance" ||
|
|
|
|
|
element.subtype === "document" ||
|
|
|
|
|
element.subtype === "risk-alert" ||
|
|
|
|
|
element.subtype === "vehicle-status" ||
|
|
|
|
|
element.subtype === "vehicle-list" ||
|
|
|
|
|
element.subtype === "status-summary" ||
|
|
|
|
|
element.subtype === "delivery-status" ||
|
|
|
|
|
element.subtype === "delivery-status-summary" ||
|
|
|
|
|
element.subtype === "delivery-today-stats" ||
|
|
|
|
|
element.subtype === "cargo-list" ||
|
|
|
|
|
element.subtype === "customer-issues" ||
|
|
|
|
|
element.subtype === "driver-management" ||
|
|
|
|
|
element.subtype === "work-history" ||
|
|
|
|
|
element.subtype === "transport-stats";
|
|
|
|
|
|
|
|
|
|
// 자체 기능 위젯 (DB 연결 불필요, 헤더 설정만 가능)
|
|
|
|
|
const isSelfContainedWidget =
|
|
|
|
|
element.subtype === "weather" || element.subtype === "exchange" || element.subtype === "calculator";
|
|
|
|
|
|
|
|
|
|
// 지도 위젯 (위도/경도 매핑 필요)
|
|
|
|
|
const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary";
|
|
|
|
|
|
|
|
|
|
// 헤더 전용 위젯
|
|
|
|
|
const isHeaderOnlyWidget =
|
|
|
|
|
element.type === "widget" &&
|
|
|
|
|
(element.subtype === "clock" || element.subtype === "calendar" || isSelfContainedWidget);
|
|
|
|
|
|
|
|
|
|
// 저장 가능 여부 확인
|
|
|
|
|
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 ||
|
|
|
|
|
(isSimpleWidget
|
2025-10-22 10:58:21 +09:00
|
|
|
? queryResult && queryResult.rows.length > 0
|
2025-10-22 10:45:10 +09:00
|
|
|
: isMapWidget
|
2025-10-22 10:58:21 +09:00
|
|
|
? queryResult && queryResult.rows.length > 0 && chartConfig.latitudeColumn && chartConfig.longitudeColumn
|
|
|
|
|
: queryResult &&
|
2025-10-22 10:45:10 +09:00
|
|
|
queryResult.rows.length > 0 &&
|
|
|
|
|
chartConfig.xAxis &&
|
|
|
|
|
(isPieChart || isApiSource ? (chartConfig.aggregation === "count" ? true : hasYAxis) : hasYAxis));
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
2025-10-22 12:48:17 +09:00
|
|
|
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-gray-50 transition-transform duration-300 ease-in-out",
|
2025-10-22 10:45:10 +09:00
|
|
|
isOpen ? "translate-x-0" : "translate-x-[-100%]",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{/* 헤더 */}
|
2025-10-22 12:48:17 +09:00
|
|
|
<div className="flex items-center justify-between bg-white px-3 py-2 shadow-sm">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<div className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded">
|
|
|
|
|
<span className="text-primary text-xs font-bold">⚙</span>
|
|
|
|
|
</div>
|
|
|
|
|
<span className="text-xs font-semibold text-gray-900">{element.title}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-gray-100"
|
|
|
|
|
>
|
|
|
|
|
<X className="h-3.5 w-3.5 text-gray-500" />
|
|
|
|
|
</button>
|
2025-10-22 10:45:10 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 본문: 스크롤 가능 영역 */}
|
2025-10-22 12:48:17 +09:00
|
|
|
<div className="flex-1 overflow-y-auto p-3">
|
|
|
|
|
{/* 기본 설정 카드 */}
|
|
|
|
|
<div className="mb-3 rounded-lg bg-white p-3 shadow-sm">
|
|
|
|
|
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">기본 설정</div>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{/* 커스텀 제목 입력 */}
|
|
|
|
|
<div>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={customTitle}
|
|
|
|
|
onChange={(e) => setCustomTitle(e.target.value)}
|
|
|
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
|
|
|
placeholder="위젯 제목"
|
|
|
|
|
className="focus:border-primary focus:ring-primary/20 h-8 w-full rounded border border-gray-200 bg-gray-50 px-2 text-xs placeholder:text-gray-400 focus:bg-white focus:ring-1 focus:outline-none"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 헤더 표시 옵션 */}
|
|
|
|
|
<label className="flex cursor-pointer items-center gap-2 rounded border border-gray-200 bg-gray-50 px-2 py-1.5 transition-colors hover:border-gray-300">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
id="showHeader"
|
|
|
|
|
checked={showHeader}
|
|
|
|
|
onChange={(e) => setShowHeader(e.target.checked)}
|
|
|
|
|
className="text-primary focus:ring-primary h-3 w-3 rounded border-gray-300"
|
|
|
|
|
/>
|
|
|
|
|
<span className="text-xs text-gray-700">헤더 표시</span>
|
2025-10-22 10:58:21 +09:00
|
|
|
</label>
|
2025-10-22 10:45:10 +09:00
|
|
|
</div>
|
2025-10-22 10:58:21 +09:00
|
|
|
</div>
|
2025-10-22 10:45:10 +09:00
|
|
|
|
2025-10-22 12:48:17 +09:00
|
|
|
{/* 헤더 전용 위젯이 아닐 때만 데이터 소스 표시 */}
|
2025-10-22 10:45:10 +09:00
|
|
|
{!isHeaderOnlyWidget && (
|
2025-10-22 12:48:17 +09:00
|
|
|
<div className="rounded-lg bg-white p-3 shadow-sm">
|
|
|
|
|
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">데이터 소스</div>
|
|
|
|
|
|
|
|
|
|
<Tabs
|
|
|
|
|
defaultValue={dataSource.type}
|
|
|
|
|
onValueChange={(value) => handleDataSourceTypeChange(value as "database" | "api")}
|
|
|
|
|
className="w-full"
|
|
|
|
|
>
|
|
|
|
|
<TabsList className="grid h-7 w-full grid-cols-2 bg-gray-100 p-0.5">
|
|
|
|
|
<TabsTrigger
|
|
|
|
|
value="database"
|
|
|
|
|
className="h-6 rounded text-[11px] data-[state=active]:bg-white data-[state=active]:shadow-sm"
|
|
|
|
|
>
|
|
|
|
|
데이터베이스
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
<TabsTrigger
|
|
|
|
|
value="api"
|
|
|
|
|
className="h-6 rounded text-[11px] data-[state=active]:bg-white data-[state=active]:shadow-sm"
|
|
|
|
|
>
|
|
|
|
|
REST API
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
</TabsList>
|
|
|
|
|
|
|
|
|
|
<TabsContent value="database" className="mt-2 space-y-2">
|
|
|
|
|
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
|
|
|
|
|
<QueryEditor
|
|
|
|
|
dataSource={dataSource}
|
|
|
|
|
onDataSourceChange={handleDataSourceUpdate}
|
|
|
|
|
onQueryTest={handleQueryTest}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* 차트/지도 설정 */}
|
|
|
|
|
{!isSimpleWidget && queryResult && queryResult.rows.length > 0 && (
|
|
|
|
|
<div className="mt-2">
|
|
|
|
|
{isMapWidget ? (
|
|
|
|
|
<VehicleMapConfigPanel
|
|
|
|
|
config={chartConfig}
|
|
|
|
|
queryResult={queryResult}
|
|
|
|
|
onConfigChange={handleChartConfigChange}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<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 && queryResult && queryResult.rows.length > 0 && (
|
|
|
|
|
<div className="mt-2">
|
|
|
|
|
{isMapWidget ? (
|
|
|
|
|
<VehicleMapConfigPanel
|
|
|
|
|
config={chartConfig}
|
|
|
|
|
queryResult={queryResult}
|
|
|
|
|
onConfigChange={handleChartConfigChange}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<ChartConfigPanel
|
|
|
|
|
config={chartConfig}
|
|
|
|
|
queryResult={queryResult}
|
|
|
|
|
onConfigChange={handleChartConfigChange}
|
|
|
|
|
chartType={element.subtype}
|
|
|
|
|
dataSourceType={dataSource.type}
|
|
|
|
|
query={dataSource.query}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</TabsContent>
|
|
|
|
|
</Tabs>
|
|
|
|
|
|
|
|
|
|
{/* 데이터 로드 상태 */}
|
|
|
|
|
{queryResult && (
|
|
|
|
|
<div className="mt-2 flex items-center gap-1.5 rounded bg-green-50 px-2 py-1">
|
|
|
|
|
<div className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
|
|
|
|
<span className="text-[10px] font-medium text-green-700">
|
|
|
|
|
{queryResult.rows.length}개 데이터 로드됨
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-10-22 10:45:10 +09:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-10-22 10:58:21 +09:00
|
|
|
{/* 푸터: 적용 버튼 */}
|
2025-10-22 12:48:17 +09:00
|
|
|
<div className="flex gap-2 bg-white p-2 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
|
|
|
|
|
<button
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
className="flex-1 rounded bg-gray-100 py-2 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-200"
|
|
|
|
|
>
|
2025-10-22 10:58:21 +09:00
|
|
|
취소
|
2025-10-22 12:48:17 +09:00
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleApply}
|
|
|
|
|
disabled={isHeaderOnlyWidget ? false : !canApply}
|
|
|
|
|
className="bg-primary hover:bg-primary/90 flex-1 rounded py-2 text-xs font-medium text-white transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
|
|
|
|
>
|
2025-10-22 10:58:21 +09:00
|
|
|
적용
|
2025-10-22 12:48:17 +09:00
|
|
|
</button>
|
2025-10-22 10:45:10 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|