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, useRef, useCallback, useEffect } from "react";
|
|
|
|
|
import { DashboardCanvas } from "./DashboardCanvas";
|
|
|
|
|
import { DashboardSidebar } from "./DashboardSidebar";
|
|
|
|
|
import { DashboardToolbar } from "./DashboardToolbar";
|
|
|
|
|
import { ElementConfigModal } from "./ElementConfigModal";
|
|
|
|
|
import { DashboardElement, ElementType, ElementSubtype } from "./types";
|
|
|
|
|
import { GRID_CONFIG } from "./gridUtils";
|
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
|
|
|
* - 요소 이동, 크기 조절, 삭제 기능
|
|
|
|
|
* - 레이아웃 저장/불러오기 기능
|
|
|
|
|
*/
|
|
|
|
|
export default function DashboardDesigner() {
|
|
|
|
|
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-01 12:06:24 +09:00
|
|
|
const [dashboardId, setDashboardId] = useState<string | null>(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-09-30 13:23:22 +09:00
|
|
|
const canvasRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
2025-10-01 12:06:24 +09:00
|
|
|
// URL 파라미터에서 대시보드 ID 읽기 및 데이터 로드
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
const params = new URLSearchParams(window.location.search);
|
2025-10-13 17:05:14 +09:00
|
|
|
const loadId = params.get("load");
|
|
|
|
|
|
2025-10-01 12:06:24 +09:00
|
|
|
if (loadId) {
|
|
|
|
|
loadDashboard(loadId);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 대시보드 데이터 로드
|
|
|
|
|
const loadDashboard = async (id: string) => {
|
|
|
|
|
setIsLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
// console.log('🔄 대시보드 로딩:', id);
|
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
|
|
|
// console.log('✅ 대시보드 로딩 완료:', dashboard);
|
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-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) {
|
|
|
|
|
// console.error('❌ 대시보드 로딩 오류:', 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-13 18:09:20 +09:00
|
|
|
// 새로운 요소 생성 (고정 그리드 기반 기본 크기)
|
2025-10-13 17:05:14 +09:00
|
|
|
const createElement = useCallback(
|
|
|
|
|
(type: ElementType, subtype: ElementSubtype, x: number, y: number) => {
|
|
|
|
|
// 기본 크기: 차트는 4x3 셀, 위젯은 2x2 셀
|
|
|
|
|
const defaultCells = type === "chart" ? { width: 4, height: 3 } : { width: 2, height: 2 };
|
2025-10-13 18:09:20 +09:00
|
|
|
const cellWithGap = GRID_CONFIG.CELL_SIZE + 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;
|
|
|
|
|
|
|
|
|
|
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-13 18:09:20 +09:00
|
|
|
[elementCounter],
|
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
|
|
|
|
|
|
|
|
// 전체 삭제
|
|
|
|
|
const clearCanvas = useCallback(() => {
|
2025-10-13 17:05:14 +09:00
|
|
|
if (window.confirm("모든 요소를 삭제하시겠습니까?")) {
|
2025-09-30 13:23:22 +09:00
|
|
|
setElements([]);
|
|
|
|
|
setSelectedElement(null);
|
|
|
|
|
setElementCounter(0);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 요소 설정 모달 열기
|
|
|
|
|
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-01 12:06:24 +09:00
|
|
|
const saveLayout = useCallback(async () => {
|
|
|
|
|
if (elements.length === 0) {
|
2025-10-13 17:05:14 +09:00
|
|
|
alert("저장할 요소가 없습니다. 차트나 위젯을 추가해주세요.");
|
2025-10-01 12:06:24 +09:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// 실제 API 호출
|
2025-10-13 17:05:14 +09:00
|
|
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
|
|
|
|
|
|
|
|
|
const elementsData = elements.map((el) => ({
|
2025-10-01 12:06:24 +09:00
|
|
|
id: el.id,
|
2025-09-30 13:23:22 +09:00
|
|
|
type: el.type,
|
|
|
|
|
subtype: el.subtype,
|
|
|
|
|
position: el.position,
|
|
|
|
|
size: el.size,
|
|
|
|
|
title: el.title,
|
2025-10-01 12:06:24 +09:00
|
|
|
content: el.content,
|
2025-09-30 13:23:22 +09:00
|
|
|
dataSource: el.dataSource,
|
2025-10-13 17:05:14 +09:00
|
|
|
chartConfig: el.chartConfig,
|
2025-10-01 12:06:24 +09:00
|
|
|
}));
|
2025-10-13 17:05:14 +09:00
|
|
|
|
2025-10-01 12:06:24 +09:00
|
|
|
let savedDashboard;
|
2025-10-13 17:05:14 +09:00
|
|
|
|
2025-10-01 12:06:24 +09:00
|
|
|
if (dashboardId) {
|
|
|
|
|
// 기존 대시보드 업데이트
|
|
|
|
|
// console.log('🔄 대시보드 업데이트:', dashboardId);
|
|
|
|
|
savedDashboard = await dashboardApi.updateDashboard(dashboardId, {
|
2025-10-13 17:05:14 +09:00
|
|
|
elements: elementsData,
|
2025-10-01 12:06:24 +09:00
|
|
|
});
|
2025-10-13 17:05:14 +09:00
|
|
|
|
2025-10-01 12:06:24 +09:00
|
|
|
alert(`대시보드 "${savedDashboard.title}"이 업데이트되었습니다!`);
|
2025-10-13 17:05:14 +09:00
|
|
|
|
2025-10-01 12:06:24 +09:00
|
|
|
// 뷰어 페이지로 이동
|
|
|
|
|
window.location.href = `/dashboard/${savedDashboard.id}`;
|
|
|
|
|
} else {
|
|
|
|
|
// 새 대시보드 생성
|
2025-10-13 17:05:14 +09:00
|
|
|
const title = prompt("대시보드 제목을 입력하세요:", "새 대시보드");
|
2025-10-01 12:06:24 +09:00
|
|
|
if (!title) return;
|
|
|
|
|
|
2025-10-13 17:05:14 +09:00
|
|
|
const description = prompt("대시보드 설명을 입력하세요 (선택사항):", "");
|
|
|
|
|
|
2025-10-01 12:06:24 +09:00
|
|
|
const dashboardData = {
|
|
|
|
|
title,
|
|
|
|
|
description: description || undefined,
|
|
|
|
|
isPublic: false,
|
2025-10-13 17:05:14 +09:00
|
|
|
elements: elementsData,
|
2025-10-01 12:06:24 +09:00
|
|
|
};
|
2025-10-13 17:05:14 +09:00
|
|
|
|
2025-10-01 12:06:24 +09:00
|
|
|
savedDashboard = await dashboardApi.createDashboard(dashboardData);
|
2025-10-13 17:05:14 +09:00
|
|
|
|
2025-10-01 12:06:24 +09:00
|
|
|
// console.log('✅ 대시보드 생성 완료:', savedDashboard);
|
2025-10-13 17:05:14 +09:00
|
|
|
|
2025-10-01 12:06:24 +09:00
|
|
|
const viewDashboard = confirm(`대시보드 "${title}"이 저장되었습니다!\n\n지금 확인해보시겠습니까?`);
|
|
|
|
|
if (viewDashboard) {
|
|
|
|
|
window.location.href = `/dashboard/${savedDashboard.id}`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// console.error('❌ 저장 오류:', error);
|
2025-10-13 17:05:14 +09:00
|
|
|
|
|
|
|
|
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류";
|
2025-10-01 12:06:24 +09:00
|
|
|
alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}\n\n관리자에게 문의하세요.`);
|
|
|
|
|
}
|
|
|
|
|
}, [elements, dashboardId]);
|
|
|
|
|
|
|
|
|
|
// 로딩 중이면 로딩 화면 표시
|
|
|
|
|
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 (
|
|
|
|
|
<div className="flex h-full bg-gray-50">
|
|
|
|
|
{/* 캔버스 영역 */}
|
2025-10-13 18:09:20 +09:00
|
|
|
<div className="relative flex-1 overflow-auto border-r-2 border-gray-300 bg-gray-100">
|
2025-10-01 12:06:24 +09:00
|
|
|
{/* 편집 중인 대시보드 표시 */}
|
|
|
|
|
{dashboardTitle && (
|
2025-10-14 09:41:33 +09:00
|
|
|
<div className="bg-accent0 absolute left-6 top-6 z-10 rounded-lg px-3 py-1 text-sm font-medium text-white shadow-lg">
|
2025-10-01 12:06:24 +09:00
|
|
|
📝 편집 중: {dashboardTitle}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-10-13 17:05:14 +09:00
|
|
|
|
|
|
|
|
<DashboardToolbar onClearCanvas={clearCanvas} onSaveLayout={saveLayout} />
|
2025-10-13 18:09:20 +09:00
|
|
|
|
|
|
|
|
{/* 캔버스 중앙 정렬 컨테이너 */}
|
|
|
|
|
<div className="flex justify-center p-4">
|
|
|
|
|
<DashboardCanvas
|
|
|
|
|
ref={canvasRef}
|
|
|
|
|
elements={elements}
|
|
|
|
|
selectedElement={selectedElement}
|
|
|
|
|
onCreateElement={createElement}
|
|
|
|
|
onUpdateElement={updateElement}
|
|
|
|
|
onRemoveElement={removeElement}
|
|
|
|
|
onSelectElement={setSelectedElement}
|
|
|
|
|
onConfigureElement={openConfigModal}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2025-09-30 13:23:22 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 사이드바 */}
|
|
|
|
|
<DashboardSidebar />
|
|
|
|
|
|
|
|
|
|
{/* 요소 설정 모달 */}
|
|
|
|
|
{configModalElement && (
|
|
|
|
|
<ElementConfigModal
|
|
|
|
|
element={configModalElement}
|
|
|
|
|
isOpen={true}
|
|
|
|
|
onClose={closeConfigModal}
|
|
|
|
|
onSave={saveElementConfig}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 요소 제목 생성 헬퍼 함수
|
|
|
|
|
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 "📊 바 차트";
|
|
|
|
|
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-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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 요소 내용 생성 헬퍼 함수
|
|
|
|
|
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 "바 차트가 여기에 표시됩니다";
|
|
|
|
|
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-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
|
|
|
}
|