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

1184 lines
45 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState, useCallback, useRef, useEffect } from "react";
import dynamic from "next/dynamic";
import {
DashboardElement,
QueryResult,
Position,
ElementSubtype,
AXIS_BASED_CHARTS,
CIRCULAR_CHARTS,
getChartCategory,
} from "./types";
import { ChartRenderer } from "./charts/ChartRenderer";
import { GRID_CONFIG, magneticSnap, snapSizeToGrid } from "./gridUtils";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
// 위젯 동적 임포트
const WeatherWidget = dynamic(() => import("@/components/dashboard/widgets/WeatherWidget"), {
ssr: false,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const ExchangeWidget = dynamic(() => import("@/components/dashboard/widgets/ExchangeWidget"), {
ssr: false,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const CalculatorWidget = dynamic(() => import("@/components/dashboard/widgets/CalculatorWidget"), {
ssr: false,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const VehicleStatusWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleStatusWidget"), {
ssr: false,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const VehicleListWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleListWidget"), {
ssr: false,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const VehicleMapOnlyWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleMapOnlyWidget"), {
ssr: false,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 범용 지도 위젯 (차량, 창고, 고객 등 모든 위치 위젯 통합)
const MapSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/MapSummaryWidget"), {
ssr: false,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 🧪 테스트용 지도 위젯 (REST API 지원)
const MapTestWidget = dynamic(() => import("@/components/dashboard/widgets/MapTestWidget"), {
ssr: false,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 🧪 테스트용 지도 위젯 V2 (다중 데이터 소스)
const MapTestWidgetV2 = dynamic(() => import("@/components/dashboard/widgets/MapTestWidgetV2"), {
ssr: false,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 🧪 테스트용 차트 위젯 (다중 데이터 소스)
const ChartTestWidget = dynamic(() => import("@/components/dashboard/widgets/ChartTestWidget"), {
ssr: false,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const ListTestWidget = dynamic(
() => import("@/components/dashboard/widgets/ListTestWidget").then((mod) => ({ default: mod.ListTestWidget })),
{
ssr: false,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
},
);
const CustomMetricTestWidget = dynamic(() => import("@/components/dashboard/widgets/CustomMetricTestWidget"), {
ssr: false,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const RiskAlertTestWidget = dynamic(() => import("@/components/dashboard/widgets/RiskAlertTestWidget"), {
ssr: false,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 범용 상태 요약 위젯 (차량, 배송 등 모든 상태 위젯 통합)
const StatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/StatusSummaryWidget"), {
ssr: false,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 범용 목록 위젯 (차량, 기사, 제품 등 모든 목록 위젯 통합) - 다른 분 작업 중, 임시 주석
/* const ListSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/ListSummaryWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
}); */
// 개별 위젯들 (주석 처리 - StatusSummaryWidget으로 통합됨)
// const DeliveryStatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/DeliveryStatusSummaryWidget"), {
// ssr: false,
// loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
// });
// const DeliveryTodayStatsWidget = dynamic(() => import("@/components/dashboard/widgets/DeliveryTodayStatsWidget"), {
// ssr: false,
// loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
// });
// const CargoListWidget = dynamic(() => import("@/components/dashboard/widgets/CargoListWidget"), {
// ssr: false,
// loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
// });
// const CustomerIssuesWidget = dynamic(() => import("@/components/dashboard/widgets/CustomerIssuesWidget"), {
// ssr: false,
// loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
// });
const RiskAlertWidget = dynamic(() => import("@/components/dashboard/widgets/RiskAlertWidget"), {
ssr: false,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const TaskWidget = dynamic(() => import("@/components/dashboard/widgets/TaskWidget"), {
ssr: false,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const BookingAlertWidget = dynamic(() => import("@/components/dashboard/widgets/BookingAlertWidget"), {
ssr: false,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
const DocumentWidget = dynamic(() => import("@/components/dashboard/widgets/DocumentWidget"), {
ssr: false,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 시계 위젯 임포트
import { ClockWidget } from "./widgets/ClockWidget";
// 달력 위젯 임포트
import { CalendarWidget } from "./widgets/CalendarWidget";
// 기사 관리 위젯 임포트
import { DriverManagementWidget } from "./widgets/DriverManagementWidget";
// 리스트 위젯 임포트 (구버전)
import { ListWidget } from "./widgets/ListWidget";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
// 3D 필드 위젯
const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DWidget"), {
ssr: false,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 작업 이력 위젯
const WorkHistoryWidget = dynamic(() => import("@/components/dashboard/widgets/WorkHistoryWidget"), {
ssr: false,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 커스텀 통계 카드 위젯
const CustomStatsWidget = dynamic(() => import("@/components/dashboard/widgets/CustomStatsWidget"), {
ssr: false,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
// 사용자 커스텀 카드 위젯
const CustomMetricWidget = dynamic(() => import("@/components/dashboard/widgets/CustomMetricWidget"), {
ssr: false,
loading: () => (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> ...</div>
),
});
interface CanvasElementProps {
element: DashboardElement;
isSelected: boolean;
selectedElements?: string[]; // 🔥 다중 선택된 요소 ID 배열
allElements?: DashboardElement[]; // 🔥 모든 요소 배열
multiDragOffset?: { x: number; y: number }; // 🔥 다중 드래그 시 이 요소의 오프셋
cellSize: number;
subGridSize: number;
canvasWidth?: number;
verticalGuidelines: number[];
horizontalGuidelines: 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;
onRemove: (id: string) => void;
onSelect: (id: string | null) => void;
}
/**
* 캔버스에 배치된 개별 요소 컴포넌트
* - 드래그로 이동 가능 (그리드 스냅)
* - 크기 조절 핸들 (그리드 스냅)
* - 삭제 버튼
*/
export function CanvasElement({
element,
isSelected,
selectedElements = [],
allElements = [],
multiDragOffset,
cellSize,
subGridSize,
canvasWidth = 1560,
verticalGuidelines,
horizontalGuidelines,
onUpdate,
onUpdateMultiple,
onMultiDragStart,
onMultiDragMove,
onMultiDragEnd,
onRemove,
onSelect,
}: CanvasElementProps) {
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0, elementX: 0, elementY: 0 });
const dragStartRef = useRef({ x: 0, y: 0, elementX: 0, elementY: 0, initialScrollY: 0 }); // 🔥 스크롤 조정용 ref
const autoScrollDirectionRef = useRef<"up" | "down" | null>(null); // 🔥 자동 스크롤 방향
const autoScrollFrameRef = useRef<number | null>(null); // 🔥 requestAnimationFrame ID
const lastMouseYRef = useRef<number>(window.innerHeight / 2); // 🔥 마지막 마우스 Y 위치 (초기값: 화면 중간)
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 (document.querySelector('[role="dialog"]') || document.querySelector('[role="alertdialog"]')) {
return;
}
// 닫기 버튼이나 리사이즈 핸들 클릭 시 무시
if ((e.target as HTMLElement).closest(".element-close, .resize-handle")) {
return;
}
// 위젯 내부 (헤더 제외) 클릭 시 드래그 무시 - 인터랙티브 사용 가능
if ((e.target as HTMLElement).closest(".widget-interactive-area")) {
return;
}
// 위젯 테두리(바깥쪽 영역)를 클릭한 경우에만 선택/드래그 허용
// - 내용 영역을 클릭해도 대시보드 설정 사이드바가 튀어나오지 않도록 하기 위함
const container = elementRef.current;
if (container) {
const rect = container.getBoundingClientRect();
const BORDER_HIT_WIDTH = 8; // px, 테두리로 인식할 범위
const isOnBorder =
e.clientX <= rect.left + BORDER_HIT_WIDTH ||
e.clientX >= rect.right - BORDER_HIT_WIDTH ||
e.clientY <= rect.top + BORDER_HIT_WIDTH ||
e.clientY >= rect.bottom - BORDER_HIT_WIDTH;
if (!isOnBorder) {
// 테두리가 아닌 내부 클릭은 선택/드래그 처리하지 않음
return;
}
}
// 선택되지 않은 경우에만 선택 처리
if (!isSelected) {
onSelect(element.id);
}
setIsDragging(true);
const startPos = {
x: e.clientX,
y: e.clientY,
elementX: element.position.x,
elementY: element.position.y,
initialScrollY: window.pageYOffset, // 🔥 드래그 시작 시점의 스크롤 위치
};
setDragStart(startPos);
dragStartRef.current = startPos; // 🔥 ref에도 저장
// 🔥 드래그 시작 시 마우스 위치 초기화 (화면 중간)
lastMouseYRef.current = window.innerHeight / 2;
// 🔥 다중 선택된 경우, 다른 위젯들의 오프셋 계산
if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragStart) {
const offsets: Record<string, { x: number; y: number }> = {};
selectedElements.forEach((id) => {
if (id !== element.id) {
const targetElement = allElements.find((el) => el.id === id);
if (targetElement) {
offsets[id] = {
x: targetElement.position.x - element.position.x,
y: targetElement.position.y - element.position.y,
};
}
}
});
onMultiDragStart(element.id, offsets);
}
e.preventDefault();
},
[
element.id,
element.position.x,
element.position.y,
onSelect,
isSelected,
selectedElements,
allElements,
onMultiDragStart,
],
);
// 리사이즈 핸들 마우스다운
const handleResizeMouseDown = useCallback(
(e: React.MouseEvent, handle: string) => {
// 모달이나 다이얼로그가 열려있으면 리사이즈 무시
if (document.querySelector('[role="dialog"]') || document.querySelector('[role="alertdialog"]')) {
return;
}
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 isFirstSelectedElement =
!selectedElements || selectedElements.length === 0 || selectedElements[0] === element.id;
if (isFirstSelectedElement) {
const scrollThreshold = 100;
const viewportHeight = window.innerHeight;
const mouseY = e.clientY;
// 🔥 항상 마우스 위치 업데이트
lastMouseYRef.current = mouseY;
// console.log("🖱️ 마우스 위치 업데이트:", { mouseY, viewportHeight, top: scrollThreshold, bottom: viewportHeight - scrollThreshold });
}
// 🔥 현재 스크롤 위치를 고려한 deltaY 계산
const currentScrollY = window.pageYOffset;
const scrollDelta = currentScrollY - dragStartRef.current.initialScrollY;
const deltaX = e.clientX - dragStartRef.current.x;
const deltaY = e.clientY - dragStartRef.current.y + scrollDelta; // 🔥 스크롤 변화량 반영
// 임시 위치 계산 (드래그 중에는 부드럽게 이동)
let rawX = Math.max(0, dragStart.elementX + deltaX);
const rawY = Math.max(0, dragStart.elementY + deltaY);
// X 좌표가 캔버스 너비를 벗어나지 않도록 제한
const maxX = canvasWidth - element.size.width;
rawX = Math.min(rawX, maxX);
// 드래그 중에는 스냅 없이 부드럽게 이동
setTempPosition({ x: rawX, y: rawY });
// 🔥 다중 드래그 중 - 다른 위젯들의 위치 업데이트
if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragMove) {
onMultiDragMove(element, { 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;
// 최소 크기 설정: 모든 위젯 1x1
const minWidthCells = 1;
const minHeightCells = 1;
const minWidth = cellSize * minWidthCells;
const minHeight = cellSize * 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 = canvasWidth - newX;
newWidth = Math.min(newWidth, maxWidth);
// 리사이즈 중에는 스냅 없이 부드럽게 조절
const boundedX = Math.max(0, Math.min(newX, canvasWidth - newWidth));
const boundedY = Math.max(0, newY);
// 임시 크기/위치 저장 (부드러운 이동)
setTempPosition({ x: boundedX, y: boundedY });
setTempSize({ width: newWidth, height: newHeight });
}
},
[
isDragging,
isResizing,
dragStart,
resizeStart,
element,
canvasWidth,
cellSize,
verticalGuidelines,
horizontalGuidelines,
selectedElements,
allElements,
onUpdateMultiple,
onMultiDragMove,
// dragStartRef, autoScrollDirectionRef, autoScrollFrameRef는 ref라서 dependency 불필요
],
);
// 마우스 업 처리 (이미 스냅된 위치 사용)
const handleMouseUp = useCallback(() => {
if (isDragging && tempPosition) {
// 마우스를 놓을 때 그리드에 스냅
let finalX = magneticSnap(tempPosition.x, verticalGuidelines);
const finalY = magneticSnap(tempPosition.y, horizontalGuidelines);
// X 좌표가 캔버스 너비를 벗어나지 않도록 최종 제한
const maxX = canvasWidth - element.size.width;
finalX = Math.min(finalX, maxX);
onUpdate(element.id, {
position: { x: finalX, y: finalY },
});
// 🔥 다중 선택된 요소들도 함께 업데이트
if (selectedElements.length > 1 && selectedElements.includes(element.id) && onUpdateMultiple) {
const updates = selectedElements
.filter((id) => id !== element.id) // 현재 요소 제외
.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),
},
},
};
})
.filter((update): update is { id: string; updates: { position: Position } } => update !== null);
if (updates.length > 0) {
// console.log("🔥 다중 선택 요소 함께 이동:", updates);
onUpdateMultiple(updates);
}
}
setTempPosition(null);
// 🔥 다중 드래그 종료
if (onMultiDragEnd) {
onMultiDragEnd();
}
}
if (isResizing && tempPosition && tempSize) {
// 마우스를 놓을 때 그리드에 스냅
const finalX = magneticSnap(tempPosition.x, verticalGuidelines);
const finalY = magneticSnap(tempPosition.y, horizontalGuidelines);
const finalWidth = snapSizeToGrid(tempSize.width, canvasWidth || 1560);
const finalHeight = snapSizeToGrid(tempSize.height, canvasWidth || 1560);
// 가로 너비가 캔버스를 벗어나지 않도록 최종 제한
const maxWidth = canvasWidth - finalX;
const boundedWidth = Math.min(finalWidth, maxWidth);
onUpdate(element.id, {
position: { x: finalX, y: finalY },
size: { width: boundedWidth, height: finalHeight },
});
setTempPosition(null);
setTempSize(null);
}
setIsDragging(false);
setIsResizing(false);
// 🔥 자동 스크롤 정리
autoScrollDirectionRef.current = null;
if (autoScrollFrameRef.current) {
cancelAnimationFrame(autoScrollFrameRef.current);
autoScrollFrameRef.current = null;
}
}, [
isDragging,
isResizing,
tempPosition,
tempSize,
element.id,
element.size.width,
onUpdate,
onUpdateMultiple,
onMultiDragEnd,
cellSize,
canvasWidth,
selectedElements,
allElements,
dragStart.elementX,
dragStart.elementY,
verticalGuidelines,
horizontalGuidelines,
]);
// 🔥 자동 스크롤 루프 (requestAnimationFrame 사용)
useEffect(() => {
if (!isDragging) return;
const scrollSpeed = 3; // 🔥 속도를 좀 더 부드럽게 (5 → 3)
const scrollThreshold = 100;
let animationFrameId: number;
let lastTime = performance.now();
const autoScrollLoop = (currentTime: number) => {
const viewportHeight = window.innerHeight;
const lastMouseY = lastMouseYRef.current;
// 🔥 스크롤 방향 결정
let shouldScroll = false;
let scrollDirection = 0;
if (lastMouseY < scrollThreshold) {
// 위쪽 영역
shouldScroll = true;
scrollDirection = -scrollSpeed;
// console.log("⬆️ 위로 스크롤 조건 만족:", { lastMouseY, scrollThreshold });
} else if (lastMouseY > viewportHeight - scrollThreshold) {
// 아래쪽 영역
shouldScroll = true;
scrollDirection = scrollSpeed;
// console.log("⬇️ 아래로 스크롤 조건 만족:", { lastMouseY, boundary: viewportHeight - scrollThreshold });
}
// 🔥 프레임 간격 계산
const deltaTime = currentTime - lastTime;
// 🔥 10ms 간격으로 스크롤
if (shouldScroll && deltaTime >= 10) {
window.scrollBy(0, scrollDirection);
// console.log("✅ 스크롤 실행:", { scrollDirection, deltaTime });
lastTime = currentTime;
}
// 계속 반복
animationFrameId = requestAnimationFrame(autoScrollLoop);
};
// 루프 시작
animationFrameId = requestAnimationFrame(autoScrollLoop);
autoScrollFrameRef.current = animationFrameId;
return () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
};
}, [isDragging]);
// 전역 마우스 이벤트 등록
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;
// 필터 적용 (날짜 필터 등)
const { applyQueryFilters } = await import("./utils/queryHelpers");
const filteredQuery = applyQueryFilters(element.dataSource.query, element.chartConfig);
// 외부 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),
filteredQuery,
);
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(filteredQuery);
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.chartConfig,
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-primary to-purple-500";
case "pie":
return "bg-gradient-to-br from-destructive to-destructive/80";
case "line":
return "bg-gradient-to-br from-primary to-primary/80";
default:
return "bg-muted";
}
} else if (element.type === "widget") {
switch (element.subtype) {
case "exchange":
return "bg-gradient-to-br from-warning to-warning/80";
case "weather":
return "bg-gradient-to-br from-primary to-primary/80";
case "clock":
return "bg-gradient-to-br from-primary to-primary/80";
case "calendar":
return "bg-gradient-to-br from-primary to-purple-500";
case "driver-management":
return "bg-gradient-to-br from-primary to-primary";
case "list":
return "bg-gradient-to-br from-primary to-primary/80";
default:
return "bg-muted";
}
}
return "bg-muted";
};
// 드래그/리사이즈 중일 때는 임시 위치/크기 사용, 아니면 실제 값 사용
// 🔥 다중 드래그 중이면 multiDragOffset 적용 (단, 드래그 중인 위젯은 tempPosition 우선)
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 (
<div
ref={elementRef}
data-element-id={element.id}
className={`bg-background absolute min-h-[120px] min-w-[120px] cursor-move overflow-hidden rounded-lg border-2 shadow-lg ${isSelected ? "border-primary ring-primary/20 ring-2" : "border-border"} ${isDragging || isResizing ? "transition-none" : "transition-all duration-150"} `}
style={{
left: displayPosition.x,
top: displayPosition.y,
width: displaySize.width,
height: displaySize.height,
boxSizing: "border-box",
}}
onMouseDown={handleMouseDown}
>
{/* 헤더 - showHeader가 false이면 숨김 */}
{element.showHeader !== false && (
<div className="flex cursor-move items-center justify-between px-2 py-1">
<div className="flex items-center gap-2">
{/* 차트 타입 전환 드롭다운 (차트일 경우만) */}
{element.type === "chart" && (
<Select
value={element.subtype}
onValueChange={(newSubtype: string) => {
onUpdate(element.id, { subtype: newSubtype as ElementSubtype });
}}
>
<SelectTrigger
className="h-6 w-[120px] text-[11px]"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[99999]" onClick={(e) => e.stopPropagation()}>
{getChartCategory(element.subtype) === "axis-based" ? (
<SelectGroup>
<SelectLabel> </SelectLabel>
<SelectItem value="bar"> </SelectItem>
<SelectItem value="horizontal-bar"> </SelectItem>
<SelectItem value="stacked-bar"> </SelectItem>
<SelectItem value="line"> </SelectItem>
<SelectItem value="area"> </SelectItem>
<SelectItem value="combo"> </SelectItem>
</SelectGroup>
) : (
<SelectGroup>
<SelectLabel> </SelectLabel>
<SelectItem value="pie"> </SelectItem>
<SelectItem value="donut"> </SelectItem>
</SelectGroup>
)}
</SelectContent>
</Select>
)}
{/* 제목 */}
{!element.type || element.type !== "chart" ? (
element.subtype === "map-summary-v2" && !element.customTitle ? null : (
<span className="text-foreground text-xs font-bold">{element.customTitle || element.title}</span>
)
) : null}
</div>
</div>
)}
{/* 삭제 버튼 - 항상 표시 (우측 상단 절대 위치) */}
<Button
variant="ghost"
size="icon"
className="element-close hover:bg-destructive text-muted-foreground absolute top-1 right-1 z-10 h-5 w-5 hover:text-white"
onClick={handleRemove}
onMouseDown={(e) => e.stopPropagation()}
title="삭제"
>
<X className="h-3 w-3" />
</Button>
{/* 내용 */}
<div className={`relative px-2 pb-2 ${element.showHeader !== false ? "h-[calc(100%-32px)]" : "h-full"}`}>
{element.type === "chart" ? (
// 차트 렌더링
<div className="bg-background h-full w-full">
{isLoadingData ? (
<div className="text-muted-foreground flex h-full w-full items-center justify-center">
<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 - 32}
/>
)}
</div>
) : element.type === "widget" && element.subtype === "weather" ? (
// 날씨 위젯 렌더링
<div className="widget-interactive-area h-full w-full">
<WeatherWidget element={element} 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-status" ? (
// 차량 상태 현황 위젯 렌더링
<div className="widget-interactive-area h-full w-full">
<VehicleStatusWidget element={element} />
</div>
) : element.type === "widget" && element.subtype === "vehicle-list" ? (
// 차량 목록 위젯 렌더링
<div className="widget-interactive-area h-full w-full">
<VehicleListWidget element={element} />
</div>
) : element.type === "widget" && element.subtype === "map-summary" ? (
// 커스텀 지도 카드 - 범용 위젯
<div className="widget-interactive-area h-full w-full">
<MapSummaryWidget element={element} />
</div>
) : element.type === "widget" && element.subtype === "map-test" ? (
// 🧪 테스트용 지도 위젯 (REST API 지원)
<div className="widget-interactive-area h-full w-full">
<MapTestWidget element={element} />
</div>
) : element.type === "widget" && element.subtype === "map-summary-v2" ? (
// 지도 위젯 (다중 데이터 소스) - 승격 완료
<div className="widget-interactive-area h-full w-full">
<MapTestWidgetV2 element={element} />
</div>
) : element.type === "widget" && element.subtype === "chart" ? (
// 차트 위젯 (다중 데이터 소스) - 승격 완료
<div className="widget-interactive-area h-full w-full">
<ChartTestWidget element={element} />
</div>
) : element.type === "widget" && element.subtype === "custom-metric-v2" ? (
// 통계 카드 위젯 (다중 데이터 소스) - 승격 완료
<div className="widget-interactive-area h-full w-full">
<CustomMetricTestWidget element={element} />
</div>
) : element.type === "widget" && element.subtype === "risk-alert-v2" ? (
// 리스크/알림 위젯 (다중 데이터 소스) - 승격 완료
<div className="widget-interactive-area h-full w-full">
<RiskAlertTestWidget element={element} />
</div>
) : element.type === "widget" && element.subtype === "vehicle-map" ? (
// 차량 위치 지도 위젯 렌더링 (구버전 - 호환용)
<div className="widget-interactive-area h-full w-full">
<VehicleMapOnlyWidget element={element} />
</div>
) : element.type === "widget" && element.subtype === "status-summary" ? (
// 커스텀 상태 카드 - 범용 위젯
<div className="widget-interactive-area h-full w-full">
<StatusSummaryWidget
element={element}
title="상태 요약"
icon="📊"
bgGradient="from-background to-primary/10"
/>
</div>
) : /* element.type === "widget" && element.subtype === "list-summary" ? (
// 커스텀 목록 카드 - 범용 위젯 (다른 분 작업 중 - 임시 주석)
<div className="widget-interactive-area h-full w-full">
<ListSummaryWidget element={element} />
</div>
) : */ element.type === "widget" && element.subtype === "delivery-status" ? (
// 배송/화물 현황 위젯 - 범용 위젯 사용 (구버전 호환)
<div className="widget-interactive-area h-full w-full">
<StatusSummaryWidget
element={element}
title="배송/화물 현황"
icon="📦"
bgGradient="from-background to-primary/10"
/>
</div>
) : element.type === "widget" && element.subtype === "delivery-status-summary" ? (
// 배송 상태 요약 - 범용 위젯 사용
<div className="widget-interactive-area h-full w-full">
<StatusSummaryWidget
element={element}
title="배송 상태 요약"
icon="📊"
bgGradient="from-background to-primary/10"
statusConfig={{
: { label: "배송중", color: "blue" },
: { label: "완료", color: "green" },
: { label: "지연", color: "red" },
"픽업 대기": { label: "픽업 대기", color: "yellow" },
}}
/>
</div>
) : element.type === "widget" && element.subtype === "delivery-today-stats" ? (
// 오늘 처리 현황 - 범용 위젯 사용
<div className="widget-interactive-area h-full w-full">
<StatusSummaryWidget
element={element}
title="오늘 처리 현황"
icon="📈"
bgGradient="from-background to-success/10"
/>
</div>
) : element.type === "widget" && element.subtype === "cargo-list" ? (
// 화물 목록 - 범용 위젯 사용
<div className="widget-interactive-area h-full w-full">
<StatusSummaryWidget
element={element}
title="화물 목록"
icon="📦"
bgGradient="from-background to-warning/10"
/>
</div>
) : element.type === "widget" && element.subtype === "customer-issues" ? (
// 고객 클레임/이슈 - 범용 위젯 사용
<div className="widget-interactive-area h-full w-full">
<StatusSummaryWidget
element={element}
title="고객 클레임/이슈"
icon=""
bgGradient="from-background to-destructive/10"
/>
</div>
) : element.type === "widget" && element.subtype === "risk-alert" ? (
// 리스크/알림 위젯 렌더링
<div className="widget-interactive-area h-full w-full">
<RiskAlertWidget element={element} />
</div>
) : element.type === "widget" && element.subtype === "calendar" ? (
// 달력 위젯 렌더링
<div className="h-full w-full">
<CalendarWidget
element={element}
onConfigUpdate={(newConfig) => {
onUpdate(element.id, { calendarConfig: newConfig });
}}
/>
</div>
) : element.type === "widget" && element.subtype === "driver-management" ? (
// 기사 관리 위젯 렌더링
<div className="h-full w-full">
<DriverManagementWidget
element={element}
onConfigUpdate={(newConfig) => {
onUpdate(element.id, { driverManagementConfig: newConfig });
}}
/>
</div>
) : element.type === "widget" && (element.subtype === "list" || element.subtype === "list-v2") ? (
// 리스트 위젯 렌더링 (v1 & v2)
<div className="h-full w-full">
<ListWidget element={element} />
</div>
) : element.type === "widget" && element.subtype === "yard-management-3d" ? (
// 3D 필드 위젯 렌더링
<div className="widget-interactive-area h-full w-full">
<YardManagement3DWidget
isEditMode={true}
config={element.yardConfig}
onConfigChange={(newConfig) => {
// console.log("🏗️ 야드 설정 업데이트:", { elementId: element.id, newConfig });
onUpdate(element.id, { yardConfig: newConfig });
}}
/>
</div>
) : element.type === "widget" && element.subtype === "work-history" ? (
// 작업 이력 위젯 렌더링
<div className="h-full w-full">
<WorkHistoryWidget element={element} />
</div>
) : element.type === "widget" && element.subtype === "transport-stats" ? (
// 커스텀 통계 카드 위젯 렌더링
<div className="h-full w-full">
<CustomStatsWidget element={element} />
</div>
) : element.type === "widget" && element.subtype === "custom-metric" ? (
// 사용자 커스텀 카드 위젯 렌더링 (main에서 추가)
<div className="h-full w-full">
<CustomMetricWidget element={element} />
</div>
) : element.type === "widget" && (element.subtype === "todo" || element.subtype === "maintenance") ? (
// Task 위젯 렌더링 (To-Do + 정비 일정 통합, lhj)
<div className="widget-interactive-area h-full w-full">
<TaskWidget element={element} />
</div>
) : element.type === "widget" && element.subtype === "booking-alert" ? (
// 예약 요청 알림 위젯 렌더링
<div className="widget-interactive-area h-full w-full">
<BookingAlertWidget />
</div>
) : element.type === "widget" && element.subtype === "document" ? (
// 문서 다운로드 위젯 렌더링
<div className="widget-interactive-area h-full w-full">
<DocumentWidget />
</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 bg-success absolute h-3 w-3 border border-white ${getPositionClass()} `}
onMouseDown={(e) => onMouseDown(e, position)}
/>
);
}