모달 -> 사이드바로 변경

This commit is contained in:
dohyeons 2025-10-22 10:45:10 +09:00
parent 1470bb2e73
commit bdf9bd0075
4 changed files with 463 additions and 96 deletions

View File

@ -2,7 +2,7 @@
import React, { useState, useCallback, useRef, useEffect } from "react";
import dynamic from "next/dynamic";
import { DashboardElement, QueryResult } from "./types";
import { DashboardElement, QueryResult, Position } from "./types";
import { ChartRenderer } from "./charts/ChartRenderer";
import { GRID_CONFIG } from "./gridUtils";
@ -105,7 +105,7 @@ import { CalendarWidget } from "./widgets/CalendarWidget";
// 기사 관리 위젯 임포트
import { DriverManagementWidget } from "./widgets/DriverManagementWidget";
import { ListWidget } from "./widgets/ListWidget";
import { MoreHorizontal, X } from "lucide-react";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
// 야드 관리 3D 위젯
@ -137,12 +137,11 @@ interface CanvasElementProps {
canvasWidth?: number;
onUpdate: (id: string, updates: Partial<DashboardElement>) => void;
onUpdateMultiple?: (updates: { id: string; updates: Partial<DashboardElement> }[]) => void; // 🔥 다중 업데이트
onMultiDragStart?: (draggedId: string, otherOffsets: Record<string, { x: number; y: number }>) => void; // 🔥 다중 드래그 시작
onMultiDragMove?: (draggedElement: DashboardElement, tempPosition: { x: number; y: number }) => void; // 🔥 다중 드래그 중
onMultiDragEnd?: () => void; // 🔥 다중 드래그 종료
onMultiDragStart?: (draggedId: string, otherOffsets: Record<string, { x: number; y: number }>) => void;
onMultiDragMove?: (draggedElement: DashboardElement, tempPosition: { x: number; y: number }) => void;
onMultiDragEnd?: () => void;
onRemove: (id: string) => void;
onSelect: (id: string | null) => void;
onConfigure?: (element: DashboardElement) => void;
}
/**
@ -167,7 +166,6 @@ export function CanvasElement({
onMultiDragEnd,
onRemove,
onSelect,
onConfigure,
}: CanvasElementProps) {
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
@ -219,7 +217,7 @@ export function CanvasElement({
elementX: element.position.x,
elementY: element.position.y,
});
// 🔥 다중 선택된 경우, 다른 위젯들의 오프셋 계산
if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragStart) {
const offsets: Record<string, { x: number; y: number }> = {};
@ -236,10 +234,19 @@ export function CanvasElement({
});
onMultiDragStart(element.id, offsets);
}
e.preventDefault();
},
[element.id, element.position.x, element.position.y, onSelect, isSelected, selectedElements, allElements, onMultiDragStart],
[
element.id,
element.position.x,
element.position.y,
onSelect,
isSelected,
selectedElements,
allElements,
onMultiDragStart,
],
);
// 리사이즈 핸들 마우스다운
@ -285,7 +292,7 @@ export function CanvasElement({
const snappedY = Math.round(rawY / subGridSize) * subGridSize;
setTempPosition({ x: snappedX, y: snappedY });
// 🔥 다중 드래그 중 - 다른 위젯들의 위치 업데이트
if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragMove) {
onMultiDragMove(element, { x: snappedX, y: snappedY });
@ -382,32 +389,37 @@ export function CanvasElement({
.map((id) => {
const targetElement = allElements.find((el) => el.id === id);
if (!targetElement) return null;
// 현재 요소와의 상대적 위치 유지
const relativeX = targetElement.position.x - dragStart.elementX;
const relativeY = targetElement.position.y - dragStart.elementY;
const newPosition: Position = {
x: Math.max(0, Math.min(canvasWidth - targetElement.size.width, finalX + relativeX)),
y: Math.max(0, finalY + relativeY),
};
if (targetElement.position.z !== undefined) {
newPosition.z = targetElement.position.z;
}
return {
id,
updates: {
position: {
x: Math.max(0, Math.min(canvasWidth - targetElement.size.width, finalX + relativeX)),
y: Math.max(0, finalY + relativeY),
z: targetElement.position.z,
},
position: newPosition,
},
};
})
.filter((update): update is { id: string; updates: Partial<DashboardElement> } => update !== null);
.filter((update): update is { id: string; updates: { position: Position } } => update !== null);
if (updates.length > 0) {
console.log("🔥 다중 선택 요소 함께 이동:", updates);
onUpdateMultiple(updates);
onUpdateMultiple(updates as { id: string; updates: Partial<DashboardElement> }[]);
}
}
setTempPosition(null);
// 🔥 다중 드래그 종료
if (onMultiDragEnd) {
onMultiDragEnd();
@ -583,11 +595,15 @@ export function CanvasElement({
// 드래그/리사이즈 중일 때는 임시 위치/크기 사용, 아니면 실제 값 사용
// 🔥 다중 드래그 중이면 multiDragOffset 적용 (단, 드래그 중인 위젯은 tempPosition 우선)
const displayPosition = tempPosition || (multiDragOffset && !isDragging ? {
x: element.position.x + multiDragOffset.x,
y: element.position.y + multiDragOffset.y,
z: element.position.z,
} : element.position);
const displayPosition: Position =
tempPosition ||
(multiDragOffset && !isDragging
? {
x: element.position.x + multiDragOffset.x,
y: element.position.y + multiDragOffset.y,
...(element.position.z !== undefined && { z: element.position.z }),
}
: element.position);
const displaySize = tempSize || element.size;
return (
@ -609,18 +625,6 @@ export function CanvasElement({
<div className="flex cursor-move items-center justify-between p-3">
<span className="text-sm font-bold text-gray-800">{element.customTitle || element.title}</span>
<div className="flex gap-1">
{/* 설정 버튼 (기사관리 위젯만 자체 설정 UI 사용) */}
{onConfigure && !(element.type === "widget" && element.subtype === "driver-management") && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-gray-400"
onClick={() => onConfigure(element)}
title="설정"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
)}
{/* 삭제 버튼 */}
<Button
variant="ghost"

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 { ElementConfigModal } from "./ElementConfigModal";
import { ElementConfigSidebar } from "./ElementConfigSidebar";
import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal";
import { YardWidgetConfigModal } from "./widgets/YardWidgetConfigModal";
import { DashboardSaveModal } from "./DashboardSaveModal";
@ -44,9 +44,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
const { refreshMenus } = useMenu();
const [elements, setElements] = useState<DashboardElement[]>([]);
const [selectedElement, setSelectedElement] = useState<string | null>(null);
const [selectedElements, setSelectedElements] = useState<string[]>([]); // 🔥 다중 선택
const [selectedElements, setSelectedElements] = useState<string[]>([]); // 다중 선택
const [elementCounter, setElementCounter] = useState(0);
const [configModalElement, setConfigModalElement] = useState<DashboardElement | null>(null);
const [dashboardId, setDashboardId] = useState<string | null>(initialDashboardId || null);
const [dashboardTitle, setDashboardTitle] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
@ -59,6 +58,10 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
const [successModalOpen, setSuccessModalOpen] = useState(false);
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
// 사이드바 상태
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarElement, setSidebarElement] = useState<DashboardElement | null>(null);
// 클립보드 (복사/붙여넣기용)
const [clipboard, setClipboard] = useState<DashboardElement | null>(null);
@ -336,7 +339,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
onDelete: handleDeleteSelected,
onCopy: handleCopyElement,
onPaste: handlePasteElement,
enabled: !saveModalOpen && !successModalOpen && !clearConfirmOpen,
enabled: !saveModalOpen && !successModalOpen && !clearConfirmOpen && !sidebarOpen,
});
// 전체 삭제 확인 모달 열기
@ -352,32 +355,32 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
setClearConfirmOpen(false);
}, []);
// 요소 설정 모달 열기
const openConfigModal = useCallback((element: DashboardElement) => {
setConfigModalElement(element);
}, []);
// 요소 설정 모달 닫기
const closeConfigModal = useCallback(() => {
setConfigModalElement(null);
}, []);
// 요소 설정 저장
const saveElementConfig = useCallback(
(updatedElement: DashboardElement) => {
updateElement(updatedElement.id, updatedElement);
},
[updateElement],
);
// 리스트 위젯 설정 저장 (Partial 업데이트)
const saveListWidgetConfig = useCallback(
// 리스트/야드 위젯 설정 저장 (Partial 업데이트)
const saveWidgetConfig = useCallback(
(updates: Partial<DashboardElement>) => {
if (configModalElement) {
updateElement(configModalElement.id, updates);
if (sidebarElement) {
updateElement(sidebarElement.id, updates);
}
},
[configModalElement, updateElement],
[sidebarElement, updateElement],
);
// 사이드바 닫기
const handleCloseSidebar = useCallback(() => {
setSidebarOpen(false);
setSidebarElement(null);
setSelectedElement(null);
}, []);
// 사이드바 적용
const handleApplySidebar = useCallback(
(updatedElement: DashboardElement) => {
updateElement(updatedElement.id, updatedElement);
// 사이드바는 열린 채로 유지하여 연속 수정 가능
// 단, sidebarElement도 업데이트해서 최신 상태 반영
setSidebarElement(updatedElement);
},
[updateElement],
);
// 레이아웃 저장
@ -560,14 +563,28 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
onRemoveElement={removeElement}
onSelectElement={(id) => {
setSelectedElement(id);
setSelectedElements([]); // 단일 선택 시 다중 선택 해제
setSelectedElements([]);
// 선택된 요소 찾아서 사이드바 열기
const element = elements.find((el) => el.id === id);
if (element) {
// 리스트/야드 위젯은 별도 모달 사용
if (element.subtype === "list" || element.subtype === "yard-management-3d") {
setSidebarElement(element);
setSidebarOpen(false);
} else {
setSidebarElement(element);
setSidebarOpen(true);
}
}
}}
onSelectMultiple={(ids) => {
console.log("🎯 DashboardDesigner - onSelectMultiple 호출:", ids);
setSelectedElements(ids);
setSelectedElement(null); // 다중 선택 시 단일 선택 해제
setSelectedElement(null);
setSidebarOpen(false);
setSidebarElement(null);
}}
onConfigureElement={openConfigModal}
onConfigureElement={() => {}}
backgroundColor={canvasBackgroundColor}
canvasWidth={canvasConfig.width}
canvasHeight={dynamicCanvasHeight}
@ -575,32 +592,38 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
</div>
</div>
{/* 요소 설정 모달 */}
{configModalElement && (
<>
{configModalElement.type === "widget" && configModalElement.subtype === "list" ? (
<ListWidgetConfigModal
element={configModalElement}
isOpen={true}
onClose={closeConfigModal}
onSave={saveListWidgetConfig}
/>
) : configModalElement.type === "widget" && configModalElement.subtype === "yard-management-3d" ? (
<YardWidgetConfigModal
element={configModalElement}
isOpen={true}
onClose={closeConfigModal}
onSave={saveListWidgetConfig}
/>
) : (
<ElementConfigModal
element={configModalElement}
isOpen={true}
onClose={closeConfigModal}
onSave={saveElementConfig}
/>
)}
</>
{/* 요소 설정 사이드바 */}
<ElementConfigSidebar
element={sidebarElement}
isOpen={sidebarOpen}
onClose={handleCloseSidebar}
onApply={handleApplySidebar}
/>
{/* 리스트 위젯 전용 모달 */}
{sidebarElement && sidebarElement.subtype === "list" && (
<ListWidgetConfigModal
element={sidebarElement}
isOpen={true}
onClose={() => {
setSidebarElement(null);
setSelectedElement(null);
}}
onSave={saveWidgetConfig}
/>
)}
{/* 야드 위젯 전용 모달 */}
{sidebarElement && sidebarElement.subtype === "yard-management-3d" && (
<YardWidgetConfigModal
element={sidebarElement}
isOpen={true}
onClose={() => {
setSidebarElement(null);
setSelectedElement(null);
}}
onSave={saveWidgetConfig}
/>
)}
{/* 저장 모달 */}

View File

@ -0,0 +1,339 @@
"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 { DataSourceSelector } from "./data-sources/DataSourceSelector";
import { DatabaseConfig } from "./data-sources/DatabaseConfig";
import { ApiConfig } from "./data-sources/ApiConfig";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { X, ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
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 [currentStep, setCurrentStep] = useState<1 | 2>(1);
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);
setCurrentStep(1);
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 handleNext = useCallback(() => {
if (currentStep === 1) {
setCurrentStep(2);
}
}, [currentStep]);
// 이전 단계로 이동
const handlePrev = useCallback(() => {
if (currentStep > 1) {
setCurrentStep((prev) => (prev - 1) as 1 | 2);
}
}, [currentStep]);
// 취소 처리
const handleCancel = useCallback(() => {
onClose();
}, [onClose]);
// 적용 처리
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;
// 차트 설정이 필요 없는 위젯 (쿼리/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
? currentStep === 2 && queryResult && queryResult.rows.length > 0
: isMapWidget
? currentStep === 2 &&
queryResult &&
queryResult.rows.length > 0 &&
chartConfig.latitudeColumn &&
chartConfig.longitudeColumn
: currentStep === 2 &&
queryResult &&
queryResult.rows.length > 0 &&
chartConfig.xAxis &&
(isPieChart || isApiSource ? (chartConfig.aggregation === "count" ? true : hasYAxis) : hasYAxis));
return (
<div
className={cn(
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-96 flex-col bg-white shadow-2xl transition-transform duration-300 ease-in-out",
isOpen ? "translate-x-0" : "translate-x-[-100%]",
)}
>
{/* 헤더 */}
<div className="flex items-center justify-between border-b p-4">
<h3 className="text-lg font-semibold text-gray-900">{element.title} </h3>
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8">
<X className="h-5 w-5" />
</Button>
</div>
{/* 본문: 스크롤 가능 영역 */}
<div className="flex-1 overflow-y-auto p-4">
{/* 커스텀 제목 입력 */}
<div className="mb-4">
<label className="mb-1 block text-sm font-medium text-gray-700"> </label>
<input
type="text"
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
placeholder="비워두면 자동 생성"
className="focus:border-primary focus:ring-primary w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:outline-none"
/>
</div>
{/* 헤더 표시 옵션 */}
<div className="mb-4 flex items-center gap-2">
<input
type="checkbox"
id="showHeader"
checked={showHeader}
onChange={(e) => setShowHeader(e.target.checked)}
className="text-primary focus:ring-primary h-4 w-4 rounded border-gray-300"
/>
<label htmlFor="showHeader" className="text-sm font-medium text-gray-700">
</label>
</div>
{/* 진행 상황 표시 */}
{!isSimpleWidget && !isHeaderOnlyWidget && (
<div className="mb-4 rounded-md bg-gray-50 p-3">
<div className="text-sm font-medium text-gray-700">
{currentStep} / 2: {currentStep === 1 ? "데이터 소스 선택" : "데이터 설정"}
</div>
</div>
)}
{/* 단계별 내용 */}
{!isHeaderOnlyWidget && (
<div className="space-y-4">
{currentStep === 1 && (
<DataSourceSelector dataSource={dataSource} onTypeChange={handleDataSourceTypeChange} />
)}
{currentStep === 2 && (
<div className="space-y-4">
{/* 데이터 설정 */}
{dataSource.type === "database" ? (
<>
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
<QueryEditor
dataSource={dataSource}
onDataSourceChange={handleDataSourceUpdate}
onQueryTest={handleQueryTest}
/>
</>
) : (
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
)}
{/* 차트/지도 설정 */}
{!isSimpleWidget && queryResult && queryResult.rows.length > 0 && (
<div className="mt-4">
{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>
)}
</div>
)}
</div>
)}
{/* 데이터 로드 상태 */}
{queryResult && (
<div className="mt-4">
<Badge variant="default">{queryResult.rows.length} </Badge>
</div>
)}
</div>
{/* 푸터: 단계 이동 및 적용 버튼 */}
<div className="flex items-center justify-between border-t p-4">
<div className="flex gap-2">
{!isSimpleWidget && !isHeaderOnlyWidget && currentStep > 1 && (
<Button variant="outline" size="sm" onClick={handlePrev}>
<ChevronLeft className="mr-1 h-4 w-4" />
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleCancel}>
</Button>
{isHeaderOnlyWidget || currentStep === 1 ? (
<Button size="sm" onClick={isHeaderOnlyWidget ? handleApply : handleNext}>
{isHeaderOnlyWidget ? "적용" : "다음"}
{!isHeaderOnlyWidget && <ChevronRight className="ml-1 h-4 w-4" />}
</Button>
) : (
<Button size="sm" onClick={handleApply} disabled={!canApply}>
</Button>
)}
</div>
</div>
</div>
);
}

View File

@ -43,6 +43,7 @@ export type ElementSubtype =
export interface Position {
x: number;
y: number;
z?: number;
}
export interface Size {