2025-10-13 17:05:14 +09:00
|
|
|
|
"use client";
|
2025-09-30 13:23:22 +09:00
|
|
|
|
|
2025-10-13 17:05:14 +09:00
|
|
|
|
import React, { useState, useCallback, useRef, useEffect } from "react";
|
2025-10-13 19:04:28 +09:00
|
|
|
|
import dynamic from "next/dynamic";
|
2025-10-13 17:05:14 +09:00
|
|
|
|
import { DashboardElement, QueryResult } from "./types";
|
|
|
|
|
|
import { ChartRenderer } from "./charts/ChartRenderer";
|
|
|
|
|
|
import { snapToGrid, snapSizeToGrid, GRID_CONFIG } from "./gridUtils";
|
2025-09-30 13:23:22 +09:00
|
|
|
|
|
2025-10-13 19:04:28 +09:00
|
|
|
|
// 위젯 동적 임포트
|
|
|
|
|
|
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>,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-10-14 10:34:18 +09:00
|
|
|
|
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>,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-10-15 10:29:15 +09:00
|
|
|
|
const VehicleStatusWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleStatusWidget"), {
|
|
|
|
|
|
ssr: false,
|
|
|
|
|
|
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const VehicleListWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleListWidget"), {
|
|
|
|
|
|
ssr: false,
|
|
|
|
|
|
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const VehicleMapOnlyWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleMapOnlyWidget"), {
|
2025-10-14 11:55:31 +09:00
|
|
|
|
ssr: false,
|
|
|
|
|
|
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-10-15 16:16:27 +09:00
|
|
|
|
// 범용 지도 위젯 (차량, 창고, 고객 등 모든 위치 위젯 통합)
|
|
|
|
|
|
const MapSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/MapSummaryWidget"), {
|
2025-10-14 16:36:00 +09:00
|
|
|
|
ssr: false,
|
|
|
|
|
|
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-10-15 16:16:27 +09:00
|
|
|
|
// 범용 상태 요약 위젯 (차량, 배송 등 모든 상태 위젯 통합)
|
|
|
|
|
|
const StatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/StatusSummaryWidget"), {
|
|
|
|
|
|
ssr: false,
|
|
|
|
|
|
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 범용 목록 위젯 (차량, 기사, 제품 등 모든 목록 위젯 통합) - 다른 분 작업 중, 임시 주석
|
|
|
|
|
|
/* const ListSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/ListSummaryWidget"), {
|
|
|
|
|
|
ssr: false,
|
|
|
|
|
|
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</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-gray-500">로딩 중...</div>,
|
|
|
|
|
|
// });
|
|
|
|
|
|
// const DeliveryTodayStatsWidget = dynamic(() => import("@/components/dashboard/widgets/DeliveryTodayStatsWidget"), {
|
|
|
|
|
|
// ssr: false,
|
|
|
|
|
|
// loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
|
|
|
|
|
// });
|
|
|
|
|
|
// const CargoListWidget = dynamic(() => import("@/components/dashboard/widgets/CargoListWidget"), {
|
|
|
|
|
|
// ssr: false,
|
|
|
|
|
|
// loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
|
|
|
|
|
// });
|
|
|
|
|
|
// const CustomerIssuesWidget = dynamic(() => import("@/components/dashboard/widgets/CustomerIssuesWidget"), {
|
|
|
|
|
|
// ssr: false,
|
|
|
|
|
|
// loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
|
|
|
|
|
// });
|
|
|
|
|
|
|
2025-10-14 16:36:00 +09:00
|
|
|
|
const RiskAlertWidget = dynamic(() => import("@/components/dashboard/widgets/RiskAlertWidget"), {
|
|
|
|
|
|
ssr: false,
|
|
|
|
|
|
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-10-14 17:21:28 +09:00
|
|
|
|
const TodoWidget = dynamic(() => import("@/components/dashboard/widgets/TodoWidget"), {
|
|
|
|
|
|
ssr: false,
|
|
|
|
|
|
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const BookingAlertWidget = dynamic(() => import("@/components/dashboard/widgets/BookingAlertWidget"), {
|
|
|
|
|
|
ssr: false,
|
|
|
|
|
|
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const MaintenanceWidget = dynamic(() => import("@/components/dashboard/widgets/MaintenanceWidget"), {
|
|
|
|
|
|
ssr: false,
|
|
|
|
|
|
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const DocumentWidget = dynamic(() => import("@/components/dashboard/widgets/DocumentWidget"), {
|
|
|
|
|
|
ssr: false,
|
|
|
|
|
|
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-10-14 09:41:33 +09:00
|
|
|
|
// 시계 위젯 임포트
|
|
|
|
|
|
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";
|
2025-10-15 11:17:09 +09:00
|
|
|
|
import { ListWidget } from "./widgets/ListWidget";
|
2025-10-14 09:41:33 +09:00
|
|
|
|
|
2025-10-17 15:26:21 +09:00
|
|
|
|
// 야드 관리 3D 위젯
|
|
|
|
|
|
const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DWidget"), {
|
|
|
|
|
|
ssr: false,
|
|
|
|
|
|
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-09-30 13:23:22 +09:00
|
|
|
|
interface CanvasElementProps {
|
|
|
|
|
|
element: DashboardElement;
|
|
|
|
|
|
isSelected: boolean;
|
2025-10-13 17:05:14 +09:00
|
|
|
|
cellSize: number;
|
2025-10-16 09:55:14 +09:00
|
|
|
|
canvasWidth?: number;
|
2025-09-30 13:23:22 +09:00
|
|
|
|
onUpdate: (id: string, updates: Partial<DashboardElement>) => void;
|
|
|
|
|
|
onRemove: (id: string) => void;
|
|
|
|
|
|
onSelect: (id: string | null) => void;
|
|
|
|
|
|
onConfigure?: (element: DashboardElement) => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 캔버스에 배치된 개별 요소 컴포넌트
|
2025-10-13 17:05:14 +09:00
|
|
|
|
* - 드래그로 이동 가능 (그리드 스냅)
|
|
|
|
|
|
* - 크기 조절 핸들 (그리드 스냅)
|
2025-09-30 13:23:22 +09:00
|
|
|
|
* - 삭제 버튼
|
|
|
|
|
|
*/
|
2025-10-13 17:05:14 +09:00
|
|
|
|
export function CanvasElement({
|
|
|
|
|
|
element,
|
|
|
|
|
|
isSelected,
|
|
|
|
|
|
cellSize,
|
2025-10-16 09:55:14 +09:00
|
|
|
|
canvasWidth = 1560,
|
2025-10-13 17:05:14 +09:00
|
|
|
|
onUpdate,
|
|
|
|
|
|
onRemove,
|
|
|
|
|
|
onSelect,
|
|
|
|
|
|
onConfigure,
|
|
|
|
|
|
}: CanvasElementProps) {
|
2025-09-30 13:23:22 +09:00
|
|
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
|
|
|
|
const [isResizing, setIsResizing] = useState(false);
|
|
|
|
|
|
const [dragStart, setDragStart] = useState({ x: 0, y: 0, elementX: 0, elementY: 0 });
|
2025-10-13 17:05:14 +09:00
|
|
|
|
const [resizeStart, setResizeStart] = useState({
|
|
|
|
|
|
x: 0,
|
|
|
|
|
|
y: 0,
|
|
|
|
|
|
width: 0,
|
|
|
|
|
|
height: 0,
|
|
|
|
|
|
elementX: 0,
|
|
|
|
|
|
elementY: 0,
|
|
|
|
|
|
handle: "",
|
2025-09-30 13:23:22 +09:00
|
|
|
|
});
|
|
|
|
|
|
const [chartData, setChartData] = useState<QueryResult | null>(null);
|
|
|
|
|
|
const [isLoadingData, setIsLoadingData] = useState(false);
|
|
|
|
|
|
const elementRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
2025-10-13 17:05:14 +09:00
|
|
|
|
// 드래그/리사이즈 중 임시 위치/크기 (스냅 전)
|
|
|
|
|
|
const [tempPosition, setTempPosition] = useState<{ x: number; y: number } | null>(null);
|
|
|
|
|
|
const [tempSize, setTempSize] = useState<{ width: number; height: number } | null>(null);
|
|
|
|
|
|
|
2025-09-30 13:23:22 +09:00
|
|
|
|
// 요소 선택 처리
|
2025-10-13 17:05:14 +09:00
|
|
|
|
const handleMouseDown = useCallback(
|
|
|
|
|
|
(e: React.MouseEvent) => {
|
2025-10-17 18:00:27 +09:00
|
|
|
|
// 모달이나 다이얼로그가 열려있으면 드래그 무시
|
|
|
|
|
|
if (document.querySelector('[role="dialog"]')) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-13 17:05:14 +09:00
|
|
|
|
// 닫기 버튼이나 리사이즈 핸들 클릭 시 무시
|
|
|
|
|
|
if ((e.target as HTMLElement).closest(".element-close, .resize-handle")) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-09-30 13:23:22 +09:00
|
|
|
|
|
2025-10-14 10:05:40 +09:00
|
|
|
|
// 위젯 내부 (헤더 제외) 클릭 시 드래그 무시 - 인터랙티브 사용 가능
|
|
|
|
|
|
if ((e.target as HTMLElement).closest(".widget-interactive-area")) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-16 10:02:47 +09:00
|
|
|
|
// 선택되지 않은 경우에만 선택 처리
|
|
|
|
|
|
if (!isSelected) {
|
|
|
|
|
|
onSelect(element.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-13 17:05:14 +09:00
|
|
|
|
setIsDragging(true);
|
|
|
|
|
|
setDragStart({
|
|
|
|
|
|
x: e.clientX,
|
|
|
|
|
|
y: e.clientY,
|
|
|
|
|
|
elementX: element.position.x,
|
|
|
|
|
|
elementY: element.position.y,
|
|
|
|
|
|
});
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
},
|
2025-10-16 10:02:47 +09:00
|
|
|
|
[element.id, element.position.x, element.position.y, onSelect, isSelected],
|
2025-10-13 17:05:14 +09:00
|
|
|
|
);
|
2025-09-30 13:23:22 +09:00
|
|
|
|
|
|
|
|
|
|
// 리사이즈 핸들 마우스다운
|
2025-10-13 17:05:14 +09:00
|
|
|
|
const handleResizeMouseDown = useCallback(
|
|
|
|
|
|
(e: React.MouseEvent, handle: string) => {
|
2025-10-17 18:00:27 +09:00
|
|
|
|
// 모달이나 다이얼로그가 열려있으면 리사이즈 무시
|
|
|
|
|
|
if (document.querySelector('[role="dialog"]')) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-13 17:05:14 +09:00
|
|
|
|
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,
|
2025-09-30 13:23:22 +09:00
|
|
|
|
});
|
2025-10-13 17:05:14 +09:00
|
|
|
|
},
|
|
|
|
|
|
[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;
|
|
|
|
|
|
|
2025-10-17 09:49:02 +09:00
|
|
|
|
// 임시 위치 계산
|
2025-10-14 13:20:17 +09:00
|
|
|
|
let rawX = Math.max(0, dragStart.elementX + deltaX);
|
2025-10-13 17:05:14 +09:00
|
|
|
|
const rawY = Math.max(0, dragStart.elementY + deltaY);
|
|
|
|
|
|
|
2025-10-14 13:20:17 +09:00
|
|
|
|
// X 좌표가 캔버스 너비를 벗어나지 않도록 제한
|
2025-10-16 09:55:14 +09:00
|
|
|
|
const maxX = canvasWidth - element.size.width;
|
2025-10-14 13:20:17 +09:00
|
|
|
|
rawX = Math.min(rawX, maxX);
|
|
|
|
|
|
|
2025-10-17 09:49:02 +09:00
|
|
|
|
// 드래그 중 실시간 스냅 (마그네틱 스냅)
|
|
|
|
|
|
const subGridSize = Math.floor(cellSize / 3);
|
|
|
|
|
|
const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기
|
|
|
|
|
|
const magneticThreshold = 15; // 큰 그리드에 끌리는 거리 (px)
|
2025-10-17 13:44:51 +09:00
|
|
|
|
|
2025-10-17 09:49:02 +09:00
|
|
|
|
// X 좌표 스냅 (큰 그리드 우선, 없으면 서브그리드)
|
|
|
|
|
|
const nearestGridX = Math.round(rawX / gridSize) * gridSize;
|
|
|
|
|
|
const distToGridX = Math.abs(rawX - nearestGridX);
|
2025-10-17 13:44:51 +09:00
|
|
|
|
const snappedX = distToGridX <= magneticThreshold ? nearestGridX : Math.round(rawX / subGridSize) * subGridSize;
|
|
|
|
|
|
|
2025-10-17 09:49:02 +09:00
|
|
|
|
// Y 좌표 스냅 (큰 그리드 우선, 없으면 서브그리드)
|
|
|
|
|
|
const nearestGridY = Math.round(rawY / gridSize) * gridSize;
|
|
|
|
|
|
const distToGridY = Math.abs(rawY - nearestGridY);
|
2025-10-17 13:44:51 +09:00
|
|
|
|
const snappedY = distToGridY <= magneticThreshold ? nearestGridY : Math.round(rawY / subGridSize) * subGridSize;
|
2025-10-17 09:49:02 +09:00
|
|
|
|
|
|
|
|
|
|
setTempPosition({ x: snappedX, y: snappedY });
|
2025-10-13 17:05:14 +09:00
|
|
|
|
} 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;
|
2025-10-16 10:09:10 +09:00
|
|
|
|
const minWidth = cellSize * minWidthCells;
|
|
|
|
|
|
const minHeight = cellSize * minHeightCells;
|
2025-10-13 17:05:14 +09:00
|
|
|
|
|
|
|
|
|
|
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);
|
2025-10-13 17:05:14 +09:00
|
|
|
|
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);
|
2025-10-13 17:05:14 +09:00
|
|
|
|
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);
|
2025-10-13 17:05:14 +09:00
|
|
|
|
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);
|
2025-10-13 17:05:14 +09:00
|
|
|
|
newX = resizeStart.elementX + deltaX;
|
|
|
|
|
|
newY = resizeStart.elementY + deltaY;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-14 13:20:17 +09:00
|
|
|
|
// 가로 너비가 캔버스를 벗어나지 않도록 제한
|
2025-10-16 09:55:14 +09:00
|
|
|
|
const maxWidth = canvasWidth - newX;
|
2025-10-14 13:20:17 +09:00
|
|
|
|
newWidth = Math.min(newWidth, maxWidth);
|
|
|
|
|
|
|
2025-10-17 09:49:02 +09:00
|
|
|
|
// 리사이즈 중 실시간 스냅 (마그네틱 스냅)
|
|
|
|
|
|
const subGridSize = Math.floor(cellSize / 3);
|
|
|
|
|
|
const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기
|
|
|
|
|
|
const magneticThreshold = 15;
|
2025-10-17 13:44:51 +09:00
|
|
|
|
|
2025-10-17 09:49:02 +09:00
|
|
|
|
// 위치 스냅
|
|
|
|
|
|
const nearestGridX = Math.round(newX / gridSize) * gridSize;
|
|
|
|
|
|
const distToGridX = Math.abs(newX - nearestGridX);
|
2025-10-17 13:44:51 +09:00
|
|
|
|
const snappedX = distToGridX <= magneticThreshold ? nearestGridX : Math.round(newX / subGridSize) * subGridSize;
|
|
|
|
|
|
|
2025-10-17 09:49:02 +09:00
|
|
|
|
const nearestGridY = Math.round(newY / gridSize) * gridSize;
|
|
|
|
|
|
const distToGridY = Math.abs(newY - nearestGridY);
|
2025-10-17 13:44:51 +09:00
|
|
|
|
const snappedY = distToGridY <= magneticThreshold ? nearestGridY : Math.round(newY / subGridSize) * subGridSize;
|
|
|
|
|
|
|
2025-10-17 09:49:02 +09:00
|
|
|
|
// 크기 스냅 (그리드 칸 단위로 스냅하되, 마지막 GAP은 제외)
|
|
|
|
|
|
// 예: 1칸 = cellSize, 2칸 = cellSize*2 + GAP, 3칸 = cellSize*3 + GAP*2
|
|
|
|
|
|
const calculateGridWidth = (cells: number) => cells * cellSize + Math.max(0, cells - 1) * 5;
|
2025-10-17 13:44:51 +09:00
|
|
|
|
|
2025-10-17 09:49:02 +09:00
|
|
|
|
// 가장 가까운 그리드 칸 수 계산
|
|
|
|
|
|
const nearestWidthCells = Math.round(newWidth / gridSize);
|
|
|
|
|
|
const nearestGridWidth = calculateGridWidth(nearestWidthCells);
|
|
|
|
|
|
const distToGridWidth = Math.abs(newWidth - nearestGridWidth);
|
2025-10-17 13:44:51 +09:00
|
|
|
|
const snappedWidth =
|
|
|
|
|
|
distToGridWidth <= magneticThreshold ? nearestGridWidth : Math.round(newWidth / subGridSize) * subGridSize;
|
|
|
|
|
|
|
2025-10-17 09:49:02 +09:00
|
|
|
|
const nearestHeightCells = Math.round(newHeight / gridSize);
|
|
|
|
|
|
const nearestGridHeight = calculateGridWidth(nearestHeightCells);
|
|
|
|
|
|
const distToGridHeight = Math.abs(newHeight - nearestGridHeight);
|
2025-10-17 13:44:51 +09:00
|
|
|
|
const snappedHeight =
|
|
|
|
|
|
distToGridHeight <= magneticThreshold ? nearestGridHeight : Math.round(newHeight / subGridSize) * subGridSize;
|
2025-10-17 09:49:02 +09:00
|
|
|
|
|
|
|
|
|
|
// 임시 크기/위치 저장 (스냅됨)
|
|
|
|
|
|
setTempPosition({ x: Math.max(0, snappedX), y: Math.max(0, snappedY) });
|
|
|
|
|
|
setTempSize({ width: snappedWidth, height: snappedHeight });
|
2025-09-30 13:23:22 +09:00
|
|
|
|
}
|
2025-10-13 17:05:14 +09:00
|
|
|
|
},
|
2025-10-17 13:44:51 +09:00
|
|
|
|
[
|
|
|
|
|
|
isDragging,
|
|
|
|
|
|
isResizing,
|
|
|
|
|
|
dragStart,
|
|
|
|
|
|
resizeStart,
|
|
|
|
|
|
element.size.width,
|
|
|
|
|
|
element.type,
|
|
|
|
|
|
element.subtype,
|
|
|
|
|
|
canvasWidth,
|
|
|
|
|
|
cellSize,
|
|
|
|
|
|
],
|
2025-10-13 17:05:14 +09:00
|
|
|
|
);
|
|
|
|
|
|
|
2025-10-17 09:49:02 +09:00
|
|
|
|
// 마우스 업 처리 (이미 스냅된 위치 사용)
|
2025-10-13 17:05:14 +09:00
|
|
|
|
const handleMouseUp = useCallback(() => {
|
|
|
|
|
|
if (isDragging && tempPosition) {
|
2025-10-17 09:49:02 +09:00
|
|
|
|
// tempPosition은 이미 드래그 중에 마그네틱 스냅 적용됨
|
|
|
|
|
|
// 다시 스냅하지 않고 그대로 사용!
|
|
|
|
|
|
let finalX = tempPosition.x;
|
|
|
|
|
|
const finalY = tempPosition.y;
|
2025-09-30 13:23:22 +09:00
|
|
|
|
|
2025-10-14 13:20:17 +09:00
|
|
|
|
// X 좌표가 캔버스 너비를 벗어나지 않도록 최종 제한
|
2025-10-16 09:55:14 +09:00
|
|
|
|
const maxX = canvasWidth - element.size.width;
|
2025-10-17 09:49:02 +09:00
|
|
|
|
finalX = Math.min(finalX, maxX);
|
2025-10-14 13:20:17 +09:00
|
|
|
|
|
2025-09-30 13:23:22 +09:00
|
|
|
|
onUpdate(element.id, {
|
2025-10-17 09:49:02 +09:00
|
|
|
|
position: { x: finalX, y: finalY },
|
2025-09-30 13:23:22 +09:00
|
|
|
|
});
|
2025-10-13 17:05:14 +09:00
|
|
|
|
|
|
|
|
|
|
setTempPosition(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (isResizing && tempPosition && tempSize) {
|
2025-10-17 09:49:02 +09:00
|
|
|
|
// tempPosition과 tempSize는 이미 리사이즈 중에 마그네틱 스냅 적용됨
|
|
|
|
|
|
// 다시 스냅하지 않고 그대로 사용!
|
2025-10-17 16:23:33 +09:00
|
|
|
|
const finalX = tempPosition.x;
|
2025-10-17 09:49:02 +09:00
|
|
|
|
const finalY = tempPosition.y;
|
|
|
|
|
|
let finalWidth = tempSize.width;
|
|
|
|
|
|
const finalHeight = tempSize.height;
|
2025-10-13 17:05:14 +09:00
|
|
|
|
|
2025-10-14 13:20:17 +09:00
|
|
|
|
// 가로 너비가 캔버스를 벗어나지 않도록 최종 제한
|
2025-10-17 09:49:02 +09:00
|
|
|
|
const maxWidth = canvasWidth - finalX;
|
|
|
|
|
|
finalWidth = Math.min(finalWidth, maxWidth);
|
2025-10-14 13:20:17 +09:00
|
|
|
|
|
2025-10-13 17:05:14 +09:00
|
|
|
|
onUpdate(element.id, {
|
2025-10-17 09:49:02 +09:00
|
|
|
|
position: { x: finalX, y: finalY },
|
|
|
|
|
|
size: { width: finalWidth, height: finalHeight },
|
2025-10-13 17:05:14 +09:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
setTempPosition(null);
|
|
|
|
|
|
setTempSize(null);
|
2025-09-30 13:23:22 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setIsDragging(false);
|
|
|
|
|
|
setIsResizing(false);
|
2025-10-16 09:55:14 +09:00
|
|
|
|
}, [isDragging, isResizing, tempPosition, tempSize, element.id, element.size.width, onUpdate, cellSize, canvasWidth]);
|
2025-09-30 13:23:22 +09:00
|
|
|
|
|
|
|
|
|
|
// 전역 마우스 이벤트 등록
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
|
if (isDragging || isResizing) {
|
2025-10-13 17:05:14 +09:00
|
|
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
|
|
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
|
|
|
|
|
2025-09-30 13:23:22 +09:00
|
|
|
|
return () => {
|
2025-10-13 17:05:14 +09:00
|
|
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
|
|
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
2025-09-30 13:23:22 +09:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
|
|
|
|
|
|
|
|
|
|
|
|
// 데이터 로딩
|
|
|
|
|
|
const loadChartData = useCallback(async () => {
|
2025-10-13 17:05:14 +09:00
|
|
|
|
if (!element.dataSource?.query || element.type !== "chart") {
|
2025-09-30 13:23:22 +09:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setIsLoadingData(true);
|
|
|
|
|
|
try {
|
2025-10-14 15:59:16 +09:00
|
|
|
|
let result;
|
|
|
|
|
|
|
2025-10-15 15:05:20 +09:00
|
|
|
|
// 필터 적용 (날짜 필터 등)
|
|
|
|
|
|
const { applyQueryFilters } = await import("./utils/queryHelpers");
|
|
|
|
|
|
const filteredQuery = applyQueryFilters(element.dataSource.query, element.chartConfig);
|
|
|
|
|
|
|
2025-10-14 15:59:16 +09:00
|
|
|
|
// 외부 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),
|
2025-10-15 15:05:20 +09:00
|
|
|
|
filteredQuery,
|
2025-10-14 15:59:16 +09:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (!externalResult.success) {
|
|
|
|
|
|
throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패");
|
|
|
|
|
|
}
|
2025-10-13 17:05:14 +09:00
|
|
|
|
|
2025-10-14 15:59:16 +09:00
|
|
|
|
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");
|
2025-10-15 15:05:20 +09:00
|
|
|
|
result = await dashboardApi.executeQuery(filteredQuery);
|
2025-10-14 15:59:16 +09:00
|
|
|
|
|
|
|
|
|
|
setChartData({
|
|
|
|
|
|
columns: result.columns || [],
|
|
|
|
|
|
rows: result.rows || [],
|
|
|
|
|
|
totalRows: result.rowCount || 0,
|
|
|
|
|
|
executionTime: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-09-30 13:23:22 +09:00
|
|
|
|
} catch (error) {
|
2025-10-17 10:38:22 +09:00
|
|
|
|
// console.error("Chart data loading error:", error);
|
2025-09-30 13:23:22 +09:00
|
|
|
|
setChartData(null);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsLoadingData(false);
|
|
|
|
|
|
}
|
2025-10-14 15:59:16 +09:00
|
|
|
|
}, [
|
|
|
|
|
|
element.dataSource?.query,
|
|
|
|
|
|
element.dataSource?.connectionType,
|
|
|
|
|
|
element.dataSource?.externalConnectionId,
|
2025-10-15 15:05:20 +09:00
|
|
|
|
element.chartConfig,
|
2025-10-14 15:59:16 +09:00
|
|
|
|
element.type,
|
|
|
|
|
|
]);
|
2025-09-30 13:23:22 +09:00
|
|
|
|
|
|
|
|
|
|
// 컴포넌트 마운트 시 및 쿼리 변경 시 데이터 로딩
|
|
|
|
|
|
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 = () => {
|
2025-10-13 17:05:14 +09:00
|
|
|
|
if (element.type === "chart") {
|
2025-09-30 13:23:22 +09:00
|
|
|
|
switch (element.subtype) {
|
2025-10-13 17:05:14 +09:00
|
|
|
|
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";
|
2025-09-30 13:23:22 +09:00
|
|
|
|
}
|
2025-10-13 17:05:14 +09:00
|
|
|
|
} else if (element.type === "widget") {
|
2025-09-30 13:23:22 +09:00
|
|
|
|
switch (element.subtype) {
|
2025-10-13 17:05:14 +09:00
|
|
|
|
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";
|
2025-10-14 09:41:33 +09:00
|
|
|
|
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";
|
2025-10-15 11:17:09 +09:00
|
|
|
|
case "list":
|
|
|
|
|
|
return "bg-gradient-to-br from-cyan-400 to-blue-600";
|
2025-10-13 17:05:14 +09:00
|
|
|
|
default:
|
|
|
|
|
|
return "bg-gray-200";
|
2025-09-30 13:23:22 +09:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-10-13 17:05:14 +09:00
|
|
|
|
return "bg-gray-200";
|
2025-09-30 13:23:22 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-13 17:05:14 +09:00
|
|
|
|
// 드래그/리사이즈 중일 때는 임시 위치/크기 사용, 아니면 실제 값 사용
|
|
|
|
|
|
const displayPosition = tempPosition || element.position;
|
|
|
|
|
|
const displaySize = tempSize || element.size;
|
|
|
|
|
|
|
2025-09-30 13:23:22 +09:00
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref={elementRef}
|
2025-10-13 17:05:14 +09:00
|
|
|
|
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"} `}
|
2025-09-30 13:23:22 +09:00
|
|
|
|
style={{
|
2025-10-13 17:05:14 +09:00
|
|
|
|
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",
|
2025-09-30 13:23:22 +09:00
|
|
|
|
}}
|
|
|
|
|
|
onMouseDown={handleMouseDown}
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* 헤더 */}
|
2025-10-13 17:05:14 +09:00
|
|
|
|
<div className="flex cursor-move items-center justify-between border-b border-gray-200 bg-gray-50 p-3">
|
2025-10-16 14:59:07 +09:00
|
|
|
|
<span className="text-sm font-bold text-gray-800">{element.customTitle || element.title}</span>
|
2025-09-30 13:23:22 +09:00
|
|
|
|
<div className="flex gap-1">
|
2025-10-17 14:52:08 +09:00
|
|
|
|
{/* 설정 버튼 (기사관리 위젯만 자체 설정 UI 사용) */}
|
2025-10-17 18:00:27 +09:00
|
|
|
|
{onConfigure && !(element.type === "widget" && element.subtype === "driver-management") && (
|
|
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
2025-09-30 13:23:22 +09:00
|
|
|
|
{/* 삭제 버튼 */}
|
|
|
|
|
|
<button
|
2025-10-13 17:05:14 +09:00
|
|
|
|
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"
|
2025-09-30 13:23:22 +09:00
|
|
|
|
onClick={handleRemove}
|
|
|
|
|
|
title="삭제"
|
|
|
|
|
|
>
|
|
|
|
|
|
×
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 내용 */}
|
2025-10-13 17:05:14 +09:00
|
|
|
|
<div className="relative h-[calc(100%-45px)]">
|
|
|
|
|
|
{element.type === "chart" ? (
|
2025-09-30 13:23:22 +09:00
|
|
|
|
// 차트 렌더링
|
2025-10-13 17:05:14 +09:00
|
|
|
|
<div className="h-full w-full bg-white">
|
2025-09-30 13:23:22 +09:00
|
|
|
|
{isLoadingData ? (
|
2025-10-13 17:05:14 +09:00
|
|
|
|
<div className="flex h-full w-full items-center justify-center text-gray-500">
|
2025-09-30 13:23:22 +09:00
|
|
|
|
<div className="text-center">
|
2025-10-13 17:05:14 +09:00
|
|
|
|
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
|
2025-09-30 13:23:22 +09:00
|
|
|
|
<div className="text-sm">데이터 로딩 중...</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<ChartRenderer
|
|
|
|
|
|
element={element}
|
2025-10-14 13:20:17 +09:00
|
|
|
|
data={chartData || undefined}
|
2025-09-30 13:23:22 +09:00
|
|
|
|
width={element.size.width}
|
|
|
|
|
|
height={element.size.height - 45}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2025-10-13 19:04:28 +09:00
|
|
|
|
) : element.type === "widget" && element.subtype === "weather" ? (
|
|
|
|
|
|
// 날씨 위젯 렌더링
|
2025-10-14 10:23:20 +09:00
|
|
|
|
<div className="widget-interactive-area h-full w-full">
|
2025-10-14 13:20:17 +09:00
|
|
|
|
<WeatherWidget city="서울" refreshInterval={600000} />
|
2025-10-13 19:04:28 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
) : element.type === "widget" && element.subtype === "exchange" ? (
|
|
|
|
|
|
// 환율 위젯 렌더링
|
2025-10-14 10:23:20 +09:00
|
|
|
|
<div className="widget-interactive-area h-full w-full">
|
2025-10-14 13:20:17 +09:00
|
|
|
|
<ExchangeWidget baseCurrency="KRW" targetCurrency="USD" refreshInterval={600000} />
|
2025-10-13 19:04:28 +09:00
|
|
|
|
</div>
|
2025-10-14 09:41:33 +09:00
|
|
|
|
) : element.type === "widget" && element.subtype === "clock" ? (
|
|
|
|
|
|
// 시계 위젯 렌더링
|
|
|
|
|
|
<div className="h-full w-full">
|
2025-10-14 10:23:20 +09:00
|
|
|
|
<ClockWidget
|
|
|
|
|
|
element={element}
|
|
|
|
|
|
onConfigUpdate={(newConfig) => {
|
|
|
|
|
|
onUpdate(element.id, { clockConfig: newConfig });
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2025-10-14 09:41:33 +09:00
|
|
|
|
</div>
|
2025-10-14 10:34:18 +09:00
|
|
|
|
) : element.type === "widget" && element.subtype === "calculator" ? (
|
|
|
|
|
|
// 계산기 위젯 렌더링
|
|
|
|
|
|
<div className="widget-interactive-area h-full w-full">
|
|
|
|
|
|
<CalculatorWidget />
|
|
|
|
|
|
</div>
|
2025-10-15 10:29:15 +09:00
|
|
|
|
) : 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>
|
2025-10-15 16:16:27 +09:00
|
|
|
|
) : element.type === "widget" && element.subtype === "map-summary" ? (
|
|
|
|
|
|
// 커스텀 지도 카드 - 범용 위젯
|
|
|
|
|
|
<div className="widget-interactive-area h-full w-full">
|
|
|
|
|
|
<MapSummaryWidget element={element} />
|
|
|
|
|
|
</div>
|
2025-10-14 11:55:31 +09:00
|
|
|
|
) : element.type === "widget" && element.subtype === "vehicle-map" ? (
|
2025-10-15 16:16:27 +09:00
|
|
|
|
// 차량 위치 지도 위젯 렌더링 (구버전 - 호환용)
|
2025-10-14 11:55:31 +09:00
|
|
|
|
<div className="widget-interactive-area h-full w-full">
|
2025-10-15 10:29:15 +09:00
|
|
|
|
<VehicleMapOnlyWidget element={element} />
|
2025-10-14 11:55:31 +09:00
|
|
|
|
</div>
|
2025-10-15 16:16:27 +09:00
|
|
|
|
) : element.type === "widget" && element.subtype === "status-summary" ? (
|
|
|
|
|
|
// 커스텀 상태 카드 - 범용 위젯
|
|
|
|
|
|
<div className="widget-interactive-area h-full w-full">
|
2025-10-16 09:55:14 +09:00
|
|
|
|
<StatusSummaryWidget element={element} title="상태 요약" icon="📊" bgGradient="from-slate-50 to-blue-50" />
|
2025-10-15 16:16:27 +09:00
|
|
|
|
</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">
|
2025-10-16 09:55:14 +09:00
|
|
|
|
<StatusSummaryWidget
|
2025-10-15 16:16:27 +09:00
|
|
|
|
element={element}
|
|
|
|
|
|
title="배송/화물 현황"
|
|
|
|
|
|
icon="📦"
|
|
|
|
|
|
bgGradient="from-slate-50 to-blue-50"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : element.type === "widget" && element.subtype === "delivery-status-summary" ? (
|
|
|
|
|
|
// 배송 상태 요약 - 범용 위젯 사용
|
2025-10-14 16:36:00 +09:00
|
|
|
|
<div className="widget-interactive-area h-full w-full">
|
2025-10-16 09:55:14 +09:00
|
|
|
|
<StatusSummaryWidget
|
2025-10-15 16:16:27 +09:00
|
|
|
|
element={element}
|
|
|
|
|
|
title="배송 상태 요약"
|
|
|
|
|
|
icon="📊"
|
|
|
|
|
|
bgGradient="from-slate-50 to-blue-50"
|
|
|
|
|
|
statusConfig={{
|
2025-10-16 09:55:14 +09:00
|
|
|
|
배송중: { label: "배송중", color: "blue" },
|
|
|
|
|
|
완료: { label: "완료", color: "green" },
|
|
|
|
|
|
지연: { label: "지연", color: "red" },
|
|
|
|
|
|
"픽업 대기": { label: "픽업 대기", color: "yellow" },
|
2025-10-15 16:16:27 +09:00
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : element.type === "widget" && element.subtype === "delivery-today-stats" ? (
|
|
|
|
|
|
// 오늘 처리 현황 - 범용 위젯 사용
|
|
|
|
|
|
<div className="widget-interactive-area h-full w-full">
|
2025-10-16 09:55:14 +09:00
|
|
|
|
<StatusSummaryWidget
|
2025-10-15 16:16:27 +09:00
|
|
|
|
element={element}
|
|
|
|
|
|
title="오늘 처리 현황"
|
|
|
|
|
|
icon="📈"
|
|
|
|
|
|
bgGradient="from-slate-50 to-green-50"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : element.type === "widget" && element.subtype === "cargo-list" ? (
|
|
|
|
|
|
// 화물 목록 - 범용 위젯 사용
|
|
|
|
|
|
<div className="widget-interactive-area h-full w-full">
|
2025-10-16 09:55:14 +09:00
|
|
|
|
<StatusSummaryWidget
|
2025-10-15 16:16:27 +09:00
|
|
|
|
element={element}
|
|
|
|
|
|
title="화물 목록"
|
|
|
|
|
|
icon="📦"
|
|
|
|
|
|
bgGradient="from-slate-50 to-orange-50"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : element.type === "widget" && element.subtype === "customer-issues" ? (
|
|
|
|
|
|
// 고객 클레임/이슈 - 범용 위젯 사용
|
|
|
|
|
|
<div className="widget-interactive-area h-full w-full">
|
2025-10-16 09:55:14 +09:00
|
|
|
|
<StatusSummaryWidget
|
2025-10-15 16:16:27 +09:00
|
|
|
|
element={element}
|
|
|
|
|
|
title="고객 클레임/이슈"
|
|
|
|
|
|
icon="⚠️"
|
|
|
|
|
|
bgGradient="from-slate-50 to-red-50"
|
|
|
|
|
|
/>
|
2025-10-14 16:36:00 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
) : element.type === "widget" && element.subtype === "risk-alert" ? (
|
|
|
|
|
|
// 리스크/알림 위젯 렌더링
|
|
|
|
|
|
<div className="widget-interactive-area h-full w-full">
|
|
|
|
|
|
<RiskAlertWidget />
|
|
|
|
|
|
</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>
|
2025-10-15 11:17:09 +09:00
|
|
|
|
) : element.type === "widget" && element.subtype === "list" ? (
|
|
|
|
|
|
// 리스트 위젯 렌더링
|
|
|
|
|
|
<div className="h-full w-full">
|
|
|
|
|
|
<ListWidget
|
|
|
|
|
|
element={element}
|
|
|
|
|
|
onConfigUpdate={(newConfig) => {
|
|
|
|
|
|
onUpdate(element.id, { listConfig: newConfig as any });
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2025-10-17 15:26:21 +09:00
|
|
|
|
) : 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) => {
|
|
|
|
|
|
onUpdate(element.id, { yardConfig: newConfig });
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2025-10-14 17:21:28 +09:00
|
|
|
|
) : element.type === "widget" && element.subtype === "todo" ? (
|
|
|
|
|
|
// To-Do 위젯 렌더링
|
|
|
|
|
|
<div className="widget-interactive-area h-full w-full">
|
2025-10-17 09:49:02 +09:00
|
|
|
|
<TodoWidget element={element} />
|
2025-10-14 17:21:28 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
) : element.type === "widget" && element.subtype === "booking-alert" ? (
|
|
|
|
|
|
// 예약 요청 알림 위젯 렌더링
|
|
|
|
|
|
<div className="widget-interactive-area h-full w-full">
|
|
|
|
|
|
<BookingAlertWidget />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : element.type === "widget" && element.subtype === "maintenance" ? (
|
|
|
|
|
|
// 정비 일정 위젯 렌더링
|
|
|
|
|
|
<div className="widget-interactive-area h-full w-full">
|
|
|
|
|
|
<MaintenanceWidget />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : element.type === "widget" && element.subtype === "document" ? (
|
|
|
|
|
|
// 문서 다운로드 위젯 렌더링
|
|
|
|
|
|
<div className="widget-interactive-area h-full w-full">
|
|
|
|
|
|
<DocumentWidget />
|
|
|
|
|
|
</div>
|
2025-09-30 13:23:22 +09:00
|
|
|
|
) : (
|
2025-10-13 19:04:28 +09:00
|
|
|
|
// 기타 위젯 렌더링
|
2025-10-13 17:05:14 +09:00
|
|
|
|
<div
|
|
|
|
|
|
className={`flex h-full w-full items-center justify-center p-5 text-center text-sm font-medium text-white ${getContentClass()} `}
|
|
|
|
|
|
>
|
2025-09-30 13:23:22 +09:00
|
|
|
|
<div>
|
2025-10-13 19:04:28 +09:00
|
|
|
|
<div className="mb-2 text-4xl">🔧</div>
|
2025-09-30 13:23:22 +09:00
|
|
|
|
<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 {
|
2025-10-13 17:05:14 +09:00
|
|
|
|
position: "nw" | "ne" | "sw" | "se";
|
2025-09-30 13:23:22 +09:00
|
|
|
|
onMouseDown: (e: React.MouseEvent, handle: string) => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 크기 조절 핸들 컴포넌트
|
|
|
|
|
|
*/
|
|
|
|
|
|
function ResizeHandle({ position, onMouseDown }: ResizeHandleProps) {
|
|
|
|
|
|
const getPositionClass = () => {
|
|
|
|
|
|
switch (position) {
|
2025-10-13 17:05:14 +09:00
|
|
|
|
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";
|
2025-09-30 13:23:22 +09:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
2025-10-13 17:05:14 +09:00
|
|
|
|
className={`resize-handle absolute h-3 w-3 border border-white bg-green-500 ${getPositionClass()} `}
|
2025-09-30 13:23:22 +09:00
|
|
|
|
onMouseDown={(e) => onMouseDown(e, position)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|