1184 lines
45 KiB
TypeScript
1184 lines
45 KiB
TypeScript
"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)}
|
||
/>
|
||
);
|
||
}
|