"use client"; import React, { useState, useCallback, useRef, useEffect } from "react"; import dynamic from "next/dynamic"; import { DashboardElement, QueryResult } from "./types"; import { ChartRenderer } from "./charts/ChartRenderer"; import { snapToGrid, snapSizeToGrid, GRID_CONFIG } from "./gridUtils"; // 위젯 동적 임포트 const WeatherWidget = dynamic(() => import("@/components/dashboard/widgets/WeatherWidget"), { ssr: false, loading: () =>
로딩 중...
, }); const ExchangeWidget = dynamic(() => import("@/components/dashboard/widgets/ExchangeWidget"), { ssr: false, loading: () =>
로딩 중...
, }); const CalculatorWidget = dynamic(() => import("@/components/dashboard/widgets/CalculatorWidget"), { ssr: false, loading: () =>
로딩 중...
, }); const VehicleMapWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleMapWidget"), { ssr: false, loading: () =>
로딩 중...
, }); const DeliveryStatusWidget = dynamic(() => import("@/components/dashboard/widgets/DeliveryStatusWidget"), { ssr: false, loading: () =>
로딩 중...
, }); const RiskAlertWidget = dynamic(() => import("@/components/dashboard/widgets/RiskAlertWidget"), { ssr: false, loading: () =>
로딩 중...
, }); // 시계 위젯 임포트 import { ClockWidget } from "./widgets/ClockWidget"; // 달력 위젯 임포트 import { CalendarWidget } from "./widgets/CalendarWidget"; // 기사 관리 위젯 임포트 import { DriverManagementWidget } from "./widgets/DriverManagementWidget"; interface CanvasElementProps { element: DashboardElement; isSelected: boolean; cellSize: number; onUpdate: (id: string, updates: Partial) => void; onRemove: (id: string) => void; onSelect: (id: string | null) => void; onConfigure?: (element: DashboardElement) => void; } /** * 캔버스에 배치된 개별 요소 컴포넌트 * - 드래그로 이동 가능 (그리드 스냅) * - 크기 조절 핸들 (그리드 스냅) * - 삭제 버튼 */ export function CanvasElement({ element, isSelected, cellSize, onUpdate, onRemove, onSelect, onConfigure, }: CanvasElementProps) { const [isDragging, setIsDragging] = useState(false); const [isResizing, setIsResizing] = useState(false); const [dragStart, setDragStart] = useState({ x: 0, y: 0, elementX: 0, elementY: 0 }); const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, width: 0, height: 0, elementX: 0, elementY: 0, handle: "", }); const [chartData, setChartData] = useState(null); const [isLoadingData, setIsLoadingData] = useState(false); const elementRef = useRef(null); // 드래그/리사이즈 중 임시 위치/크기 (스냅 전) const [tempPosition, setTempPosition] = useState<{ x: number; y: number } | null>(null); const [tempSize, setTempSize] = useState<{ width: number; height: number } | null>(null); // 요소 선택 처리 const handleMouseDown = useCallback( (e: React.MouseEvent) => { // 닫기 버튼이나 리사이즈 핸들 클릭 시 무시 if ((e.target as HTMLElement).closest(".element-close, .resize-handle")) { return; } // 위젯 내부 (헤더 제외) 클릭 시 드래그 무시 - 인터랙티브 사용 가능 if ((e.target as HTMLElement).closest(".widget-interactive-area")) { return; } onSelect(element.id); setIsDragging(true); setDragStart({ x: e.clientX, y: e.clientY, elementX: element.position.x, elementY: element.position.y, }); e.preventDefault(); }, [element.id, element.position.x, element.position.y, onSelect], ); // 리사이즈 핸들 마우스다운 const handleResizeMouseDown = useCallback( (e: React.MouseEvent, handle: string) => { e.stopPropagation(); setIsResizing(true); setResizeStart({ x: e.clientX, y: e.clientY, width: element.size.width, height: element.size.height, elementX: element.position.x, elementY: element.position.y, handle, }); }, [element.size.width, element.size.height, element.position.x, element.position.y], ); // 마우스 이동 처리 (그리드 스냅 적용) const handleMouseMove = useCallback( (e: MouseEvent) => { if (isDragging) { const deltaX = e.clientX - dragStart.x; const deltaY = e.clientY - dragStart.y; // 임시 위치 계산 (스냅 안 됨) let rawX = Math.max(0, dragStart.elementX + deltaX); const rawY = Math.max(0, dragStart.elementY + deltaY); // X 좌표가 캔버스 너비를 벗어나지 않도록 제한 const maxX = GRID_CONFIG.CANVAS_WIDTH - element.size.width; rawX = Math.min(rawX, maxX); setTempPosition({ x: rawX, y: rawY }); } else if (isResizing) { const deltaX = e.clientX - resizeStart.x; const deltaY = e.clientY - resizeStart.y; let newWidth = resizeStart.width; let newHeight = resizeStart.height; let newX = resizeStart.elementX; let newY = resizeStart.elementY; // 최소 크기 설정: 달력은 2x3, 나머지는 2x2 const minWidthCells = 2; const minHeightCells = element.type === "widget" && element.subtype === "calendar" ? 3 : 2; const minWidth = GRID_CONFIG.CELL_SIZE * minWidthCells; const minHeight = GRID_CONFIG.CELL_SIZE * minHeightCells; switch (resizeStart.handle) { case "se": // 오른쪽 아래 newWidth = Math.max(minWidth, resizeStart.width + deltaX); newHeight = Math.max(minHeight, resizeStart.height + deltaY); break; case "sw": // 왼쪽 아래 newWidth = Math.max(minWidth, resizeStart.width - deltaX); newHeight = Math.max(minHeight, resizeStart.height + deltaY); newX = resizeStart.elementX + deltaX; break; case "ne": // 오른쪽 위 newWidth = Math.max(minWidth, resizeStart.width + deltaX); newHeight = Math.max(minHeight, resizeStart.height - deltaY); newY = resizeStart.elementY + deltaY; break; case "nw": // 왼쪽 위 newWidth = Math.max(minWidth, resizeStart.width - deltaX); newHeight = Math.max(minHeight, resizeStart.height - deltaY); newX = resizeStart.elementX + deltaX; newY = resizeStart.elementY + deltaY; break; } // 가로 너비가 캔버스를 벗어나지 않도록 제한 const maxWidth = GRID_CONFIG.CANVAS_WIDTH - newX; newWidth = Math.min(newWidth, maxWidth); // 임시 크기/위치 저장 (스냅 안 됨) setTempPosition({ x: Math.max(0, newX), y: Math.max(0, newY) }); setTempSize({ width: newWidth, height: newHeight }); } }, [isDragging, isResizing, dragStart, resizeStart, element.size.width, element.type, element.subtype], ); // 마우스 업 처리 (그리드 스냅 적용) const handleMouseUp = useCallback(() => { if (isDragging && tempPosition) { // 드래그 종료 시 그리드에 스냅 (동적 셀 크기 사용) let snappedX = snapToGrid(tempPosition.x, cellSize); const snappedY = snapToGrid(tempPosition.y, cellSize); // X 좌표가 캔버스 너비를 벗어나지 않도록 최종 제한 const maxX = GRID_CONFIG.CANVAS_WIDTH - element.size.width; snappedX = Math.min(snappedX, maxX); onUpdate(element.id, { position: { x: snappedX, y: snappedY }, }); setTempPosition(null); } if (isResizing && tempPosition && tempSize) { // 리사이즈 종료 시 그리드에 스냅 (동적 셀 크기 사용) const snappedX = snapToGrid(tempPosition.x, cellSize); const snappedY = snapToGrid(tempPosition.y, cellSize); let snappedWidth = snapSizeToGrid(tempSize.width, 2, cellSize); const snappedHeight = snapSizeToGrid(tempSize.height, 2, cellSize); // 가로 너비가 캔버스를 벗어나지 않도록 최종 제한 const maxWidth = GRID_CONFIG.CANVAS_WIDTH - snappedX; snappedWidth = Math.min(snappedWidth, maxWidth); onUpdate(element.id, { position: { x: snappedX, y: snappedY }, size: { width: snappedWidth, height: snappedHeight }, }); setTempPosition(null); setTempSize(null); } setIsDragging(false); setIsResizing(false); }, [isDragging, isResizing, tempPosition, tempSize, element.id, element.size.width, onUpdate, cellSize]); // 전역 마우스 이벤트 등록 React.useEffect(() => { if (isDragging || isResizing) { document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); return () => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; } }, [isDragging, isResizing, handleMouseMove, handleMouseUp]); // 데이터 로딩 const loadChartData = useCallback(async () => { if (!element.dataSource?.query || element.type !== "chart") { return; } setIsLoadingData(true); try { let result; // 외부 DB vs 현재 DB 분기 if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) { // 외부 DB const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection"); const externalResult = await ExternalDbConnectionAPI.executeQuery( parseInt(element.dataSource.externalConnectionId), element.dataSource.query, ); if (!externalResult.success) { throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패"); } setChartData({ columns: externalResult.data?.[0] ? Object.keys(externalResult.data[0]) : [], rows: externalResult.data || [], totalRows: externalResult.data?.length || 0, executionTime: 0, }); } else { // 현재 DB const { dashboardApi } = await import("@/lib/api/dashboard"); result = await dashboardApi.executeQuery(element.dataSource.query); setChartData({ columns: result.columns || [], rows: result.rows || [], totalRows: result.rowCount || 0, executionTime: 0, }); } } catch (error) { console.error("Chart data loading error:", error); setChartData(null); } finally { setIsLoadingData(false); } }, [ element.dataSource?.query, element.dataSource?.connectionType, element.dataSource?.externalConnectionId, element.type, ]); // 컴포넌트 마운트 시 및 쿼리 변경 시 데이터 로딩 useEffect(() => { loadChartData(); }, [loadChartData]); // 자동 새로고침 설정 useEffect(() => { if (!element.dataSource?.refreshInterval || element.dataSource.refreshInterval === 0) { return; } const interval = setInterval(loadChartData, element.dataSource.refreshInterval); return () => clearInterval(interval); }, [element.dataSource?.refreshInterval, loadChartData]); // 요소 삭제 const handleRemove = useCallback(() => { onRemove(element.id); }, [element.id, onRemove]); // 스타일 클래스 생성 const getContentClass = () => { if (element.type === "chart") { switch (element.subtype) { case "bar": return "bg-gradient-to-br from-indigo-400 to-purple-600"; case "pie": return "bg-gradient-to-br from-pink-400 to-red-500"; case "line": return "bg-gradient-to-br from-blue-400 to-cyan-400"; default: return "bg-gray-200"; } } else if (element.type === "widget") { switch (element.subtype) { case "exchange": return "bg-gradient-to-br from-pink-400 to-yellow-400"; case "weather": return "bg-gradient-to-br from-cyan-400 to-indigo-800"; case "clock": return "bg-gradient-to-br from-teal-400 to-cyan-600"; case "calendar": return "bg-gradient-to-br from-indigo-400 to-purple-600"; case "driver-management": return "bg-gradient-to-br from-blue-400 to-indigo-600"; default: return "bg-gray-200"; } } return "bg-gray-200"; }; // 드래그/리사이즈 중일 때는 임시 위치/크기 사용, 아니면 실제 값 사용 const displayPosition = tempPosition || element.position; const displaySize = tempSize || element.size; return (
{/* 헤더 */}
{element.title}
{/* 설정 버튼 (시계, 달력, 기사관리 위젯은 자체 설정 UI 사용) */} {onConfigure && !( element.type === "widget" && (element.subtype === "clock" || element.subtype === "calendar" || element.subtype === "driver-management") ) && ( )} {/* 삭제 버튼 */}
{/* 내용 */}
{element.type === "chart" ? ( // 차트 렌더링
{isLoadingData ? (
데이터 로딩 중...
) : ( )}
) : element.type === "widget" && element.subtype === "weather" ? ( // 날씨 위젯 렌더링
) : element.type === "widget" && element.subtype === "exchange" ? ( // 환율 위젯 렌더링
) : element.type === "widget" && element.subtype === "clock" ? ( // 시계 위젯 렌더링
{ onUpdate(element.id, { clockConfig: newConfig }); }} />
) : element.type === "widget" && element.subtype === "calculator" ? ( // 계산기 위젯 렌더링
) : element.type === "widget" && element.subtype === "vehicle-map" ? ( // 차량 위치 지도 위젯 렌더링
) : element.type === "widget" && element.subtype === "delivery-status" ? ( // 배송/화물 현황 위젯 렌더링
) : element.type === "widget" && element.subtype === "risk-alert" ? ( // 리스크/알림 위젯 렌더링
) : element.type === "widget" && element.subtype === "calendar" ? ( // 달력 위젯 렌더링
{ onUpdate(element.id, { calendarConfig: newConfig }); }} />
) : element.type === "widget" && element.subtype === "driver-management" ? ( // 기사 관리 위젯 렌더링
{ onUpdate(element.id, { driverManagementConfig: newConfig }); }} />
) : ( // 기타 위젯 렌더링
🔧
{element.content}
)}
{/* 리사이즈 핸들 (선택된 요소에만 표시) */} {isSelected && ( <> )}
); } interface ResizeHandleProps { position: "nw" | "ne" | "sw" | "se"; onMouseDown: (e: React.MouseEvent, handle: string) => void; } /** * 크기 조절 핸들 컴포넌트 */ function ResizeHandle({ position, onMouseDown }: ResizeHandleProps) { const getPositionClass = () => { switch (position) { case "nw": return "top-[-5px] left-[-5px] cursor-nw-resize"; case "ne": return "top-[-5px] right-[-5px] cursor-ne-resize"; case "sw": return "bottom-[-5px] left-[-5px] cursor-sw-resize"; case "se": return "bottom-[-5px] right-[-5px] cursor-se-resize"; } }; return (
onMouseDown(e, position)} /> ); }