"use client"; import React, { useState, useRef, useCallback } from "react"; import { useRouter } from "next/navigation"; import { DashboardCanvas } from "./DashboardCanvas"; import { DashboardTopMenu } from "./DashboardTopMenu"; import { WidgetConfigSidebar } from "./WidgetConfigSidebar"; import { DashboardSaveModal } from "./DashboardSaveModal"; import { DashboardElement, ElementType, ElementSubtype } from "./types"; import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateBoxSize } from "./gridUtils"; import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector"; import { DashboardProvider } from "@/contexts/DashboardContext"; import { useMenu } from "@/contexts/MenuContext"; import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"; import { ResizableDialog, ResizableDialogContent, ResizableDialogDescription, ResizableDialogHeader, ResizableDialogTitle, } from "@/components/ui/resizable-dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { CheckCircle2 } from "lucide-react"; interface DashboardDesignerProps { dashboardId?: string; } /** * 대시보드 설계 도구 메인 컴포넌트 * - 드래그 앤 드롭으로 차트/위젯 배치 * - 그리드 기반 레이아웃 (12 컬럼) * - 요소 이동, 크기 조절, 삭제 기능 * - 레이아웃 저장/불러오기 기능 */ export default function DashboardDesigner({ dashboardId: initialDashboardId }: DashboardDesignerProps = {}) { const router = useRouter(); const { refreshMenus } = useMenu(); const [elements, setElements] = useState([]); const [selectedElement, setSelectedElement] = useState(null); const [selectedElements, setSelectedElements] = useState([]); // 다중 선택 const [elementCounter, setElementCounter] = useState(0); const [dashboardId, setDashboardId] = useState(initialDashboardId || null); const [dashboardTitle, setDashboardTitle] = useState(""); const [isLoading, setIsLoading] = useState(false); const [canvasBackgroundColor, setCanvasBackgroundColor] = useState("transparent"); const canvasRef = useRef(null); // 저장 모달 상태 const [saveModalOpen, setSaveModalOpen] = useState(false); const [dashboardDescription, setDashboardDescription] = useState(""); const [successModalOpen, setSuccessModalOpen] = useState(false); const [clearConfirmOpen, setClearConfirmOpen] = useState(false); // 사이드바 상태 const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarElement, setSidebarElement] = useState(null); // 클립보드 (복사/붙여넣기용) const [clipboard, setClipboard] = useState(null); // 화면 해상도 자동 감지 const [screenResolution] = useState(() => detectScreenResolution()); const [resolution, setResolution] = useState(() => { // 새 대시보드인 경우 (dashboardId 없음) 화면 해상도 감지값 사용 // 기존 대시보드 편집인 경우 FHD로 시작 (로드 시 덮어씀) return initialDashboardId ? "fhd" : detectScreenResolution(); }); // resolution 변경 감지 및 요소 자동 조정 const handleResolutionChange = useCallback( (newResolution: Resolution) => { setResolution((prev) => { // 이전 해상도와 새 해상도의 캔버스 너비 비율 계산 const oldConfig = RESOLUTIONS[prev]; const newConfig = RESOLUTIONS[newResolution]; const widthRatio = newConfig.width / oldConfig.width; // 요소들의 위치와 크기를 비율에 맞춰 조정 if (widthRatio !== 1 && elements.length > 0) { // 새 해상도의 셀 크기 계산 const newCellSize = calculateCellSize(newConfig.width); const adjustedElements = elements.map((el) => { // 비율에 맞춰 조정 (X와 너비만) const scaledX = el.position.x * widthRatio; const scaledWidth = el.size.width * widthRatio; // 그리드에 스냅 (X, Y, 너비, 높이 모두) const snappedX = snapToGrid(scaledX, newCellSize); const snappedY = snapToGrid(el.position.y, newCellSize); const snappedWidth = snapSizeToGrid(scaledWidth, newConfig.width); const snappedHeight = snapSizeToGrid(el.size.height, newConfig.width); return { ...el, position: { x: snappedX, y: snappedY, }, size: { width: snappedWidth, height: snappedHeight, }, }; }); setElements(adjustedElements); } return newResolution; }); }, [elements], ); // 현재 해상도 설정 (안전하게 기본값 제공) const canvasConfig = RESOLUTIONS[resolution] || RESOLUTIONS.fhd; // 캔버스 높이 동적 계산 (요소들의 최하단 위치 기준) const calculateCanvasHeight = useCallback(() => { if (elements.length === 0) { return canvasConfig.height; // 기본 높이 } // 모든 요소의 최하단 y 좌표 계산 const maxBottomY = Math.max(...elements.map((el) => el.position.y + el.size.height)); // 최소 높이는 기본 높이, 요소가 아래로 내려가면 자동으로 늘어남 // 패딩 추가 (100px 여유) return Math.max(canvasConfig.height, maxBottomY + 100); }, [elements, canvasConfig.height]); const dynamicCanvasHeight = calculateCanvasHeight(); // 대시보드 ID가 props로 전달되면 로드 React.useEffect(() => { if (initialDashboardId) { loadDashboard(initialDashboardId); } }, [initialDashboardId]); // 대시보드 데이터 로드 const loadDashboard = async (id: string) => { setIsLoading(true); try { const { dashboardApi } = await import("@/lib/api/dashboard"); const dashboard = await dashboardApi.getDashboard(id); console.log("🔍 [loadDashboard] 대시보드 응답:", { id: dashboard.id, title: dashboard.title, settingsType: typeof (dashboard as any).settings, settingsValue: (dashboard as any).settings, }); // 대시보드 정보 설정 setDashboardId(dashboard.id); setDashboardTitle(dashboard.title); // 저장된 설정 복원 const settings = (dashboard as { settings?: { resolution?: Resolution; backgroundColor?: string } }).settings; console.log("🔍 [loadDashboard] 파싱된 settings:", { settings, resolution: settings?.resolution, backgroundColor: settings?.backgroundColor, }); // 배경색 설정 if (settings?.backgroundColor) { setCanvasBackgroundColor(settings.backgroundColor); } // 해상도와 요소를 함께 설정 (해상도가 먼저 반영되어야 함) const loadedResolution = settings?.resolution || "fhd"; console.log("🔍 [loadDashboard] 로드할 resolution:", loadedResolution); setResolution(loadedResolution); // 요소들 설정 if (dashboard.elements && dashboard.elements.length > 0) { // chartConfig.dataSources를 element.dataSources로 복사 (프론트엔드 호환성) const elementsWithDataSources = dashboard.elements.map((el) => ({ ...el, dataSources: el.chartConfig?.dataSources || el.dataSources, })); setElements(elementsWithDataSources); // elementCounter를 가장 큰 ID 번호로 설정 const maxId = dashboard.elements.reduce((max, el) => { const match = el.id.match(/element-(\d+)/); if (match) { const num = parseInt(match[1]); return num > max ? num : max; } return max; }, 0); setElementCounter(maxId); } } catch (error) { alert( "대시보드를 불러오는 중 오류가 발생했습니다.\n\n" + (error instanceof Error ? error.message : "알 수 없는 오류"), ); } finally { setIsLoading(false); } }; // 새로운 요소 생성 (동적 그리드 기반 기본 크기) const createElement = useCallback( (type: ElementType, subtype: ElementSubtype, x: number, y: number) => { // 좌표 유효성 검사 if (isNaN(x) || isNaN(y)) { return; } // 기본 크기 설정 (그리드 박스 단위) const boxSize = calculateBoxSize(canvasConfig.width); // 그리드 박스 단위 기본 크기 let boxesWidth = 3; // 기본 위젯: 박스 3개 let boxesHeight = 3; // 기본 위젯: 박스 3개 if (type === "chart") { boxesWidth = 4; // 차트: 박스 4개 boxesHeight = 3; // 차트: 박스 3개 } else if (type === "widget" && subtype === "calendar") { boxesWidth = 3; // 달력: 박스 3개 boxesHeight = 4; // 달력: 박스 4개 } // 박스 개수를 픽셀로 변환 (마지막 간격 제거) const defaultWidth = boxesWidth * boxSize + (boxesWidth - 1) * GRID_CONFIG.GRID_BOX_GAP; const defaultHeight = boxesHeight * boxSize + (boxesHeight - 1) * GRID_CONFIG.GRID_BOX_GAP; // 크기 유효성 검사 if (isNaN(defaultWidth) || isNaN(defaultHeight) || defaultWidth <= 0 || defaultHeight <= 0) { return; } const newElement: DashboardElement = { id: `element-${elementCounter + 1}`, type, subtype, position: { x, y }, size: { width: defaultWidth, height: defaultHeight }, title: getElementTitle(type, subtype), content: getElementContent(type, subtype), }; setElements((prev) => [...prev, newElement]); setElementCounter((prev) => prev + 1); setSelectedElement(newElement.id); // 새 요소 생성 시 자동으로 설정 사이드바 열기 setSidebarElement(newElement); setSidebarOpen(true); }, [elementCounter, canvasConfig], ); // 메뉴에서 요소 추가 시 (캔버스 중앙에 배치) const addElementFromMenu = useCallback( (type: ElementType, subtype: ElementSubtype) => { // 캔버스 중앙 좌표 계산 const centerX = Math.floor(canvasConfig.width / 2); const centerY = Math.floor(canvasConfig.height / 2); // 좌표 유효성 확인 if (isNaN(centerX) || isNaN(centerY)) { return; } createElement(type, subtype, centerX, centerY); }, [canvasConfig, createElement], ); // 요소 업데이트 const updateElement = useCallback((id: string, updates: Partial) => { setElements((prev) => prev.map((el) => { if (el.id === id) { return { ...el, ...updates }; } return el; }), ); }, []); // 요소 삭제 const removeElement = useCallback( (id: string) => { setElements((prev) => prev.filter((el) => el.id !== id)); if (selectedElement === id) { setSelectedElement(null); } // 삭제된 요소의 사이드바가 열려있으면 닫기 if (sidebarElement?.id === id) { setSidebarOpen(false); setSidebarElement(null); } }, [selectedElement, sidebarElement], ); // 키보드 단축키 핸들러들 const handleCopyElement = useCallback(() => { if (!selectedElement) return; const element = elements.find((el) => el.id === selectedElement); if (element) { setClipboard(element); } }, [selectedElement, elements]); const handlePasteElement = useCallback(() => { if (!clipboard) return; // 새 ID 생성 const newId = `element-${elementCounter + 1}`; setElementCounter((prev) => prev + 1); // 위치를 약간 오프셋 (오른쪽 아래로 20px씩) const newElement: DashboardElement = { ...clipboard, id: newId, position: { x: clipboard.position.x + 20, y: clipboard.position.y + 20, }, }; setElements((prev) => [...prev, newElement]); setSelectedElement(newId); }, [clipboard, elementCounter]); const handleDeleteSelected = useCallback(() => { if (selectedElement) { removeElement(selectedElement); } }, [selectedElement, removeElement]); // 키보드 단축키 활성화 useKeyboardShortcuts({ selectedElementId: selectedElement, onDelete: handleDeleteSelected, onCopy: handleCopyElement, onPaste: handlePasteElement, enabled: !saveModalOpen && !successModalOpen && !clearConfirmOpen && !sidebarOpen, }); // 전체 삭제 확인 모달 열기 const clearCanvas = useCallback(() => { setClearConfirmOpen(true); }, []); // 실제 초기화 실행 const handleClearConfirm = useCallback(() => { setElements([]); setSelectedElement(null); setElementCounter(0); setClearConfirmOpen(false); }, []); // 사이드바 닫기 const handleCloseSidebar = useCallback(() => { setSidebarOpen(false); setSidebarElement(null); setSelectedElement(null); }, []); // 사이드바 적용 const handleApplySidebar = useCallback( (updatedElement: DashboardElement) => { // 현재 요소의 최신 상태를 가져와서 position과 size는 유지 const currentElement = elements.find((el) => el.id === updatedElement.id); if (currentElement) { // id, position, size 제거 후 나머지만 업데이트 // eslint-disable-next-line @typescript-eslint/no-unused-vars const { id, position, size, ...updates } = updatedElement; const finalElement = { ...currentElement, ...updates, }; updateElement(id, updates); // 사이드바도 최신 상태로 업데이트 setSidebarElement(finalElement); } }, [elements, updateElement], ); // 레이아웃 저장 const saveLayout = useCallback(() => { if (elements.length === 0) { alert("저장할 요소가 없습니다. 차트나 위젯을 추가해주세요."); return; } // 저장 모달 열기 setSaveModalOpen(true); }, [elements]); // 저장 처리 const handleSave = useCallback( async (data: { title: string; description: string; assignToMenu: boolean; menuType?: "admin" | "user"; menuId?: string; }) => { try { const { dashboardApi } = await import("@/lib/api/dashboard"); const elementsData = elements.map((el) => { return { id: el.id, type: el.type, subtype: el.subtype, // 위치와 크기는 정수로 반올림 (DB integer 타입) position: { x: Math.round(el.position.x), y: Math.round(el.position.y), }, size: { width: Math.round(el.size.width), height: Math.round(el.size.height), }, title: el.title, customTitle: el.customTitle, showHeader: el.showHeader, content: el.content, dataSource: el.dataSource, // dataSources는 chartConfig에 포함시켜서 저장 (백엔드 스키마 수정 불필요) chartConfig: el.dataSources && el.dataSources.length > 0 ? { ...el.chartConfig, dataSources: el.dataSources } : el.chartConfig, listConfig: el.listConfig, yardConfig: el.yardConfig, customMetricConfig: el.customMetricConfig, }; }); let savedDashboard; if (dashboardId) { // 기존 대시보드 업데이트 const updateData = { title: data.title, description: data.description || undefined, elements: elementsData, settings: { resolution, backgroundColor: canvasBackgroundColor, }, }; console.log("🔍 [handleSave] 업데이트 데이터:", { dashboardId, settings: updateData.settings, }); savedDashboard = await dashboardApi.updateDashboard(dashboardId, updateData); } else { // 새 대시보드 생성 const dashboardData = { title: data.title, description: data.description || undefined, isPublic: false, elements: elementsData, settings: { resolution, backgroundColor: canvasBackgroundColor, }, }; console.log("🔍 [handleSave] 생성 데이터:", { settings: dashboardData.settings, }); savedDashboard = await dashboardApi.createDashboard(dashboardData); setDashboardId(savedDashboard.id); } setDashboardTitle(savedDashboard.title); setDashboardDescription(data.description); // 메뉴 할당 처리 if (data.assignToMenu && data.menuId) { const { menuApi } = await import("@/lib/api/menu"); // 대시보드 URL 생성 (관리자 메뉴면 mode=admin 추가) let dashboardUrl = `/dashboard/${savedDashboard.id}`; if (data.menuType === "admin") { dashboardUrl += "?mode=admin"; } // 메뉴 정보 가져오기 const menuResponse = await menuApi.getMenuInfo(data.menuId); if (menuResponse.success && menuResponse.data) { const menu = menuResponse.data; const updateData = { menuUrl: dashboardUrl, parentObjId: menu.parent_obj_id || menu.PARENT_OBJ_ID || "0", menuNameKor: menu.menu_name_kor || menu.MENU_NAME_KOR || "", menuDesc: menu.menu_desc || menu.MENU_DESC || "", seq: menu.seq || menu.SEQ || 1, menuType: menu.menu_type || menu.MENU_TYPE || "1", status: menu.status || menu.STATUS || "active", companyCode: menu.company_code || menu.COMPANY_CODE || "", langKey: menu.lang_key || menu.LANG_KEY || "", }; // 메뉴 URL 업데이트 await menuApi.updateMenu(data.menuId, updateData); // 메뉴 목록 새로고침 await refreshMenus(); } } // 성공 모달 표시 setSuccessModalOpen(true); } catch (error) { const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류"; alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}`); throw error; } }, [elements, dashboardId, resolution, canvasBackgroundColor, refreshMenus], ); // 로딩 중이면 로딩 화면 표시 if (isLoading) { return (
대시보드 로딩 중...
잠시만 기다려주세요
); } return (
{/* 상단 메뉴바 */} {/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */} {/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */}
{ setSelectedElement(id); setSelectedElements([]); // 선택된 요소 찾아서 사이드바 열기 const element = elements.find((el) => el.id === id); if (element) { setSidebarElement(element); setSidebarOpen(true); } }} onSelectMultiple={(ids) => { setSelectedElements(ids); setSelectedElement(null); setSidebarOpen(false); setSidebarElement(null); }} onConfigureElement={() => {}} backgroundColor={canvasBackgroundColor} canvasWidth={canvasConfig.width} canvasHeight={dynamicCanvasHeight} />
{/* 요소 설정 사이드바 (통합) */} {/* 저장 모달 */} setSaveModalOpen(false)} onSave={handleSave} initialTitle={dashboardTitle} initialDescription={dashboardDescription} isEditing={!!dashboardId} /> {/* 저장 성공 모달 */} { setSuccessModalOpen(false); router.push("/admin/dashboard"); }} >
저장 완료 대시보드가 성공적으로 저장되었습니다.
{/* 초기화 확인 모달 */} 캔버스 초기화 모든 요소가 삭제됩니다. 이 작업은 되돌릴 수 없습니다.
계속하시겠습니까?
취소 초기화
); } // 요소 제목 생성 헬퍼 함수 function getElementTitle(type: ElementType, subtype: ElementSubtype): string { if (type === "chart") { switch (subtype) { case "bar": return "바 차트"; case "horizontal-bar": return "수평 바 차트"; case "stacked-bar": return "누적 바 차트"; case "pie": return "원형 차트"; case "donut": return "도넛 차트"; case "line": return "꺾은선 차트"; case "area": return "영역 차트"; case "combo": return "콤보 차트"; default: return "차트"; } } else if (type === "widget") { switch (subtype) { case "exchange": return "환율 위젯"; case "weather": return "날씨 위젯"; case "clock": return "시계 위젯"; case "calculator": return "계산기 위젯"; case "vehicle-map": return "차량 위치 지도"; case "calendar": return "달력 위젯"; case "driver-management": return "기사 관리 위젯"; case "list-v2": return "리스트 위젯"; case "map-summary-v2": return "커스텀 지도 카드"; case "status-summary": return "커스텀 상태 카드"; case "risk-alert-v2": return "리스크 알림 위젯"; case "todo": return "할 일 위젯"; case "booking-alert": return "예약 알림 위젯"; case "maintenance": return "정비 일정 위젯"; case "document": return "문서 위젯"; case "yard-management-3d": return "야드 관리 3D"; case "work-history": return "작업 이력"; case "transport-stats": return "커스텀 통계 카드"; default: return "위젯"; } } return "요소"; } // 요소 내용 생성 헬퍼 함수 function getElementContent(type: ElementType, subtype: ElementSubtype): string { if (type === "chart") { switch (subtype) { case "bar": return "바 차트가 여기에 표시됩니다"; case "horizontal-bar": return "수평 바 차트가 여기에 표시됩니다"; case "pie": return "원형 차트가 여기에 표시됩니다"; case "line": return "꺾은선 차트가 여기에 표시됩니다"; default: return "차트가 여기에 표시됩니다"; } } else if (type === "widget") { switch (subtype) { case "exchange": return "USD: ₩1,320\nJPY: ₩900\nEUR: ₩1,450"; case "weather": return "서울\n23°C\n구름 많음"; case "clock": return "clock"; case "calculator": return "calculator"; case "vehicle-map": return "vehicle-map"; case "calendar": return "calendar"; case "driver-management": return "driver-management"; case "list-v2": return "list-widget"; case "yard-management-3d": return "yard-3d"; case "work-history": return "work-history"; case "transport-stats": return "커스텀 통계 카드"; default: return "위젯 내용이 여기에 표시됩니다"; } } return "내용이 여기에 표시됩니다"; }