ERP-node/frontend/components/admin/dashboard/CanvasElement.tsx

501 lines
18 KiB
TypeScript
Raw Normal View History

"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: () => <div className="flex h-full items-center justify-center text-sm text-gray-500"> ...</div>,
});
const ExchangeWidget = dynamic(() => import("@/components/dashboard/widgets/ExchangeWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500"> ...</div>,
});
const CalculatorWidget = dynamic(() => import("@/components/dashboard/widgets/CalculatorWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500"> ...</div>,
});
const VehicleMapWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleMapWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500"> ...</div>,
});
// 시계 위젯 임포트
import { ClockWidget } from "./widgets/ClockWidget";
2025-10-14 10:48:17 +09:00
// 달력 위젯 임포트
import { CalendarWidget } from "./widgets/CalendarWidget";
2025-10-14 11:26:53 +09:00
// 기사 관리 위젯 임포트
import { DriverManagementWidget } from "./widgets/DriverManagementWidget";
interface CanvasElementProps {
element: DashboardElement;
isSelected: boolean;
cellSize: number;
onUpdate: (id: string, updates: Partial<DashboardElement>) => 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<QueryResult | null>(null);
const [isLoadingData, setIsLoadingData] = useState(false);
const elementRef = useRef<HTMLDivElement>(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;
}
2025-10-14 10:05:40 +09:00
// 위젯 내부 (헤더 제외) 클릭 시 드래그 무시 - 인터랙티브 사용 가능
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;
2025-10-14 10:48:17 +09:00
// 최소 크기 설정: 달력은 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": // 오른쪽 아래
2025-10-14 10:48:17 +09:00
newWidth = Math.max(minWidth, resizeStart.width + deltaX);
newHeight = Math.max(minHeight, resizeStart.height + deltaY);
break;
case "sw": // 왼쪽 아래
2025-10-14 10:48:17 +09:00
newWidth = Math.max(minWidth, resizeStart.width - deltaX);
newHeight = Math.max(minHeight, resizeStart.height + deltaY);
newX = resizeStart.elementX + deltaX;
break;
case "ne": // 오른쪽 위
2025-10-14 10:48:17 +09:00
newWidth = Math.max(minWidth, resizeStart.width + deltaX);
newHeight = Math.max(minHeight, resizeStart.height - deltaY);
newY = resizeStart.elementY + deltaY;
break;
case "nw": // 왼쪽 위
2025-10-14 10:48:17 +09:00
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 {
// console.log('🔄 쿼리 실행 시작:', element.dataSource.query);
// 실제 API 호출
const { dashboardApi } = await import("@/lib/api/dashboard");
const result = await dashboardApi.executeQuery(element.dataSource.query);
// console.log('✅ 쿼리 실행 결과:', result);
setChartData({
columns: result.columns || [],
rows: result.rows || [],
totalRows: result.rowCount || 0,
executionTime: 0,
});
} catch (error) {
setChartData(null);
} finally {
setIsLoadingData(false);
}
}, [element.dataSource?.query, 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";
2025-10-14 10:48:17 +09:00
case "calendar":
return "bg-gradient-to-br from-indigo-400 to-purple-600";
2025-10-14 11:26:53 +09:00
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 (
<div
ref={elementRef}
className={`absolute min-h-[120px] min-w-[120px] cursor-move rounded-lg border-2 bg-white shadow-lg ${isSelected ? "border-blue-500 shadow-blue-200" : "border-gray-400"} ${isDragging || isResizing ? "transition-none" : "transition-all duration-150"} `}
style={{
left: displayPosition.x,
top: displayPosition.y,
width: displaySize.width,
height: displaySize.height,
2025-10-13 17:16:44 +09:00
padding: `${GRID_CONFIG.ELEMENT_PADDING}px`,
boxSizing: "border-box",
}}
onMouseDown={handleMouseDown}
>
{/* 헤더 */}
<div className="flex cursor-move items-center justify-between border-b border-gray-200 bg-gray-50 p-3">
<span className="text-sm font-bold text-gray-800">{element.title}</span>
<div className="flex gap-1">
2025-10-14 11:26:53 +09:00
{/* 설정 버튼 (시계, 달력, 기사관리 위젯은 자체 설정 UI 사용) */}
2025-10-14 10:48:17 +09:00
{onConfigure &&
2025-10-14 11:26:53 +09:00
!(
element.type === "widget" &&
(element.subtype === "clock" || element.subtype === "calendar" || element.subtype === "driver-management")
) && (
2025-10-14 10:48:17 +09:00
<button
className="hover:bg-accent0 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors duration-200 hover:text-white"
onClick={() => onConfigure(element)}
title="설정"
>
</button>
)}
{/* 삭제 버튼 */}
<button
className="element-close hover:bg-destructive/100 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors duration-200 hover:text-white"
onClick={handleRemove}
title="삭제"
>
×
</button>
</div>
</div>
{/* 내용 */}
<div className="relative h-[calc(100%-45px)]">
{element.type === "chart" ? (
// 차트 렌더링
<div className="h-full w-full bg-white">
{isLoadingData ? (
<div className="flex h-full w-full items-center justify-center text-gray-500">
<div className="text-center">
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
<div className="text-sm"> ...</div>
</div>
</div>
) : (
<ChartRenderer
element={element}
data={chartData || undefined}
width={element.size.width}
height={element.size.height - 45}
/>
)}
</div>
) : element.type === "widget" && element.subtype === "weather" ? (
// 날씨 위젯 렌더링
<div className="widget-interactive-area h-full w-full">
<WeatherWidget city="서울" refreshInterval={600000} />
</div>
) : element.type === "widget" && element.subtype === "exchange" ? (
// 환율 위젯 렌더링
<div className="widget-interactive-area h-full w-full">
<ExchangeWidget baseCurrency="KRW" targetCurrency="USD" refreshInterval={600000} />
</div>
) : element.type === "widget" && element.subtype === "clock" ? (
// 시계 위젯 렌더링
<div className="h-full w-full">
<ClockWidget
element={element}
onConfigUpdate={(newConfig) => {
onUpdate(element.id, { clockConfig: newConfig });
}}
/>
</div>
) : element.type === "widget" && element.subtype === "calculator" ? (
// 계산기 위젯 렌더링
<div className="widget-interactive-area h-full w-full">
<CalculatorWidget />
</div>
) : element.type === "widget" && element.subtype === "vehicle-map" ? (
// 차량 위치 지도 위젯 렌더링
<div className="widget-interactive-area h-full w-full">
<VehicleMapWidget />
</div>
2025-10-14 10:48:17 +09:00
) : element.type === "widget" && element.subtype === "calendar" ? (
// 달력 위젯 렌더링
<div className="h-full w-full">
<CalendarWidget
element={element}
onConfigUpdate={(newConfig) => {
onUpdate(element.id, { calendarConfig: newConfig });
}}
/>
</div>
2025-10-14 11:26:53 +09:00
) : element.type === "widget" && element.subtype === "driver-management" ? (
// 기사 관리 위젯 렌더링
<div className="h-full w-full">
<DriverManagementWidget
element={element}
onConfigUpdate={(newConfig) => {
onUpdate(element.id, { driverManagementConfig: newConfig });
}}
/>
</div>
) : (
// 기타 위젯 렌더링
<div
className={`flex h-full w-full items-center justify-center p-5 text-center text-sm font-medium text-white ${getContentClass()} `}
>
<div>
<div className="mb-2 text-4xl">🔧</div>
<div className="whitespace-pre-line">{element.content}</div>
</div>
</div>
)}
</div>
{/* 리사이즈 핸들 (선택된 요소에만 표시) */}
{isSelected && (
<>
<ResizeHandle position="nw" onMouseDown={handleResizeMouseDown} />
<ResizeHandle position="ne" onMouseDown={handleResizeMouseDown} />
<ResizeHandle position="sw" onMouseDown={handleResizeMouseDown} />
<ResizeHandle position="se" onMouseDown={handleResizeMouseDown} />
</>
)}
</div>
);
}
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 (
<div
className={`resize-handle absolute h-3 w-3 border border-white bg-green-500 ${getPositionClass()} `}
onMouseDown={(e) => onMouseDown(e, position)}
/>
);
}