2025-10-13 17:05:14 +09:00
|
|
|
"use client";
|
2025-09-30 13:23:22 +09:00
|
|
|
|
2025-10-14 10:51:28 +09:00
|
|
|
import React, { useState, useRef, useCallback } from "react";
|
2025-10-14 17:25:07 +09:00
|
|
|
import { useRouter } from "next/navigation";
|
2025-10-13 17:05:14 +09:00
|
|
|
import { DashboardCanvas } from "./DashboardCanvas";
|
2025-10-16 09:55:14 +09:00
|
|
|
import { DashboardTopMenu } from "./DashboardTopMenu";
|
2025-10-13 17:05:14 +09:00
|
|
|
import { ElementConfigModal } from "./ElementConfigModal";
|
2025-10-15 11:17:09 +09:00
|
|
|
import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal";
|
2025-10-17 09:49:02 +09:00
|
|
|
import { TodoWidgetConfigModal } from "./widgets/TodoWidgetConfigModal";
|
2025-10-16 16:43:04 +09:00
|
|
|
import { DashboardSaveModal } from "./DashboardSaveModal";
|
2025-10-13 17:05:14 +09:00
|
|
|
import { DashboardElement, ElementType, ElementSubtype } from "./types";
|
2025-10-16 11:55:14 +09:00
|
|
|
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize } from "./gridUtils";
|
2025-10-16 09:55:14 +09:00
|
|
|
import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector";
|
2025-10-17 09:49:02 +09:00
|
|
|
import { DashboardProvider } from "@/contexts/DashboardContext";
|
2025-10-16 16:43:04 +09:00
|
|
|
import { useMenu } from "@/contexts/MenuContext";
|
|
|
|
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
2025-10-16 16:51:24 +09:00
|
|
|
import {
|
|
|
|
|
AlertDialog,
|
|
|
|
|
AlertDialogAction,
|
|
|
|
|
AlertDialogCancel,
|
|
|
|
|
AlertDialogContent,
|
|
|
|
|
AlertDialogDescription,
|
|
|
|
|
AlertDialogFooter,
|
|
|
|
|
AlertDialogHeader,
|
|
|
|
|
AlertDialogTitle,
|
|
|
|
|
} from "@/components/ui/alert-dialog";
|
2025-10-16 16:43:04 +09:00
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { CheckCircle2 } from "lucide-react";
|
2025-09-30 13:23:22 +09:00
|
|
|
|
2025-10-15 17:11:26 +09:00
|
|
|
interface DashboardDesignerProps {
|
|
|
|
|
dashboardId?: string;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-30 13:23:22 +09:00
|
|
|
/**
|
|
|
|
|
* 대시보드 설계 도구 메인 컴포넌트
|
|
|
|
|
* - 드래그 앤 드롭으로 차트/위젯 배치
|
2025-10-13 17:05:14 +09:00
|
|
|
* - 그리드 기반 레이아웃 (12 컬럼)
|
2025-09-30 13:23:22 +09:00
|
|
|
* - 요소 이동, 크기 조절, 삭제 기능
|
|
|
|
|
* - 레이아웃 저장/불러오기 기능
|
|
|
|
|
*/
|
2025-10-15 17:11:26 +09:00
|
|
|
export default function DashboardDesigner({ dashboardId: initialDashboardId }: DashboardDesignerProps = {}) {
|
2025-10-14 17:25:07 +09:00
|
|
|
const router = useRouter();
|
2025-10-16 16:43:04 +09:00
|
|
|
const { refreshMenus } = useMenu();
|
2025-09-30 13:23:22 +09:00
|
|
|
const [elements, setElements] = useState<DashboardElement[]>([]);
|
|
|
|
|
const [selectedElement, setSelectedElement] = useState<string | null>(null);
|
|
|
|
|
const [elementCounter, setElementCounter] = useState(0);
|
|
|
|
|
const [configModalElement, setConfigModalElement] = useState<DashboardElement | null>(null);
|
2025-10-15 17:11:26 +09:00
|
|
|
const [dashboardId, setDashboardId] = useState<string | null>(initialDashboardId || null);
|
2025-10-13 17:05:14 +09:00
|
|
|
const [dashboardTitle, setDashboardTitle] = useState<string>("");
|
2025-10-01 12:06:24 +09:00
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
2025-10-14 11:55:31 +09:00
|
|
|
const [canvasBackgroundColor, setCanvasBackgroundColor] = useState<string>("#f9fafb");
|
2025-09-30 13:23:22 +09:00
|
|
|
const canvasRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
2025-10-16 16:43:04 +09:00
|
|
|
// 저장 모달 상태
|
|
|
|
|
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
|
|
|
|
const [dashboardDescription, setDashboardDescription] = useState<string>("");
|
|
|
|
|
const [successModalOpen, setSuccessModalOpen] = useState(false);
|
2025-10-16 16:51:24 +09:00
|
|
|
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
|
2025-10-16 14:53:06 +09:00
|
|
|
|
2025-10-16 10:27:43 +09:00
|
|
|
// 화면 해상도 자동 감지
|
2025-10-16 09:55:14 +09:00
|
|
|
const [screenResolution] = useState<Resolution>(() => detectScreenResolution());
|
|
|
|
|
const [resolution, setResolution] = useState<Resolution>(screenResolution);
|
|
|
|
|
|
2025-10-16 11:55:14 +09:00
|
|
|
// resolution 변경 감지 및 요소 자동 조정
|
|
|
|
|
const handleResolutionChange = useCallback(
|
|
|
|
|
(newResolution: Resolution) => {
|
|
|
|
|
setResolution((prev) => {
|
|
|
|
|
// 이전 해상도와 새 해상도의 캔버스 너비 비율 계산
|
|
|
|
|
const oldConfig = RESOLUTIONS[prev];
|
|
|
|
|
const newConfig = RESOLUTIONS[newResolution];
|
|
|
|
|
const widthRatio = newConfig.width / oldConfig.width;
|
|
|
|
|
|
|
|
|
|
// 요소들의 위치와 크기를 비율에 맞춰 조정
|
|
|
|
|
if (widthRatio !== 1 && elements.length > 0) {
|
|
|
|
|
// 새 해상도의 셀 크기 계산
|
|
|
|
|
const newCellSize = calculateCellSize(newConfig.width);
|
|
|
|
|
|
|
|
|
|
const adjustedElements = elements.map((el) => {
|
|
|
|
|
// 비율에 맞춰 조정 (X와 너비만)
|
|
|
|
|
const scaledX = el.position.x * widthRatio;
|
|
|
|
|
const scaledWidth = el.size.width * widthRatio;
|
|
|
|
|
|
|
|
|
|
// 그리드에 스냅 (X, Y, 너비, 높이 모두)
|
|
|
|
|
const snappedX = snapToGrid(scaledX, newCellSize);
|
|
|
|
|
const snappedY = snapToGrid(el.position.y, newCellSize);
|
|
|
|
|
const snappedWidth = snapSizeToGrid(scaledWidth, 2, newCellSize);
|
|
|
|
|
const snappedHeight = snapSizeToGrid(el.size.height, 2, newCellSize);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...el,
|
|
|
|
|
position: {
|
|
|
|
|
x: snappedX,
|
|
|
|
|
y: snappedY,
|
|
|
|
|
},
|
|
|
|
|
size: {
|
|
|
|
|
width: snappedWidth,
|
|
|
|
|
height: snappedHeight,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setElements(adjustedElements);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return newResolution;
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[elements],
|
|
|
|
|
);
|
2025-10-16 11:29:45 +09:00
|
|
|
|
2025-10-16 10:05:43 +09:00
|
|
|
// 현재 해상도 설정 (안전하게 기본값 제공)
|
|
|
|
|
const canvasConfig = RESOLUTIONS[resolution] || RESOLUTIONS.fhd;
|
2025-10-16 09:55:14 +09:00
|
|
|
|
2025-10-16 11:09:11 +09:00
|
|
|
// 캔버스 높이 동적 계산 (요소들의 최하단 위치 기준)
|
|
|
|
|
const calculateCanvasHeight = useCallback(() => {
|
|
|
|
|
if (elements.length === 0) {
|
|
|
|
|
return canvasConfig.height; // 기본 높이
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 모든 요소의 최하단 y 좌표 계산
|
|
|
|
|
const maxBottomY = Math.max(...elements.map((el) => el.position.y + el.size.height));
|
|
|
|
|
|
|
|
|
|
// 최소 높이는 기본 높이, 요소가 아래로 내려가면 자동으로 늘어남
|
|
|
|
|
// 패딩 추가 (100px 여유)
|
|
|
|
|
return Math.max(canvasConfig.height, maxBottomY + 100);
|
|
|
|
|
}, [elements, canvasConfig.height]);
|
|
|
|
|
|
|
|
|
|
const dynamicCanvasHeight = calculateCanvasHeight();
|
|
|
|
|
|
2025-10-15 17:11:26 +09:00
|
|
|
// 대시보드 ID가 props로 전달되면 로드
|
2025-10-01 12:06:24 +09:00
|
|
|
React.useEffect(() => {
|
2025-10-15 17:11:26 +09:00
|
|
|
if (initialDashboardId) {
|
|
|
|
|
loadDashboard(initialDashboardId);
|
2025-10-01 12:06:24 +09:00
|
|
|
}
|
2025-10-15 17:11:26 +09:00
|
|
|
}, [initialDashboardId]);
|
2025-10-01 12:06:24 +09:00
|
|
|
|
|
|
|
|
// 대시보드 데이터 로드
|
|
|
|
|
const loadDashboard = async (id: string) => {
|
|
|
|
|
setIsLoading(true);
|
|
|
|
|
try {
|
2025-10-13 17:05:14 +09:00
|
|
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
2025-10-01 12:06:24 +09:00
|
|
|
const dashboard = await dashboardApi.getDashboard(id);
|
2025-10-13 17:05:14 +09:00
|
|
|
|
2025-10-01 12:06:24 +09:00
|
|
|
// 대시보드 정보 설정
|
|
|
|
|
setDashboardId(dashboard.id);
|
|
|
|
|
setDashboardTitle(dashboard.title);
|
2025-10-13 17:05:14 +09:00
|
|
|
|
2025-10-16 10:27:43 +09:00
|
|
|
// 저장된 설정 복원
|
2025-10-16 16:51:24 +09:00
|
|
|
const settings = (dashboard as { settings?: { resolution?: Resolution; backgroundColor?: string } }).settings;
|
|
|
|
|
if (settings?.resolution) {
|
|
|
|
|
setResolution(settings.resolution);
|
2025-10-16 10:27:43 +09:00
|
|
|
}
|
|
|
|
|
|
2025-10-16 16:51:24 +09:00
|
|
|
if (settings?.backgroundColor) {
|
|
|
|
|
setCanvasBackgroundColor(settings.backgroundColor);
|
2025-10-16 10:27:43 +09:00
|
|
|
}
|
|
|
|
|
|
2025-10-01 12:06:24 +09:00
|
|
|
// 요소들 설정
|
|
|
|
|
if (dashboard.elements && dashboard.elements.length > 0) {
|
|
|
|
|
setElements(dashboard.elements);
|
2025-10-13 17:05:14 +09:00
|
|
|
|
2025-10-01 12:06:24 +09:00
|
|
|
// elementCounter를 가장 큰 ID 번호로 설정
|
|
|
|
|
const maxId = dashboard.elements.reduce((max, el) => {
|
|
|
|
|
const match = el.id.match(/element-(\d+)/);
|
|
|
|
|
if (match) {
|
|
|
|
|
const num = parseInt(match[1]);
|
|
|
|
|
return num > max ? num : max;
|
|
|
|
|
}
|
|
|
|
|
return max;
|
|
|
|
|
}, 0);
|
|
|
|
|
setElementCounter(maxId);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
2025-10-13 17:05:14 +09:00
|
|
|
alert(
|
|
|
|
|
"대시보드를 불러오는 중 오류가 발생했습니다.\n\n" +
|
|
|
|
|
(error instanceof Error ? error.message : "알 수 없는 오류"),
|
|
|
|
|
);
|
2025-10-01 12:06:24 +09:00
|
|
|
} finally {
|
|
|
|
|
setIsLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-16 09:55:14 +09:00
|
|
|
// 새로운 요소 생성 (동적 그리드 기반 기본 크기)
|
2025-10-13 17:05:14 +09:00
|
|
|
const createElement = useCallback(
|
|
|
|
|
(type: ElementType, subtype: ElementSubtype, x: number, y: number) => {
|
2025-10-16 09:55:14 +09:00
|
|
|
// 좌표 유효성 검사
|
|
|
|
|
if (isNaN(x) || isNaN(y)) {
|
|
|
|
|
console.error("Invalid coordinates:", { x, y });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-14 10:48:17 +09:00
|
|
|
// 기본 크기 설정
|
|
|
|
|
let defaultCells = { width: 2, height: 2 }; // 기본 위젯 크기
|
|
|
|
|
|
|
|
|
|
if (type === "chart") {
|
|
|
|
|
defaultCells = { width: 4, height: 3 }; // 차트
|
|
|
|
|
} else if (type === "widget" && subtype === "calendar") {
|
|
|
|
|
defaultCells = { width: 2, height: 3 }; // 달력 최소 크기
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-16 09:55:14 +09:00
|
|
|
// 현재 해상도에 맞는 셀 크기 계산
|
|
|
|
|
const cellSize = Math.floor((canvasConfig.width + GRID_CONFIG.GAP) / GRID_CONFIG.COLUMNS) - GRID_CONFIG.GAP;
|
|
|
|
|
const cellWithGap = cellSize + GRID_CONFIG.GAP;
|
2025-10-13 17:05:14 +09:00
|
|
|
|
|
|
|
|
const defaultWidth = defaultCells.width * cellWithGap - GRID_CONFIG.GAP;
|
|
|
|
|
const defaultHeight = defaultCells.height * cellWithGap - GRID_CONFIG.GAP;
|
|
|
|
|
|
2025-10-16 10:05:43 +09:00
|
|
|
// 크기 유효성 검사
|
|
|
|
|
if (isNaN(defaultWidth) || isNaN(defaultHeight) || defaultWidth <= 0 || defaultHeight <= 0) {
|
|
|
|
|
console.error("Invalid size calculated:", {
|
|
|
|
|
canvasConfig,
|
|
|
|
|
cellSize,
|
|
|
|
|
cellWithGap,
|
|
|
|
|
defaultCells,
|
|
|
|
|
defaultWidth,
|
|
|
|
|
defaultHeight,
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-13 17:05:14 +09:00
|
|
|
const newElement: DashboardElement = {
|
|
|
|
|
id: `element-${elementCounter + 1}`,
|
|
|
|
|
type,
|
|
|
|
|
subtype,
|
|
|
|
|
position: { x, y },
|
|
|
|
|
size: { width: defaultWidth, height: defaultHeight },
|
|
|
|
|
title: getElementTitle(type, subtype),
|
|
|
|
|
content: getElementContent(type, subtype),
|
|
|
|
|
};
|
2025-09-30 13:23:22 +09:00
|
|
|
|
2025-10-13 17:05:14 +09:00
|
|
|
setElements((prev) => [...prev, newElement]);
|
|
|
|
|
setElementCounter((prev) => prev + 1);
|
|
|
|
|
setSelectedElement(newElement.id);
|
|
|
|
|
},
|
2025-10-16 16:51:24 +09:00
|
|
|
[elementCounter, canvasConfig],
|
2025-10-16 09:55:14 +09:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 메뉴에서 요소 추가 시 (캔버스 중앙에 배치)
|
|
|
|
|
const addElementFromMenu = useCallback(
|
|
|
|
|
(type: ElementType, subtype: ElementSubtype) => {
|
|
|
|
|
// 캔버스 중앙 좌표 계산
|
|
|
|
|
const centerX = Math.floor(canvasConfig.width / 2);
|
|
|
|
|
const centerY = Math.floor(canvasConfig.height / 2);
|
|
|
|
|
|
|
|
|
|
// 좌표 유효성 확인
|
|
|
|
|
if (isNaN(centerX) || isNaN(centerY)) {
|
|
|
|
|
console.error("Invalid canvas config:", canvasConfig);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
createElement(type, subtype, centerX, centerY);
|
|
|
|
|
},
|
2025-10-16 16:51:24 +09:00
|
|
|
[canvasConfig, createElement],
|
2025-10-13 17:05:14 +09:00
|
|
|
);
|
2025-09-30 13:23:22 +09:00
|
|
|
|
|
|
|
|
// 요소 업데이트
|
|
|
|
|
const updateElement = useCallback((id: string, updates: Partial<DashboardElement>) => {
|
2025-10-13 17:05:14 +09:00
|
|
|
setElements((prev) => prev.map((el) => (el.id === id ? { ...el, ...updates } : el)));
|
2025-09-30 13:23:22 +09:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 요소 삭제
|
2025-10-13 17:05:14 +09:00
|
|
|
const removeElement = useCallback(
|
|
|
|
|
(id: string) => {
|
|
|
|
|
setElements((prev) => prev.filter((el) => el.id !== id));
|
|
|
|
|
if (selectedElement === id) {
|
|
|
|
|
setSelectedElement(null);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[selectedElement],
|
|
|
|
|
);
|
2025-09-30 13:23:22 +09:00
|
|
|
|
2025-10-16 16:51:24 +09:00
|
|
|
// 전체 삭제 확인 모달 열기
|
2025-09-30 13:23:22 +09:00
|
|
|
const clearCanvas = useCallback(() => {
|
2025-10-16 16:51:24 +09:00
|
|
|
setClearConfirmOpen(true);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 실제 초기화 실행
|
|
|
|
|
const handleClearConfirm = useCallback(() => {
|
|
|
|
|
setElements([]);
|
|
|
|
|
setSelectedElement(null);
|
|
|
|
|
setElementCounter(0);
|
|
|
|
|
setClearConfirmOpen(false);
|
2025-09-30 13:23:22 +09:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 요소 설정 모달 열기
|
|
|
|
|
const openConfigModal = useCallback((element: DashboardElement) => {
|
|
|
|
|
setConfigModalElement(element);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 요소 설정 모달 닫기
|
|
|
|
|
const closeConfigModal = useCallback(() => {
|
|
|
|
|
setConfigModalElement(null);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 요소 설정 저장
|
2025-10-13 17:05:14 +09:00
|
|
|
const saveElementConfig = useCallback(
|
|
|
|
|
(updatedElement: DashboardElement) => {
|
|
|
|
|
updateElement(updatedElement.id, updatedElement);
|
|
|
|
|
},
|
|
|
|
|
[updateElement],
|
|
|
|
|
);
|
2025-09-30 13:23:22 +09:00
|
|
|
|
2025-10-15 11:17:09 +09:00
|
|
|
// 리스트 위젯 설정 저장 (Partial 업데이트)
|
|
|
|
|
const saveListWidgetConfig = useCallback(
|
|
|
|
|
(updates: Partial<DashboardElement>) => {
|
|
|
|
|
if (configModalElement) {
|
|
|
|
|
updateElement(configModalElement.id, updates);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[configModalElement, updateElement],
|
|
|
|
|
);
|
|
|
|
|
|
2025-10-17 09:49:02 +09:00
|
|
|
const saveTodoWidgetConfig = useCallback(
|
|
|
|
|
(updates: Partial<DashboardElement>) => {
|
|
|
|
|
if (configModalElement) {
|
|
|
|
|
updateElement(configModalElement.id, updates);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[configModalElement, updateElement],
|
|
|
|
|
);
|
|
|
|
|
|
2025-09-30 13:23:22 +09:00
|
|
|
// 레이아웃 저장
|
2025-10-16 16:43:04 +09:00
|
|
|
const saveLayout = useCallback(() => {
|
2025-10-01 12:06:24 +09:00
|
|
|
if (elements.length === 0) {
|
2025-10-13 17:05:14 +09:00
|
|
|
alert("저장할 요소가 없습니다. 차트나 위젯을 추가해주세요.");
|
2025-10-01 12:06:24 +09:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-16 16:43:04 +09:00
|
|
|
// 저장 모달 열기
|
|
|
|
|
setSaveModalOpen(true);
|
|
|
|
|
}, [elements]);
|
|
|
|
|
|
|
|
|
|
// 저장 처리
|
|
|
|
|
const handleSave = useCallback(
|
|
|
|
|
async (data: {
|
|
|
|
|
title: string;
|
|
|
|
|
description: string;
|
|
|
|
|
assignToMenu: boolean;
|
|
|
|
|
menuType?: "admin" | "user";
|
|
|
|
|
menuId?: string;
|
|
|
|
|
}) => {
|
|
|
|
|
try {
|
|
|
|
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
|
|
|
|
|
|
|
|
|
const elementsData = elements.map((el) => ({
|
|
|
|
|
id: el.id,
|
|
|
|
|
type: el.type,
|
|
|
|
|
subtype: el.subtype,
|
|
|
|
|
position: el.position,
|
|
|
|
|
size: el.size,
|
|
|
|
|
title: el.title,
|
2025-10-16 18:10:22 +09:00
|
|
|
customTitle: el.customTitle,
|
|
|
|
|
showHeader: el.showHeader,
|
2025-10-16 16:43:04 +09:00
|
|
|
content: el.content,
|
|
|
|
|
dataSource: el.dataSource,
|
|
|
|
|
chartConfig: el.chartConfig,
|
|
|
|
|
listConfig: el.listConfig,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
let savedDashboard;
|
|
|
|
|
|
|
|
|
|
if (dashboardId) {
|
|
|
|
|
// 기존 대시보드 업데이트
|
|
|
|
|
const updateData = {
|
|
|
|
|
title: data.title,
|
|
|
|
|
description: data.description || undefined,
|
|
|
|
|
elements: elementsData,
|
|
|
|
|
settings: {
|
|
|
|
|
resolution,
|
|
|
|
|
backgroundColor: canvasBackgroundColor,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
savedDashboard = await dashboardApi.updateDashboard(dashboardId, updateData);
|
|
|
|
|
} else {
|
|
|
|
|
// 새 대시보드 생성
|
|
|
|
|
const dashboardData = {
|
|
|
|
|
title: data.title,
|
|
|
|
|
description: data.description || undefined,
|
|
|
|
|
isPublic: false,
|
|
|
|
|
elements: elementsData,
|
|
|
|
|
settings: {
|
|
|
|
|
resolution,
|
|
|
|
|
backgroundColor: canvasBackgroundColor,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
savedDashboard = await dashboardApi.createDashboard(dashboardData);
|
|
|
|
|
setDashboardId(savedDashboard.id);
|
|
|
|
|
}
|
2025-10-13 17:05:14 +09:00
|
|
|
|
2025-10-16 14:53:06 +09:00
|
|
|
setDashboardTitle(savedDashboard.title);
|
2025-10-16 16:43:04 +09:00
|
|
|
setDashboardDescription(data.description);
|
2025-10-16 14:53:06 +09:00
|
|
|
|
2025-10-16 16:43:04 +09:00
|
|
|
// 메뉴 할당 처리
|
|
|
|
|
if (data.assignToMenu && data.menuId) {
|
2025-10-16 14:53:06 +09:00
|
|
|
const { menuApi } = await import("@/lib/api/menu");
|
2025-10-13 17:05:14 +09:00
|
|
|
|
2025-10-16 14:53:06 +09:00
|
|
|
// 대시보드 URL 생성 (관리자 메뉴면 mode=admin 추가)
|
2025-10-16 16:43:04 +09:00
|
|
|
let dashboardUrl = `/dashboard/${savedDashboard.id}`;
|
|
|
|
|
if (data.menuType === "admin") {
|
2025-10-16 14:53:06 +09:00
|
|
|
dashboardUrl += "?mode=admin";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 메뉴 정보 가져오기
|
2025-10-16 16:43:04 +09:00
|
|
|
const menuResponse = await menuApi.getMenuInfo(data.menuId);
|
|
|
|
|
|
2025-10-16 14:53:06 +09:00
|
|
|
if (menuResponse.success && menuResponse.data) {
|
|
|
|
|
const menu = menuResponse.data;
|
|
|
|
|
|
2025-10-16 16:43:04 +09:00
|
|
|
const updateData = {
|
2025-10-16 14:53:06 +09:00
|
|
|
menuUrl: dashboardUrl,
|
|
|
|
|
parentObjId: menu.parent_obj_id || menu.PARENT_OBJ_ID || "0",
|
|
|
|
|
menuNameKor: menu.menu_name_kor || menu.MENU_NAME_KOR || "",
|
|
|
|
|
menuDesc: menu.menu_desc || menu.MENU_DESC || "",
|
|
|
|
|
seq: menu.seq || menu.SEQ || 1,
|
|
|
|
|
menuType: menu.menu_type || menu.MENU_TYPE || "1",
|
|
|
|
|
status: menu.status || menu.STATUS || "active",
|
|
|
|
|
companyCode: menu.company_code || menu.COMPANY_CODE || "",
|
|
|
|
|
langKey: menu.lang_key || menu.LANG_KEY || "",
|
2025-10-16 16:43:04 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 메뉴 URL 업데이트
|
|
|
|
|
await menuApi.updateMenu(data.menuId, updateData);
|
2025-10-16 14:53:06 +09:00
|
|
|
|
2025-10-16 16:43:04 +09:00
|
|
|
// 메뉴 목록 새로고침
|
|
|
|
|
await refreshMenus();
|
2025-10-16 14:53:06 +09:00
|
|
|
}
|
2025-10-01 12:06:24 +09:00
|
|
|
}
|
2025-10-16 14:53:06 +09:00
|
|
|
|
2025-10-16 16:43:04 +09:00
|
|
|
// 성공 모달 표시
|
|
|
|
|
setSuccessModalOpen(true);
|
2025-10-16 14:53:06 +09:00
|
|
|
} catch (error) {
|
2025-10-16 16:43:04 +09:00
|
|
|
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류";
|
|
|
|
|
alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}`);
|
|
|
|
|
throw error;
|
2025-10-01 12:06:24 +09:00
|
|
|
}
|
2025-10-16 14:53:06 +09:00
|
|
|
},
|
2025-10-16 16:51:24 +09:00
|
|
|
[elements, dashboardId, resolution, canvasBackgroundColor, refreshMenus],
|
2025-10-16 14:53:06 +09:00
|
|
|
);
|
2025-10-01 12:06:24 +09:00
|
|
|
|
|
|
|
|
// 로딩 중이면 로딩 화면 표시
|
|
|
|
|
if (isLoading) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-full items-center justify-center bg-gray-50">
|
|
|
|
|
<div className="text-center">
|
2025-10-13 17:05:14 +09:00
|
|
|
<div className="border-primary mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-4 border-t-transparent" />
|
2025-10-01 12:06:24 +09:00
|
|
|
<div className="text-lg font-medium text-gray-700">대시보드 로딩 중...</div>
|
2025-10-13 17:05:14 +09:00
|
|
|
<div className="mt-1 text-sm text-gray-500">잠시만 기다려주세요</div>
|
2025-10-01 12:06:24 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-09-30 13:23:22 +09:00
|
|
|
|
|
|
|
|
return (
|
2025-10-17 09:49:02 +09:00
|
|
|
<DashboardProvider>
|
|
|
|
|
<div className="flex h-full flex-col bg-gray-50">
|
|
|
|
|
{/* 상단 메뉴바 */}
|
|
|
|
|
<DashboardTopMenu
|
|
|
|
|
onSaveLayout={saveLayout}
|
|
|
|
|
onClearCanvas={clearCanvas}
|
|
|
|
|
dashboardTitle={dashboardTitle}
|
|
|
|
|
onAddElement={addElementFromMenu}
|
|
|
|
|
resolution={resolution}
|
|
|
|
|
onResolutionChange={handleResolutionChange}
|
|
|
|
|
currentScreenResolution={screenResolution}
|
|
|
|
|
backgroundColor={canvasBackgroundColor}
|
|
|
|
|
onBackgroundColorChange={setCanvasBackgroundColor}
|
|
|
|
|
/>
|
2025-10-16 09:55:14 +09:00
|
|
|
|
2025-10-17 13:44:51 +09:00
|
|
|
{/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */}
|
|
|
|
|
<div className="flex flex-1 items-start justify-center overflow-auto bg-gray-100 p-8">
|
|
|
|
|
<div
|
|
|
|
|
className="relative shadow-2xl"
|
|
|
|
|
style={{
|
|
|
|
|
width: `${canvasConfig.width}px`,
|
|
|
|
|
minHeight: `${canvasConfig.height}px`,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<DashboardCanvas
|
|
|
|
|
ref={canvasRef}
|
|
|
|
|
elements={elements}
|
|
|
|
|
selectedElement={selectedElement}
|
|
|
|
|
onCreateElement={createElement}
|
|
|
|
|
onUpdateElement={updateElement}
|
|
|
|
|
onRemoveElement={removeElement}
|
|
|
|
|
onSelectElement={setSelectedElement}
|
|
|
|
|
onConfigureElement={openConfigModal}
|
|
|
|
|
backgroundColor={canvasBackgroundColor}
|
|
|
|
|
canvasWidth={canvasConfig.width}
|
|
|
|
|
canvasHeight={dynamicCanvasHeight}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2025-10-13 18:09:20 +09:00
|
|
|
</div>
|
2025-09-30 13:23:22 +09:00
|
|
|
|
2025-10-17 13:44:51 +09:00
|
|
|
{/* 요소 설정 모달 */}
|
|
|
|
|
{configModalElement && (
|
|
|
|
|
<>
|
|
|
|
|
{configModalElement.type === "widget" && configModalElement.subtype === "list" ? (
|
|
|
|
|
<ListWidgetConfigModal
|
|
|
|
|
element={configModalElement}
|
|
|
|
|
isOpen={true}
|
|
|
|
|
onClose={closeConfigModal}
|
|
|
|
|
onSave={saveListWidgetConfig}
|
|
|
|
|
/>
|
|
|
|
|
) : configModalElement.type === "widget" && configModalElement.subtype === "todo" ? (
|
|
|
|
|
<TodoWidgetConfigModal
|
|
|
|
|
element={configModalElement}
|
|
|
|
|
isOpen={true}
|
|
|
|
|
onClose={closeConfigModal}
|
|
|
|
|
onSave={saveTodoWidgetConfig}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<ElementConfigModal
|
|
|
|
|
element={configModalElement}
|
|
|
|
|
isOpen={true}
|
|
|
|
|
onClose={closeConfigModal}
|
|
|
|
|
onSave={saveElementConfig}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 저장 모달 */}
|
|
|
|
|
<DashboardSaveModal
|
|
|
|
|
isOpen={saveModalOpen}
|
|
|
|
|
onClose={() => setSaveModalOpen(false)}
|
|
|
|
|
onSave={handleSave}
|
|
|
|
|
initialTitle={dashboardTitle}
|
|
|
|
|
initialDescription={dashboardDescription}
|
|
|
|
|
isEditing={!!dashboardId}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* 저장 성공 모달 */}
|
|
|
|
|
<Dialog
|
|
|
|
|
open={successModalOpen}
|
|
|
|
|
onOpenChange={() => {
|
|
|
|
|
setSuccessModalOpen(false);
|
|
|
|
|
router.push("/admin/dashboard");
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<DialogContent className="sm:max-w-md">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
|
|
|
|
<CheckCircle2 className="h-6 w-6 text-green-600" />
|
|
|
|
|
</div>
|
|
|
|
|
<DialogTitle className="text-center">저장 완료</DialogTitle>
|
|
|
|
|
<DialogDescription className="text-center">대시보드가 성공적으로 저장되었습니다.</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
<div className="flex justify-center pt-4">
|
|
|
|
|
<Button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setSuccessModalOpen(false);
|
|
|
|
|
router.push("/admin/dashboard");
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
확인
|
|
|
|
|
</Button>
|
2025-10-16 16:43:04 +09:00
|
|
|
</div>
|
2025-10-17 13:44:51 +09:00
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
{/* 초기화 확인 모달 */}
|
|
|
|
|
<AlertDialog open={clearConfirmOpen} onOpenChange={setClearConfirmOpen}>
|
|
|
|
|
<AlertDialogContent>
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
<AlertDialogTitle>캔버스 초기화</AlertDialogTitle>
|
|
|
|
|
<AlertDialogDescription>
|
|
|
|
|
모든 요소가 삭제됩니다. 이 작업은 되돌릴 수 없습니다.
|
|
|
|
|
<br />
|
|
|
|
|
계속하시겠습니까?
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
<AlertDialogFooter>
|
|
|
|
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
|
|
|
|
<AlertDialogAction onClick={handleClearConfirm} className="bg-red-600 hover:bg-red-700">
|
|
|
|
|
초기화
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
</AlertDialog>
|
2025-10-17 09:49:02 +09:00
|
|
|
</div>
|
|
|
|
|
</DashboardProvider>
|
2025-09-30 13:23:22 +09:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 요소 제목 생성 헬퍼 함수
|
|
|
|
|
function getElementTitle(type: ElementType, subtype: ElementSubtype): string {
|
2025-10-13 17:05:14 +09:00
|
|
|
if (type === "chart") {
|
2025-09-30 13:23:22 +09:00
|
|
|
switch (subtype) {
|
2025-10-13 17:05:14 +09:00
|
|
|
case "bar":
|
|
|
|
|
return "📊 바 차트";
|
2025-10-14 16:49:57 +09:00
|
|
|
case "horizontal-bar":
|
|
|
|
|
return "📊 수평 바 차트";
|
2025-10-13 17:05:14 +09:00
|
|
|
case "pie":
|
|
|
|
|
return "🥧 원형 차트";
|
|
|
|
|
case "line":
|
|
|
|
|
return "📈 꺾은선 차트";
|
|
|
|
|
default:
|
|
|
|
|
return "📊 차트";
|
2025-09-30 13:23:22 +09:00
|
|
|
}
|
2025-10-13 17:05:14 +09:00
|
|
|
} else if (type === "widget") {
|
2025-09-30 13:23:22 +09:00
|
|
|
switch (subtype) {
|
2025-10-13 17:05:14 +09:00
|
|
|
case "exchange":
|
|
|
|
|
return "💱 환율 위젯";
|
|
|
|
|
case "weather":
|
|
|
|
|
return "☁️ 날씨 위젯";
|
2025-10-14 09:41:33 +09:00
|
|
|
case "clock":
|
|
|
|
|
return "⏰ 시계 위젯";
|
2025-10-14 10:34:18 +09:00
|
|
|
case "calculator":
|
|
|
|
|
return "🧮 계산기 위젯";
|
2025-10-14 11:55:31 +09:00
|
|
|
case "vehicle-map":
|
|
|
|
|
return "🚚 차량 위치 지도";
|
2025-10-14 10:48:17 +09:00
|
|
|
case "calendar":
|
2025-10-17 15:26:21 +09:00
|
|
|
return "달력 위젯";
|
2025-10-14 11:26:53 +09:00
|
|
|
case "driver-management":
|
2025-10-17 15:26:21 +09:00
|
|
|
return "기사 관리 위젯";
|
2025-10-15 11:17:09 +09:00
|
|
|
case "list":
|
2025-10-17 15:26:21 +09:00
|
|
|
return "리스트 위젯";
|
|
|
|
|
case "yard-management-3d":
|
|
|
|
|
return "야드 관리 3D";
|
2025-10-13 17:05:14 +09:00
|
|
|
default:
|
2025-10-17 15:26:21 +09:00
|
|
|
return "위젯";
|
2025-09-30 13:23:22 +09:00
|
|
|
}
|
|
|
|
|
}
|
2025-10-13 17:05:14 +09:00
|
|
|
return "요소";
|
2025-09-30 13:23:22 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 요소 내용 생성 헬퍼 함수
|
|
|
|
|
function getElementContent(type: ElementType, subtype: ElementSubtype): string {
|
2025-10-13 17:05:14 +09:00
|
|
|
if (type === "chart") {
|
2025-09-30 13:23:22 +09:00
|
|
|
switch (subtype) {
|
2025-10-13 17:05:14 +09:00
|
|
|
case "bar":
|
|
|
|
|
return "바 차트가 여기에 표시됩니다";
|
2025-10-14 16:49:57 +09:00
|
|
|
case "horizontal-bar":
|
|
|
|
|
return "수평 바 차트가 여기에 표시됩니다";
|
2025-10-13 17:05:14 +09:00
|
|
|
case "pie":
|
|
|
|
|
return "원형 차트가 여기에 표시됩니다";
|
|
|
|
|
case "line":
|
|
|
|
|
return "꺾은선 차트가 여기에 표시됩니다";
|
|
|
|
|
default:
|
|
|
|
|
return "차트가 여기에 표시됩니다";
|
2025-09-30 13:23:22 +09:00
|
|
|
}
|
2025-10-13 17:05:14 +09:00
|
|
|
} else if (type === "widget") {
|
2025-09-30 13:23:22 +09:00
|
|
|
switch (subtype) {
|
2025-10-13 17:05:14 +09:00
|
|
|
case "exchange":
|
|
|
|
|
return "USD: ₩1,320\nJPY: ₩900\nEUR: ₩1,450";
|
|
|
|
|
case "weather":
|
|
|
|
|
return "서울\n23°C\n구름 많음";
|
2025-10-14 09:41:33 +09:00
|
|
|
case "clock":
|
|
|
|
|
return "clock";
|
2025-10-14 10:34:18 +09:00
|
|
|
case "calculator":
|
|
|
|
|
return "calculator";
|
2025-10-14 11:55:31 +09:00
|
|
|
case "vehicle-map":
|
|
|
|
|
return "vehicle-map";
|
2025-10-14 10:48:17 +09:00
|
|
|
case "calendar":
|
|
|
|
|
return "calendar";
|
2025-10-14 11:26:53 +09:00
|
|
|
case "driver-management":
|
|
|
|
|
return "driver-management";
|
2025-10-15 11:17:09 +09:00
|
|
|
case "list":
|
|
|
|
|
return "list-widget";
|
2025-10-17 15:26:21 +09:00
|
|
|
case "yard-management-3d":
|
|
|
|
|
return "yard-3d";
|
2025-10-13 17:05:14 +09:00
|
|
|
default:
|
|
|
|
|
return "위젯 내용이 여기에 표시됩니다";
|
2025-09-30 13:23:22 +09:00
|
|
|
}
|
|
|
|
|
}
|
2025-10-13 17:05:14 +09:00
|
|
|
return "내용이 여기에 표시됩니다";
|
2025-09-30 13:23:22 +09:00
|
|
|
}
|