Merge pull request '대시보드 그리드 스냅 적용' (#92) from feature/dashboard into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/92
This commit is contained in:
hyeonsu 2025-10-13 18:14:20 +09:00
commit cabaada5b8
6 changed files with 865 additions and 361 deletions

View File

@ -1,12 +1,14 @@
'use client'; "use client";
import React, { useState, useCallback, useRef, useEffect } from 'react'; import React, { useState, useCallback, useRef, useEffect } from "react";
import { DashboardElement, QueryResult } from './types'; import { DashboardElement, QueryResult } from "./types";
import { ChartRenderer } from './charts/ChartRenderer'; import { ChartRenderer } from "./charts/ChartRenderer";
import { snapToGrid, snapSizeToGrid, GRID_CONFIG } from "./gridUtils";
interface CanvasElementProps { interface CanvasElementProps {
element: DashboardElement; element: DashboardElement;
isSelected: boolean; isSelected: boolean;
cellSize: number;
onUpdate: (id: string, updates: Partial<DashboardElement>) => void; onUpdate: (id: string, updates: Partial<DashboardElement>) => void;
onRemove: (id: string) => void; onRemove: (id: string) => void;
onSelect: (id: string | null) => void; onSelect: (id: string | null) => void;
@ -15,145 +17,200 @@ interface CanvasElementProps {
/** /**
* *
* - * - ( )
* - * - ( )
* - * -
*/ */
export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelect, onConfigure }: CanvasElementProps) { export function CanvasElement({
element,
isSelected,
cellSize,
onUpdate,
onRemove,
onSelect,
onConfigure,
}: CanvasElementProps) {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0, elementX: 0, elementY: 0 }); const [dragStart, setDragStart] = useState({ x: 0, y: 0, elementX: 0, elementY: 0 });
const [resizeStart, setResizeStart] = useState({ const [resizeStart, setResizeStart] = useState({
x: 0, y: 0, width: 0, height: 0, elementX: 0, elementY: 0, handle: '' x: 0,
y: 0,
width: 0,
height: 0,
elementX: 0,
elementY: 0,
handle: "",
}); });
const [chartData, setChartData] = useState<QueryResult | null>(null); const [chartData, setChartData] = useState<QueryResult | null>(null);
const [isLoadingData, setIsLoadingData] = useState(false); const [isLoadingData, setIsLoadingData] = useState(false);
const elementRef = useRef<HTMLDivElement>(null); 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) => { const handleMouseDown = useCallback(
// 닫기 버튼이나 리사이즈 핸들 클릭 시 무시 (e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('.element-close, .resize-handle')) { // 닫기 버튼이나 리사이즈 핸들 클릭 시 무시
return; if ((e.target as HTMLElement).closest(".element-close, .resize-handle")) {
} return;
onSelect(element.id);
setIsDragging(true);
setDragStart({
x: e.clientX,
y: e.clientY,
elementX: element.position.x,
elementY: element.position.y
});
e.preventDefault();
}, [element.id, element.position.x, element.position.y, onSelect]);
// 리사이즈 핸들 마우스다운
const handleResizeMouseDown = useCallback((e: React.MouseEvent, handle: string) => {
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 deltaX = e.clientX - dragStart.x;
const deltaY = e.clientY - dragStart.y;
onUpdate(element.id, {
position: {
x: Math.max(0, dragStart.elementX + deltaX),
y: Math.max(0, dragStart.elementY + deltaY)
}
});
} 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;
switch (resizeStart.handle) {
case 'se': // 오른쪽 아래
newWidth = Math.max(150, resizeStart.width + deltaX);
newHeight = Math.max(150, resizeStart.height + deltaY);
break;
case 'sw': // 왼쪽 아래
newWidth = Math.max(150, resizeStart.width - deltaX);
newHeight = Math.max(150, resizeStart.height + deltaY);
newX = resizeStart.elementX + deltaX;
break;
case 'ne': // 오른쪽 위
newWidth = Math.max(150, resizeStart.width + deltaX);
newHeight = Math.max(150, resizeStart.height - deltaY);
newY = resizeStart.elementY + deltaY;
break;
case 'nw': // 왼쪽 위
newWidth = Math.max(150, resizeStart.width - deltaX);
newHeight = Math.max(150, resizeStart.height - deltaY);
newX = resizeStart.elementX + deltaX;
newY = resizeStart.elementY + deltaY;
break;
} }
onUpdate(element.id, { onSelect(element.id);
position: { x: Math.max(0, newX), y: Math.max(0, newY) }, setIsDragging(true);
size: { width: newWidth, height: newHeight } setDragStart({
x: e.clientX,
y: e.clientY,
elementX: element.position.x,
elementY: element.position.y,
}); });
} e.preventDefault();
}, [isDragging, isResizing, dragStart, resizeStart, element.id, onUpdate]); },
[element.id, element.position.x, element.position.y, onSelect],
);
// 마우스 업 처리 // 리사이즈 핸들 마우스다운
const handleResizeMouseDown = useCallback(
(e: React.MouseEvent, handle: string) => {
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 deltaX = e.clientX - dragStart.x;
const deltaY = e.clientY - dragStart.y;
// 임시 위치 계산 (스냅 안 됨)
const rawX = Math.max(0, dragStart.elementX + deltaX);
const rawY = Math.max(0, dragStart.elementY + deltaY);
setTempPosition({ 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;
const minSize = GRID_CONFIG.CELL_SIZE * 2; // 최소 2셀
switch (resizeStart.handle) {
case "se": // 오른쪽 아래
newWidth = Math.max(minSize, resizeStart.width + deltaX);
newHeight = Math.max(minSize, resizeStart.height + deltaY);
break;
case "sw": // 왼쪽 아래
newWidth = Math.max(minSize, resizeStart.width - deltaX);
newHeight = Math.max(minSize, resizeStart.height + deltaY);
newX = resizeStart.elementX + deltaX;
break;
case "ne": // 오른쪽 위
newWidth = Math.max(minSize, resizeStart.width + deltaX);
newHeight = Math.max(minSize, resizeStart.height - deltaY);
newY = resizeStart.elementY + deltaY;
break;
case "nw": // 왼쪽 위
newWidth = Math.max(minSize, resizeStart.width - deltaX);
newHeight = Math.max(minSize, resizeStart.height - deltaY);
newX = resizeStart.elementX + deltaX;
newY = resizeStart.elementY + deltaY;
break;
}
// 임시 크기/위치 저장 (스냅 안 됨)
setTempPosition({ x: Math.max(0, newX), y: Math.max(0, newY) });
setTempSize({ width: newWidth, height: newHeight });
}
},
[isDragging, isResizing, dragStart, resizeStart],
);
// 마우스 업 처리 (그리드 스냅 적용)
const handleMouseUp = useCallback(() => { const handleMouseUp = useCallback(() => {
if (isDragging && tempPosition) {
// 드래그 종료 시 그리드에 스냅 (동적 셀 크기 사용)
const snappedX = snapToGrid(tempPosition.x, cellSize);
const snappedY = snapToGrid(tempPosition.y, cellSize);
onUpdate(element.id, {
position: { x: snappedX, y: snappedY },
});
setTempPosition(null);
}
if (isResizing && tempPosition && tempSize) {
// 리사이즈 종료 시 그리드에 스냅 (동적 셀 크기 사용)
const snappedX = snapToGrid(tempPosition.x, cellSize);
const snappedY = snapToGrid(tempPosition.y, cellSize);
const snappedWidth = snapSizeToGrid(tempSize.width, 2, cellSize);
const snappedHeight = snapSizeToGrid(tempSize.height, 2, cellSize);
onUpdate(element.id, {
position: { x: snappedX, y: snappedY },
size: { width: snappedWidth, height: snappedHeight },
});
setTempPosition(null);
setTempSize(null);
}
setIsDragging(false); setIsDragging(false);
setIsResizing(false); setIsResizing(false);
}, []); }, [isDragging, isResizing, tempPosition, tempSize, element.id, onUpdate, cellSize]);
// 전역 마우스 이벤트 등록 // 전역 마우스 이벤트 등록
React.useEffect(() => { React.useEffect(() => {
if (isDragging || isResizing) { if (isDragging || isResizing) {
document.addEventListener('mousemove', handleMouseMove); document.addEventListener("mousemove", handleMouseMove);
document.addEventListener('mouseup', handleMouseUp); document.addEventListener("mouseup", handleMouseUp);
return () => { return () => {
document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener("mouseup", handleMouseUp);
}; };
} }
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]); }, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
// 데이터 로딩 // 데이터 로딩
const loadChartData = useCallback(async () => { const loadChartData = useCallback(async () => {
if (!element.dataSource?.query || element.type !== 'chart') { if (!element.dataSource?.query || element.type !== "chart") {
return; return;
} }
setIsLoadingData(true); setIsLoadingData(true);
try { try {
// console.log('🔄 쿼리 실행 시작:', element.dataSource.query); // console.log('🔄 쿼리 실행 시작:', element.dataSource.query);
// 실제 API 호출 // 실제 API 호출
const { dashboardApi } = await import('@/lib/api/dashboard'); const { dashboardApi } = await import("@/lib/api/dashboard");
const result = await dashboardApi.executeQuery(element.dataSource.query); const result = await dashboardApi.executeQuery(element.dataSource.query);
// console.log('✅ 쿼리 실행 결과:', result); // console.log('✅ 쿼리 실행 결과:', result);
setChartData({ setChartData({
columns: result.columns || [], columns: result.columns || [],
rows: result.rows || [], rows: result.rows || [],
totalRows: result.rowCount || 0, totalRows: result.rowCount || 0,
executionTime: 0 executionTime: 0,
}); });
} catch (error) { } catch (error) {
// console.error('❌ 데이터 로딩 오류:', error); // console.error('❌ 데이터 로딩 오류:', error);
@ -185,51 +242,56 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
// 스타일 클래스 생성 // 스타일 클래스 생성
const getContentClass = () => { const getContentClass = () => {
if (element.type === 'chart') { if (element.type === "chart") {
switch (element.subtype) { switch (element.subtype) {
case 'bar': return 'bg-gradient-to-br from-indigo-400 to-purple-600'; case "bar":
case 'pie': return 'bg-gradient-to-br from-pink-400 to-red-500'; return "bg-gradient-to-br from-indigo-400 to-purple-600";
case 'line': return 'bg-gradient-to-br from-blue-400 to-cyan-400'; case "pie":
default: return 'bg-gray-200'; 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";
} }
} else if (element.type === 'widget') { } else if (element.type === "widget") {
switch (element.subtype) { switch (element.subtype) {
case 'exchange': return 'bg-gradient-to-br from-pink-400 to-yellow-400'; case "exchange":
case 'weather': return 'bg-gradient-to-br from-cyan-400 to-indigo-800'; return "bg-gradient-to-br from-pink-400 to-yellow-400";
default: return 'bg-gray-200'; case "weather":
return "bg-gradient-to-br from-cyan-400 to-indigo-800";
default:
return "bg-gray-200";
} }
} }
return 'bg-gray-200'; return "bg-gray-200";
}; };
// 드래그/리사이즈 중일 때는 임시 위치/크기 사용, 아니면 실제 값 사용
const displayPosition = tempPosition || element.position;
const displaySize = tempSize || element.size;
return ( return (
<div <div
ref={elementRef} ref={elementRef}
className={` 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"} `}
absolute bg-white border-2 rounded-lg shadow-lg
min-w-[150px] min-h-[150px] cursor-move
${isSelected ? 'border-green-500 shadow-green-200' : 'border-gray-600'}
`}
style={{ style={{
left: element.position.x, left: displayPosition.x,
top: element.position.y, top: displayPosition.y,
width: element.size.width, width: displaySize.width,
height: element.size.height height: displaySize.height,
padding: `${GRID_CONFIG.ELEMENT_PADDING}px`,
boxSizing: "border-box",
}} }}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
> >
{/* 헤더 */} {/* 헤더 */}
<div className="bg-gray-50 p-3 border-b border-gray-200 flex justify-between items-center cursor-move"> <div className="flex cursor-move items-center justify-between border-b border-gray-200 bg-gray-50 p-3">
<span className="font-bold text-sm text-gray-800">{element.title}</span> <span className="text-sm font-bold text-gray-800">{element.title}</span>
<div className="flex gap-1"> <div className="flex gap-1">
{/* 설정 버튼 */} {/* 설정 버튼 */}
{onConfigure && ( {onConfigure && (
<button <button
className=" className="hover:bg-accent0 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors duration-200 hover:text-white"
w-6 h-6 flex items-center justify-center
text-gray-400 hover:bg-accent0 hover:text-white
rounded transition-colors duration-200
"
onClick={() => onConfigure(element)} onClick={() => onConfigure(element)}
title="설정" title="설정"
> >
@ -238,11 +300,7 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
)} )}
{/* 삭제 버튼 */} {/* 삭제 버튼 */}
<button <button
className=" 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"
element-close w-6 h-6 flex items-center justify-center
text-gray-400 hover:bg-destructive/100 hover:text-white
rounded transition-colors duration-200
"
onClick={handleRemove} onClick={handleRemove}
title="삭제" title="삭제"
> >
@ -252,14 +310,14 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
</div> </div>
{/* 내용 */} {/* 내용 */}
<div className="h-[calc(100%-45px)] relative"> <div className="relative h-[calc(100%-45px)]">
{element.type === 'chart' ? ( {element.type === "chart" ? (
// 차트 렌더링 // 차트 렌더링
<div className="w-full h-full bg-white"> <div className="h-full w-full bg-white">
{isLoadingData ? ( {isLoadingData ? (
<div className="w-full h-full flex items-center justify-center text-gray-500"> <div className="flex h-full w-full items-center justify-center text-gray-500">
<div className="text-center"> <div className="text-center">
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-2" /> <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 className="text-sm"> ...</div>
</div> </div>
</div> </div>
@ -274,15 +332,13 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
</div> </div>
) : ( ) : (
// 위젯 렌더링 (기존 방식) // 위젯 렌더링 (기존 방식)
<div className={` <div
w-full h-full p-5 flex items-center justify-center className={`flex h-full w-full items-center justify-center p-5 text-center text-sm font-medium text-white ${getContentClass()} `}
text-sm text-white font-medium text-center >
${getContentClass()}
`}>
<div> <div>
<div className="text-4xl mb-2"> <div className="mb-2 text-4xl">
{element.type === 'widget' && element.subtype === 'exchange' && '💱'} {element.type === "widget" && element.subtype === "exchange" && "💱"}
{element.type === 'widget' && element.subtype === 'weather' && '☁️'} {element.type === "widget" && element.subtype === "weather" && "☁️"}
</div> </div>
<div className="whitespace-pre-line">{element.content}</div> <div className="whitespace-pre-line">{element.content}</div>
</div> </div>
@ -304,7 +360,7 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
} }
interface ResizeHandleProps { interface ResizeHandleProps {
position: 'nw' | 'ne' | 'sw' | 'se'; position: "nw" | "ne" | "sw" | "se";
onMouseDown: (e: React.MouseEvent, handle: string) => void; onMouseDown: (e: React.MouseEvent, handle: string) => void;
} }
@ -314,19 +370,20 @@ interface ResizeHandleProps {
function ResizeHandle({ position, onMouseDown }: ResizeHandleProps) { function ResizeHandle({ position, onMouseDown }: ResizeHandleProps) {
const getPositionClass = () => { const getPositionClass = () => {
switch (position) { switch (position) {
case 'nw': return 'top-[-5px] left-[-5px] cursor-nw-resize'; case "nw":
case 'ne': return 'top-[-5px] right-[-5px] cursor-ne-resize'; return "top-[-5px] left-[-5px] cursor-nw-resize";
case 'sw': return 'bottom-[-5px] left-[-5px] cursor-sw-resize'; case "ne":
case 'se': return 'bottom-[-5px] right-[-5px] cursor-se-resize'; 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 ( return (
<div <div
className={` className={`resize-handle absolute h-3 w-3 border border-white bg-green-500 ${getPositionClass()} `}
resize-handle absolute w-3 h-3 bg-green-500 border border-white
${getPositionClass()}
`}
onMouseDown={(e) => onMouseDown(e, position)} onMouseDown={(e) => onMouseDown(e, position)}
/> />
); );
@ -337,55 +394,55 @@ function ResizeHandle({ position, onMouseDown }: ResizeHandleProps) {
*/ */
function generateSampleData(query: string, chartType: string): QueryResult { function generateSampleData(query: string, chartType: string): QueryResult {
// 쿼리에서 키워드 추출하여 적절한 샘플 데이터 생성 // 쿼리에서 키워드 추출하여 적절한 샘플 데이터 생성
const isMonthly = query.toLowerCase().includes('month'); const isMonthly = query.toLowerCase().includes("month");
const isSales = query.toLowerCase().includes('sales') || query.toLowerCase().includes('매출'); const isSales = query.toLowerCase().includes("sales") || query.toLowerCase().includes("매출");
const isUsers = query.toLowerCase().includes('users') || query.toLowerCase().includes('사용자'); const isUsers = query.toLowerCase().includes("users") || query.toLowerCase().includes("사용자");
const isProducts = query.toLowerCase().includes('product') || query.toLowerCase().includes('상품'); const isProducts = query.toLowerCase().includes("product") || query.toLowerCase().includes("상품");
let columns: string[]; let columns: string[];
let rows: Record<string, any>[]; let rows: Record<string, any>[];
if (isMonthly && isSales) { if (isMonthly && isSales) {
// 월별 매출 데이터 // 월별 매출 데이터
columns = ['month', 'sales', 'order_count']; columns = ["month", "sales", "order_count"];
rows = [ rows = [
{ month: '2024-01', sales: 1200000, order_count: 45 }, { month: "2024-01", sales: 1200000, order_count: 45 },
{ month: '2024-02', sales: 1350000, order_count: 52 }, { month: "2024-02", sales: 1350000, order_count: 52 },
{ month: '2024-03', sales: 1180000, order_count: 41 }, { month: "2024-03", sales: 1180000, order_count: 41 },
{ month: '2024-04', sales: 1420000, order_count: 58 }, { month: "2024-04", sales: 1420000, order_count: 58 },
{ month: '2024-05', sales: 1680000, order_count: 67 }, { month: "2024-05", sales: 1680000, order_count: 67 },
{ month: '2024-06', sales: 1540000, order_count: 61 }, { month: "2024-06", sales: 1540000, order_count: 61 },
]; ];
} else if (isUsers) { } else if (isUsers) {
// 사용자 가입 추이 // 사용자 가입 추이
columns = ['week', 'new_users']; columns = ["week", "new_users"];
rows = [ rows = [
{ week: '2024-W10', new_users: 23 }, { week: "2024-W10", new_users: 23 },
{ week: '2024-W11', new_users: 31 }, { week: "2024-W11", new_users: 31 },
{ week: '2024-W12', new_users: 28 }, { week: "2024-W12", new_users: 28 },
{ week: '2024-W13', new_users: 35 }, { week: "2024-W13", new_users: 35 },
{ week: '2024-W14', new_users: 42 }, { week: "2024-W14", new_users: 42 },
{ week: '2024-W15', new_users: 38 }, { week: "2024-W15", new_users: 38 },
]; ];
} else if (isProducts) { } else if (isProducts) {
// 상품별 판매량 // 상품별 판매량
columns = ['product_name', 'total_sold', 'revenue']; columns = ["product_name", "total_sold", "revenue"];
rows = [ rows = [
{ product_name: '스마트폰', total_sold: 156, revenue: 234000000 }, { product_name: "스마트폰", total_sold: 156, revenue: 234000000 },
{ product_name: '노트북', total_sold: 89, revenue: 178000000 }, { product_name: "노트북", total_sold: 89, revenue: 178000000 },
{ product_name: '태블릿', total_sold: 134, revenue: 67000000 }, { product_name: "태블릿", total_sold: 134, revenue: 67000000 },
{ product_name: '이어폰', total_sold: 267, revenue: 26700000 }, { product_name: "이어폰", total_sold: 267, revenue: 26700000 },
{ product_name: '스마트워치', total_sold: 98, revenue: 49000000 }, { product_name: "스마트워치", total_sold: 98, revenue: 49000000 },
]; ];
} else { } else {
// 기본 샘플 데이터 // 기본 샘플 데이터
columns = ['category', 'value', 'count']; columns = ["category", "value", "count"];
rows = [ rows = [
{ category: 'A', value: 100, count: 10 }, { category: "A", value: 100, count: 10 },
{ category: 'B', value: 150, count: 15 }, { category: "B", value: 150, count: 15 },
{ category: 'C', value: 120, count: 12 }, { category: "C", value: 120, count: 12 },
{ category: 'D', value: 180, count: 18 }, { category: "D", value: 180, count: 18 },
{ category: 'E', value: 90, count: 9 }, { category: "E", value: 90, count: 9 },
]; ];
} }

View File

@ -1,8 +1,9 @@
'use client'; "use client";
import React, { forwardRef, useState, useCallback } from 'react'; import React, { forwardRef, useState, useCallback, useEffect } from "react";
import { DashboardElement, ElementType, ElementSubtype, DragData } from './types'; import { DashboardElement, ElementType, ElementSubtype, DragData } from "./types";
import { CanvasElement } from './CanvasElement'; import { CanvasElement } from "./CanvasElement";
import { GRID_CONFIG, snapToGrid } from "./gridUtils";
interface DashboardCanvasProps { interface DashboardCanvasProps {
elements: DashboardElement[]; elements: DashboardElement[];
@ -17,17 +18,29 @@ interface DashboardCanvasProps {
/** /**
* *
* - * -
* - * - 12
* -
* - * -
*/ */
export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>( export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
({ elements, selectedElement, onCreateElement, onUpdateElement, onRemoveElement, onSelectElement, onConfigureElement }, ref) => { (
{
elements,
selectedElement,
onCreateElement,
onUpdateElement,
onRemoveElement,
onSelectElement,
onConfigureElement,
},
ref,
) => {
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
// 드래그 오버 처리 // 드래그 오버 처리
const handleDragOver = useCallback((e: React.DragEvent) => { const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'copy'; e.dataTransfer.dropEffect = "copy";
setIsDragOver(true); setIsDragOver(true);
}, []); }, []);
@ -38,51 +51,71 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
} }
}, []); }, []);
// 드롭 처리 // 드롭 처리 (그리드 스냅 적용)
const handleDrop = useCallback((e: React.DragEvent) => { const handleDrop = useCallback(
e.preventDefault(); (e: React.DragEvent) => {
setIsDragOver(false); e.preventDefault();
setIsDragOver(false);
try { try {
const dragData: DragData = JSON.parse(e.dataTransfer.getData('application/json')); const dragData: DragData = JSON.parse(e.dataTransfer.getData("application/json"));
if (!ref || typeof ref === 'function') return;
const rect = ref.current?.getBoundingClientRect();
if (!rect) return;
// 캔버스 스크롤을 고려한 정확한 위치 계산 if (!ref || typeof ref === "function") return;
const x = e.clientX - rect.left + (ref.current?.scrollLeft || 0);
const y = e.clientY - rect.top + (ref.current?.scrollTop || 0);
onCreateElement(dragData.type, dragData.subtype, x, y); const rect = ref.current?.getBoundingClientRect();
} catch (error) { if (!rect) return;
// console.error('드롭 데이터 파싱 오류:', error);
} // 캔버스 스크롤을 고려한 정확한 위치 계산
}, [ref, onCreateElement]); const rawX = e.clientX - rect.left + (ref.current?.scrollLeft || 0);
const rawY = e.clientY - rect.top + (ref.current?.scrollTop || 0);
// 그리드에 스냅 (고정 셀 크기 사용)
const snappedX = snapToGrid(rawX, GRID_CONFIG.CELL_SIZE);
const snappedY = snapToGrid(rawY, GRID_CONFIG.CELL_SIZE);
onCreateElement(dragData.type, dragData.subtype, snappedX, snappedY);
} catch (error) {
// console.error('드롭 데이터 파싱 오류:', error);
}
},
[ref, onCreateElement],
);
// 캔버스 클릭 시 선택 해제 // 캔버스 클릭 시 선택 해제
const handleCanvasClick = useCallback((e: React.MouseEvent) => { const handleCanvasClick = useCallback(
if (e.target === e.currentTarget) { (e: React.MouseEvent) => {
onSelectElement(null); if (e.target === e.currentTarget) {
} onSelectElement(null);
}, [onSelectElement]); }
},
[onSelectElement],
);
// 고정 그리드 크기
const cellWithGap = GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP;
const gridSize = `${cellWithGap}px ${cellWithGap}px`;
// 캔버스 높이를 요소들의 최대 y + height 기준으로 계산 (최소 화면 높이 보장)
const minCanvasHeight = Math.max(
typeof window !== "undefined" ? window.innerHeight : 800,
...elements.map((el) => el.position.y + el.size.height + 100), // 하단 여백 100px
);
return ( return (
<div <div
ref={ref} ref={ref}
className={` className={`relative rounded-lg bg-gray-50 shadow-inner ${isDragOver ? "bg-blue-50/50" : ""} `}
w-full min-h-full relative
bg-gray-100
bg-grid-pattern
${isDragOver ? 'bg-accent' : ''}
`}
style={{ style={{
width: `${GRID_CONFIG.CANVAS_WIDTH}px`,
minHeight: `${minCanvasHeight}px`,
// 12 컬럼 그리드 배경
backgroundImage: ` backgroundImage: `
linear-gradient(rgba(200, 200, 200, 0.3) 1px, transparent 1px), linear-gradient(rgba(59, 130, 246, 0.15) 1px, transparent 1px),
linear-gradient(90deg, rgba(200, 200, 200, 0.3) 1px, transparent 1px) linear-gradient(90deg, rgba(59, 130, 246, 0.15) 1px, transparent 1px)
`, `,
backgroundSize: '20px 20px' backgroundSize: gridSize,
backgroundPosition: "0 0",
backgroundRepeat: "repeat",
}} }}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
@ -95,6 +128,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
key={element.id} key={element.id}
element={element} element={element}
isSelected={selectedElement === element.id} isSelected={selectedElement === element.id}
cellSize={GRID_CONFIG.CELL_SIZE}
onUpdate={onUpdateElement} onUpdate={onUpdateElement}
onRemove={onRemoveElement} onRemove={onRemoveElement}
onSelect={onSelectElement} onSelect={onSelectElement}
@ -103,7 +137,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
))} ))}
</div> </div>
); );
} },
); );
DashboardCanvas.displayName = 'DashboardCanvas'; DashboardCanvas.displayName = "DashboardCanvas";

View File

@ -1,15 +1,17 @@
'use client'; "use client";
import React, { useState, useRef, useCallback } from 'react'; import React, { useState, useRef, useCallback, useEffect } from "react";
import { DashboardCanvas } from './DashboardCanvas'; import { DashboardCanvas } from "./DashboardCanvas";
import { DashboardSidebar } from './DashboardSidebar'; import { DashboardSidebar } from "./DashboardSidebar";
import { DashboardToolbar } from './DashboardToolbar'; import { DashboardToolbar } from "./DashboardToolbar";
import { ElementConfigModal } from './ElementConfigModal'; import { ElementConfigModal } from "./ElementConfigModal";
import { DashboardElement, ElementType, ElementSubtype } from './types'; import { DashboardElement, ElementType, ElementSubtype } from "./types";
import { GRID_CONFIG } from "./gridUtils";
/** /**
* *
* - / * - /
* - (12 )
* - , , * - , ,
* - / * - /
*/ */
@ -19,15 +21,15 @@ export default function DashboardDesigner() {
const [elementCounter, setElementCounter] = useState(0); const [elementCounter, setElementCounter] = useState(0);
const [configModalElement, setConfigModalElement] = useState<DashboardElement | null>(null); const [configModalElement, setConfigModalElement] = useState<DashboardElement | null>(null);
const [dashboardId, setDashboardId] = useState<string | null>(null); const [dashboardId, setDashboardId] = useState<string | null>(null);
const [dashboardTitle, setDashboardTitle] = useState<string>(''); const [dashboardTitle, setDashboardTitle] = useState<string>("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
// URL 파라미터에서 대시보드 ID 읽기 및 데이터 로드 // URL 파라미터에서 대시보드 ID 읽기 및 데이터 로드
React.useEffect(() => { React.useEffect(() => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const loadId = params.get('load'); const loadId = params.get("load");
if (loadId) { if (loadId) {
loadDashboard(loadId); loadDashboard(loadId);
} }
@ -38,20 +40,20 @@ export default function DashboardDesigner() {
setIsLoading(true); setIsLoading(true);
try { try {
// console.log('🔄 대시보드 로딩:', id); // console.log('🔄 대시보드 로딩:', id);
const { dashboardApi } = await import('@/lib/api/dashboard'); const { dashboardApi } = await import("@/lib/api/dashboard");
const dashboard = await dashboardApi.getDashboard(id); const dashboard = await dashboardApi.getDashboard(id);
// console.log('✅ 대시보드 로딩 완료:', dashboard); // console.log('✅ 대시보드 로딩 완료:', dashboard);
// 대시보드 정보 설정 // 대시보드 정보 설정
setDashboardId(dashboard.id); setDashboardId(dashboard.id);
setDashboardTitle(dashboard.title); setDashboardTitle(dashboard.title);
// 요소들 설정 // 요소들 설정
if (dashboard.elements && dashboard.elements.length > 0) { if (dashboard.elements && dashboard.elements.length > 0) {
setElements(dashboard.elements); setElements(dashboard.elements);
// elementCounter를 가장 큰 ID 번호로 설정 // elementCounter를 가장 큰 ID 번호로 설정
const maxId = dashboard.elements.reduce((max, el) => { const maxId = dashboard.elements.reduce((max, el) => {
const match = el.id.match(/element-(\d+)/); const match = el.id.match(/element-(\d+)/);
@ -63,55 +65,63 @@ export default function DashboardDesigner() {
}, 0); }, 0);
setElementCounter(maxId); setElementCounter(maxId);
} }
} catch (error) { } catch (error) {
// console.error('❌ 대시보드 로딩 오류:', error); // console.error('❌ 대시보드 로딩 오류:', error);
alert('대시보드를 불러오는 중 오류가 발생했습니다.\n\n' + (error instanceof Error ? error.message : '알 수 없는 오류')); alert(
"대시보드를 불러오는 중 오류가 발생했습니다.\n\n" +
(error instanceof Error ? error.message : "알 수 없는 오류"),
);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
// 새로운 요소 생성 // 새로운 요소 생성 (고정 그리드 기반 기본 크기)
const createElement = useCallback(( const createElement = useCallback(
type: ElementType, (type: ElementType, subtype: ElementSubtype, x: number, y: number) => {
subtype: ElementSubtype, // 기본 크기: 차트는 4x3 셀, 위젯은 2x2 셀
x: number, const defaultCells = type === "chart" ? { width: 4, height: 3 } : { width: 2, height: 2 };
y: number const cellWithGap = GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP;
) => {
const newElement: DashboardElement = {
id: `element-${elementCounter + 1}`,
type,
subtype,
position: { x, y },
size: { width: 250, height: 200 },
title: getElementTitle(type, subtype),
content: getElementContent(type, subtype)
};
setElements(prev => [...prev, newElement]); const defaultWidth = defaultCells.width * cellWithGap - GRID_CONFIG.GAP;
setElementCounter(prev => prev + 1); const defaultHeight = defaultCells.height * cellWithGap - GRID_CONFIG.GAP;
setSelectedElement(newElement.id);
}, [elementCounter]); 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),
};
setElements((prev) => [...prev, newElement]);
setElementCounter((prev) => prev + 1);
setSelectedElement(newElement.id);
},
[elementCounter],
);
// 요소 업데이트 // 요소 업데이트
const updateElement = useCallback((id: string, updates: Partial<DashboardElement>) => { const updateElement = useCallback((id: string, updates: Partial<DashboardElement>) => {
setElements(prev => prev.map(el => setElements((prev) => prev.map((el) => (el.id === id ? { ...el, ...updates } : el)));
el.id === id ? { ...el, ...updates } : el
));
}, []); }, []);
// 요소 삭제 // 요소 삭제
const removeElement = useCallback((id: string) => { const removeElement = useCallback(
setElements(prev => prev.filter(el => el.id !== id)); (id: string) => {
if (selectedElement === id) { setElements((prev) => prev.filter((el) => el.id !== id));
setSelectedElement(null); if (selectedElement === id) {
} setSelectedElement(null);
}, [selectedElement]); }
},
[selectedElement],
);
// 전체 삭제 // 전체 삭제
const clearCanvas = useCallback(() => { const clearCanvas = useCallback(() => {
if (window.confirm('모든 요소를 삭제하시겠습니까?')) { if (window.confirm("모든 요소를 삭제하시겠습니까?")) {
setElements([]); setElements([]);
setSelectedElement(null); setSelectedElement(null);
setElementCounter(0); setElementCounter(0);
@ -129,22 +139,25 @@ export default function DashboardDesigner() {
}, []); }, []);
// 요소 설정 저장 // 요소 설정 저장
const saveElementConfig = useCallback((updatedElement: DashboardElement) => { const saveElementConfig = useCallback(
updateElement(updatedElement.id, updatedElement); (updatedElement: DashboardElement) => {
}, [updateElement]); updateElement(updatedElement.id, updatedElement);
},
[updateElement],
);
// 레이아웃 저장 // 레이아웃 저장
const saveLayout = useCallback(async () => { const saveLayout = useCallback(async () => {
if (elements.length === 0) { if (elements.length === 0) {
alert('저장할 요소가 없습니다. 차트나 위젯을 추가해주세요.'); alert("저장할 요소가 없습니다. 차트나 위젯을 추가해주세요.");
return; return;
} }
try { try {
// 실제 API 호출 // 실제 API 호출
const { dashboardApi } = await import('@/lib/api/dashboard'); const { dashboardApi } = await import("@/lib/api/dashboard");
const elementsData = elements.map(el => ({ const elementsData = elements.map((el) => ({
id: el.id, id: el.id,
type: el.type, type: el.type,
subtype: el.subtype, subtype: el.subtype,
@ -153,51 +166,49 @@ export default function DashboardDesigner() {
title: el.title, title: el.title,
content: el.content, content: el.content,
dataSource: el.dataSource, dataSource: el.dataSource,
chartConfig: el.chartConfig chartConfig: el.chartConfig,
})); }));
let savedDashboard; let savedDashboard;
if (dashboardId) { if (dashboardId) {
// 기존 대시보드 업데이트 // 기존 대시보드 업데이트
// console.log('🔄 대시보드 업데이트:', dashboardId); // console.log('🔄 대시보드 업데이트:', dashboardId);
savedDashboard = await dashboardApi.updateDashboard(dashboardId, { savedDashboard = await dashboardApi.updateDashboard(dashboardId, {
elements: elementsData elements: elementsData,
}); });
alert(`대시보드 "${savedDashboard.title}"이 업데이트되었습니다!`); alert(`대시보드 "${savedDashboard.title}"이 업데이트되었습니다!`);
// 뷰어 페이지로 이동 // 뷰어 페이지로 이동
window.location.href = `/dashboard/${savedDashboard.id}`; window.location.href = `/dashboard/${savedDashboard.id}`;
} else { } else {
// 새 대시보드 생성 // 새 대시보드 생성
const title = prompt('대시보드 제목을 입력하세요:', '새 대시보드'); const title = prompt("대시보드 제목을 입력하세요:", "새 대시보드");
if (!title) return; if (!title) return;
const description = prompt('대시보드 설명을 입력하세요 (선택사항):', ''); const description = prompt("대시보드 설명을 입력하세요 (선택사항):", "");
const dashboardData = { const dashboardData = {
title, title,
description: description || undefined, description: description || undefined,
isPublic: false, isPublic: false,
elements: elementsData elements: elementsData,
}; };
savedDashboard = await dashboardApi.createDashboard(dashboardData); savedDashboard = await dashboardApi.createDashboard(dashboardData);
// console.log('✅ 대시보드 생성 완료:', savedDashboard); // console.log('✅ 대시보드 생성 완료:', savedDashboard);
const viewDashboard = confirm(`대시보드 "${title}"이 저장되었습니다!\n\n지금 확인해보시겠습니까?`); const viewDashboard = confirm(`대시보드 "${title}"이 저장되었습니다!\n\n지금 확인해보시겠습니까?`);
if (viewDashboard) { if (viewDashboard) {
window.location.href = `/dashboard/${savedDashboard.id}`; window.location.href = `/dashboard/${savedDashboard.id}`;
} }
} }
} catch (error) { } catch (error) {
// console.error('❌ 저장 오류:', error); // console.error('❌ 저장 오류:', error);
const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류'; const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류";
alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}\n\n관리자에게 문의하세요.`); alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}\n\n관리자에게 문의하세요.`);
} }
}, [elements, dashboardId]); }, [elements, dashboardId]);
@ -207,9 +218,9 @@ export default function DashboardDesigner() {
return ( return (
<div className="flex h-full items-center justify-center bg-gray-50"> <div className="flex h-full items-center justify-center bg-gray-50">
<div className="text-center"> <div className="text-center">
<div className="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" /> <div className="border-primary mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-4 border-t-transparent" />
<div className="text-lg font-medium text-gray-700"> ...</div> <div className="text-lg font-medium text-gray-700"> ...</div>
<div className="text-sm text-gray-500 mt-1"> </div> <div className="mt-1 text-sm text-gray-500"> </div>
</div> </div>
</div> </div>
); );
@ -218,28 +229,29 @@ export default function DashboardDesigner() {
return ( return (
<div className="flex h-full bg-gray-50"> <div className="flex h-full bg-gray-50">
{/* 캔버스 영역 */} {/* 캔버스 영역 */}
<div className="flex-1 relative overflow-auto border-r-2 border-gray-300"> <div className="relative flex-1 overflow-auto border-r-2 border-gray-300 bg-gray-100">
{/* 편집 중인 대시보드 표시 */} {/* 편집 중인 대시보드 표시 */}
{dashboardTitle && ( {dashboardTitle && (
<div className="absolute top-2 left-2 z-10 bg-accent0 text-white px-3 py-1 rounded-lg text-sm font-medium shadow-lg"> <div className="bg-accent0 absolute top-6 left-6 z-10 rounded-lg px-3 py-1 text-sm font-medium text-white shadow-lg">
📝 : {dashboardTitle} 📝 : {dashboardTitle}
</div> </div>
)} )}
<DashboardToolbar <DashboardToolbar onClearCanvas={clearCanvas} onSaveLayout={saveLayout} />
onClearCanvas={clearCanvas}
onSaveLayout={saveLayout} {/* 캔버스 중앙 정렬 컨테이너 */}
/> <div className="flex justify-center p-4">
<DashboardCanvas <DashboardCanvas
ref={canvasRef} ref={canvasRef}
elements={elements} elements={elements}
selectedElement={selectedElement} selectedElement={selectedElement}
onCreateElement={createElement} onCreateElement={createElement}
onUpdateElement={updateElement} onUpdateElement={updateElement}
onRemoveElement={removeElement} onRemoveElement={removeElement}
onSelectElement={setSelectedElement} onSelectElement={setSelectedElement}
onConfigureElement={openConfigModal} onConfigureElement={openConfigModal}
/> />
</div>
</div> </div>
{/* 사이드바 */} {/* 사이드바 */}
@ -260,38 +272,52 @@ export default function DashboardDesigner() {
// 요소 제목 생성 헬퍼 함수 // 요소 제목 생성 헬퍼 함수
function getElementTitle(type: ElementType, subtype: ElementSubtype): string { function getElementTitle(type: ElementType, subtype: ElementSubtype): string {
if (type === 'chart') { if (type === "chart") {
switch (subtype) { switch (subtype) {
case 'bar': return '📊 바 차트'; case "bar":
case 'pie': return '🥧 원형 차트'; return "📊 바 차트";
case 'line': return '📈 꺾은선 차트'; case "pie":
default: return '📊 차트'; return "🥧 원형 차트";
case "line":
return "📈 꺾은선 차트";
default:
return "📊 차트";
} }
} else if (type === 'widget') { } else if (type === "widget") {
switch (subtype) { switch (subtype) {
case 'exchange': return '💱 환율 위젯'; case "exchange":
case 'weather': return '☁️ 날씨 위젯'; return "💱 환율 위젯";
default: return '🔧 위젯'; case "weather":
return "☁️ 날씨 위젯";
default:
return "🔧 위젯";
} }
} }
return '요소'; return "요소";
} }
// 요소 내용 생성 헬퍼 함수 // 요소 내용 생성 헬퍼 함수
function getElementContent(type: ElementType, subtype: ElementSubtype): string { function getElementContent(type: ElementType, subtype: ElementSubtype): string {
if (type === 'chart') { if (type === "chart") {
switch (subtype) { switch (subtype) {
case 'bar': return '바 차트가 여기에 표시됩니다'; case "bar":
case 'pie': return '원형 차트가 여기에 표시됩니다'; return "바 차트가 여기에 표시됩니다";
case 'line': return '꺾은선 차트가 여기에 표시됩니다'; case "pie":
default: return '차트가 여기에 표시됩니다'; return "원형 차트가 여기에 표시됩니다";
case "line":
return "꺾은선 차트가 여기에 표시됩니다";
default:
return "차트가 여기에 표시됩니다";
} }
} else if (type === 'widget') { } else if (type === "widget") {
switch (subtype) { switch (subtype) {
case 'exchange': return 'USD: ₩1,320\nJPY: ₩900\nEUR: ₩1,450'; case "exchange":
case 'weather': return '서울\n23°C\n구름 많음'; return "USD: ₩1,320\nJPY: ₩900\nEUR: ₩1,450";
default: return '위젯 내용이 여기에 표시됩니다'; case "weather":
return "서울\n23°C\n구름 많음";
default:
return "위젯 내용이 여기에 표시됩니다";
} }
} }
return '내용이 여기에 표시됩니다'; return "내용이 여기에 표시됩니다";
} }

View File

@ -1,7 +1,7 @@
'use client'; "use client";
import React from 'react'; import React from "react";
import { DragData, ElementType, ElementSubtype } from './types'; import { DragData, ElementType, ElementSubtype } from "./types";
/** /**
* *
@ -12,18 +12,16 @@ export function DashboardSidebar() {
// 드래그 시작 처리 // 드래그 시작 처리
const handleDragStart = (e: React.DragEvent, type: ElementType, subtype: ElementSubtype) => { const handleDragStart = (e: React.DragEvent, type: ElementType, subtype: ElementSubtype) => {
const dragData: DragData = { type, subtype }; const dragData: DragData = { type, subtype };
e.dataTransfer.setData('application/json', JSON.stringify(dragData)); e.dataTransfer.setData("application/json", JSON.stringify(dragData));
e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.effectAllowed = "copy";
}; };
return ( return (
<div className="w-80 bg-white border-l border-gray-200 overflow-y-auto p-5"> <div className="w-[370px] overflow-y-auto border-l border-gray-200 bg-white p-6">
{/* 차트 섹션 */} {/* 차트 섹션 */}
<div className="mb-8"> <div className="mb-8">
<h3 className="text-gray-800 mb-4 pb-3 border-b-2 border-green-500 font-semibold text-lg"> <h3 className="mb-4 border-b-2 border-green-500 pb-3 text-lg font-semibold text-gray-800">📊 </h3>
📊
</h3>
<div className="space-y-3"> <div className="space-y-3">
<DraggableItem <DraggableItem
icon="📊" icon="📊"
@ -31,7 +29,7 @@ export function DashboardSidebar() {
type="chart" type="chart"
subtype="bar" subtype="bar"
onDragStart={handleDragStart} onDragStart={handleDragStart}
className="border-l-4 border-primary" className="border-primary border-l-4"
/> />
<DraggableItem <DraggableItem
icon="📚" icon="📚"
@ -86,10 +84,8 @@ export function DashboardSidebar() {
{/* 위젯 섹션 */} {/* 위젯 섹션 */}
<div className="mb-8"> <div className="mb-8">
<h3 className="text-gray-800 mb-4 pb-3 border-b-2 border-green-500 font-semibold text-lg"> <h3 className="mb-4 border-b-2 border-green-500 pb-3 text-lg font-semibold text-gray-800">🔧 </h3>
🔧
</h3>
<div className="space-y-3"> <div className="space-y-3">
<DraggableItem <DraggableItem
icon="💱" icon="💱"
@ -125,20 +121,14 @@ interface DraggableItemProps {
/** /**
* *
*/ */
function DraggableItem({ icon, title, type, subtype, className = '', onDragStart }: DraggableItemProps) { function DraggableItem({ icon, title, type, subtype, className = "", onDragStart }: DraggableItemProps) {
return ( return (
<div <div
draggable draggable
className={` className={`cursor-move rounded-lg border-2 border-gray-200 bg-white p-4 text-center text-sm font-medium transition-all duration-200 hover:translate-x-1 hover:border-green-500 hover:bg-gray-50 ${className} `}
p-4 bg-white border-2 border-gray-200 rounded-lg
cursor-move transition-all duration-200
hover:bg-gray-50 hover:border-green-500 hover:translate-x-1
text-center text-sm font-medium
${className}
`}
onDragStart={(e) => onDragStart(e, type, subtype)} onDragStart={(e) => onDragStart(e, type, subtype)}
> >
<span className="text-lg mr-2">{icon}</span> <span className="mr-2 text-lg">{icon}</span>
{title} {title}
</div> </div>
); );

View File

@ -0,0 +1,228 @@
# 대시보드 그리드 시스템
## 개요
대시보드 캔버스는 **12 컬럼 그리드 시스템**을 사용하여 요소를 정렬하고 배치합니다.
모든 요소는 드래그 또는 리사이즈 종료 시 자동으로 그리드에 스냅됩니다.
## 그리드 설정
### 기본 설정 (`gridUtils.ts`)
```typescript
GRID_CONFIG = {
COLUMNS: 12, // 12 컬럼
CELL_SIZE: 60, // 60px x 60px 정사각형 셀
GAP: 8, // 셀 간격 8px
SNAP_THRESHOLD: 15, // 스냅 임계값 15px
};
```
### 실제 그리드 크기
- **셀 크기 (gap 포함)**: 68px (60px + 8px)
- **전체 캔버스 너비**: 808px (12 \* 68px - 8px)
- **셀 비율**: 1:1 (정사각형)
## 스냅 기능
### 1. 위치 스냅
요소를 드래그하여 이동할 때:
- **드래그 중**: 자유롭게 이동 (그리드 무시)
- **드래그 종료**: 가장 가까운 그리드 포인트에 자동 스냅
- **스냅 계산**: `Math.round(value / 68) * 68`
### 2. 크기 스냅
요소의 크기를 조절할 때:
- **리사이즈 중**: 자유롭게 크기 조절
- **리사이즈 종료**: 그리드 단위로 스냅
- **최소 크기**: 2셀 x 2셀 (136px x 136px)
### 3. 드롭 스냅
사이드바에서 새 요소를 드래그 앤 드롭할 때:
- 드롭 위치가 자동으로 가장 가까운 그리드 포인트에 스냅
- 기본 크기:
- 차트: 4 x 3 셀 (264px x 196px)
- 위젯: 2 x 2 셀 (136px x 136px)
## 시각적 피드백
### 그리드 배경
캔버스 배경에 그리드 라인이 표시됩니다:
```typescript
backgroundImage: `
linear-gradient(rgba(59, 130, 246, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(59, 130, 246, 0.1) 1px, transparent 1px)
`,
backgroundSize: '68px 68px'
```
### 요소 테두리
- **선택 안 됨**: 회색 테두리
- **선택됨**: 파란색 테두리 + 리사이즈 핸들 표시
- **드래그/리사이즈 중**: 트랜지션 비활성화 (부드러운 움직임)
## 사용 예시
### 기본 사용
```typescript
import { snapToGrid, snapSizeToGrid } from "./gridUtils";
// 위치 스냅
const snappedX = snapToGrid(123); // 136 (가장 가까운 그리드)
const snappedY = snapToGrid(45); // 68
// 크기 스냅
const snappedWidth = snapSizeToGrid(250); // 264 (4셀)
const snappedHeight = snapSizeToGrid(180); // 196 (3셀)
```
### 경계 체크와 함께
```typescript
import { snapBoundsToGrid } from "./gridUtils";
const snapped = snapBoundsToGrid(
{
position: { x: 123, y: 45 },
size: { width: 250, height: 180 },
},
canvasWidth,
canvasHeight,
);
// 결과:
// {
// position: { x: 136, y: 68 },
// size: { width: 264, height: 196 }
// }
```
## 그리드 인덱스
### 좌표 → 인덱스
```typescript
import { getGridIndex } from "./gridUtils";
const colIndex = getGridIndex(150); // 2 (3번째 컬럼)
const rowIndex = getGridIndex(100); // 1 (2번째 행)
```
### 인덱스 → 좌표
```typescript
import { gridIndexToCoordinate } from "./gridUtils";
const x = gridIndexToCoordinate(0); // 0 (1번째 컬럼)
const y = gridIndexToCoordinate(1); // 68 (2번째 행)
const z = gridIndexToCoordinate(11); // 748 (12번째 컬럼)
```
## 레이아웃 권장사항
### 일반 차트
- **권장 크기**: 4 x 3 셀 (264px x 196px)
- **최소 크기**: 2 x 2 셀 (136px x 136px)
- **최대 크기**: 12 x 8 셀 (808px x 536px)
### 작은 위젯
- **권장 크기**: 2 x 2 셀 (136px x 136px)
- **최소 크기**: 2 x 2 셀
- **최대 크기**: 4 x 4 셀 (264px x 264px)
### 큰 차트/대시보드
- **권장 크기**: 6 x 4 셀 (400px x 264px)
- **풀 너비**: 12 셀 (808px)
## 커스터마이징
### 그리드 크기 변경
`gridUtils.ts``GRID_CONFIG`를 수정:
```typescript
export const GRID_CONFIG = {
COLUMNS: 12,
CELL_SIZE: 80, // 60 → 80 (셀 크기 증가)
GAP: 16, // 8 → 16 (간격 증가)
SNAP_THRESHOLD: 20, // 15 → 20 (스냅 범위 증가)
} as const;
```
### 스냅 비활성화
특정 요소에서 스냅을 비활성화하려면:
```typescript
// 드래그 종료 시 스냅하지 않고 그냥 업데이트
onUpdate(element.id, {
position: { x: rawX, y: rawY }, // snapToGrid 호출 안 함
});
```
## 성능 최적화
### 트랜지션 제어
드래그/리사이즈 중에는 CSS 트랜지션을 비활성화:
```typescript
className={`
${(isDragging || isResizing) ? 'transition-none' : 'transition-all duration-150'}
`}
```
### 임시 상태 사용
마우스 이동 중에는 임시 위치/크기만 업데이트하고,
마우스 업 시에만 실제 스냅된 값으로 업데이트:
```typescript
// 드래그 중
setTempPosition({ x: rawX, y: rawY });
// 드래그 종료
const snapped = snapToGrid(tempPosition.x);
onUpdate(element.id, { position: { x: snapped, y: snapped } });
```
## 문제 해결
### 요소가 스냅되지 않는 경우
1. `snapToGrid` 함수가 호출되는지 확인
2. `SNAP_THRESHOLD` 값 확인 (너무 작으면 스냅 안 됨)
3. 임시 상태가 제대로 초기화되는지 확인
### 그리드가 보이지 않는 경우
1. 캔버스의 `backgroundImage` 스타일 확인
2. `getCellWithGap()` 반환값 확인
3. 브라우저 개발자 도구에서 배경 스타일 검사
### 성능 문제
1. 트랜지션이 비활성화되었는지 확인
2. 불필요한 리렌더링 방지 (React.memo 사용)
3. 마우스 이벤트 리스너가 제대로 제거되는지 확인
## 참고 자료
- **그리드 유틸리티**: `gridUtils.ts`
- **캔버스 컴포넌트**: `DashboardCanvas.tsx`
- **요소 컴포넌트**: `CanvasElement.tsx`
- **디자이너**: `DashboardDesigner.tsx`

View File

@ -0,0 +1,169 @@
/**
*
* - 12
* - ( = )
* -
*/
// 그리드 설정 (고정 크기)
export const GRID_CONFIG = {
COLUMNS: 12,
CELL_SIZE: 132, // 고정 셀 크기
GAP: 8, // 셀 간격
SNAP_THRESHOLD: 15, // 스냅 임계값 (px)
ELEMENT_PADDING: 4, // 요소 주위 여백 (px)
CANVAS_WIDTH: 1682, // 고정 캔버스 너비 (실제 측정값)
// 계산식: (132 + 8) × 12 - 8 = 1672px (그리드)
// 추가 여백 10px 포함 = 1682px
} as const;
/**
* (gap )
*/
export const getCellWithGap = () => {
return GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP;
};
/**
*
*/
export const getCanvasWidth = () => {
const cellWithGap = getCellWithGap();
return GRID_CONFIG.COLUMNS * cellWithGap - GRID_CONFIG.GAP;
};
/**
* ( )
* @param value -
* @param cellSize - (, GRID_CONFIG.CELL_SIZE)
* @returns ( )
*/
export const snapToGrid = (value: number, cellSize: number = GRID_CONFIG.CELL_SIZE): number => {
const cellWithGap = cellSize + GRID_CONFIG.GAP;
const gridIndex = Math.round(value / cellWithGap);
return gridIndex * cellWithGap + GRID_CONFIG.ELEMENT_PADDING;
};
/**
* ( )
* @param value -
* @param cellSize - ()
* @returns ( , )
*/
export const snapToGridWithThreshold = (value: number, cellSize: number = GRID_CONFIG.CELL_SIZE): number => {
const snapped = snapToGrid(value, cellSize);
const distance = Math.abs(value - snapped);
return distance <= GRID_CONFIG.SNAP_THRESHOLD ? snapped : value;
};
/**
*
* @param size -
* @param minCells - (기본값: 2)
* @param cellSize - ()
* @returns
*/
export const snapSizeToGrid = (
size: number,
minCells: number = 2,
cellSize: number = GRID_CONFIG.CELL_SIZE,
): number => {
const cellWithGap = cellSize + GRID_CONFIG.GAP;
const cells = Math.max(minCells, Math.round(size / cellWithGap));
return cells * cellWithGap - GRID_CONFIG.GAP;
};
/**
*
*/
export interface GridPosition {
x: number;
y: number;
}
export interface GridSize {
width: number;
height: number;
}
export interface GridBounds {
position: GridPosition;
size: GridSize;
}
/**
*
* @param bounds -
* @param canvasWidth - ( )
* @param canvasHeight - ( )
* @returns
*/
export const snapBoundsToGrid = (bounds: GridBounds, canvasWidth?: number, canvasHeight?: number): GridBounds => {
// 위치 스냅
let snappedX = snapToGrid(bounds.position.x);
let snappedY = snapToGrid(bounds.position.y);
// 크기 스냅
const snappedWidth = snapSizeToGrid(bounds.size.width);
const snappedHeight = snapSizeToGrid(bounds.size.height);
// 캔버스 경계 체크
if (canvasWidth) {
snappedX = Math.min(snappedX, canvasWidth - snappedWidth);
}
if (canvasHeight) {
snappedY = Math.min(snappedY, canvasHeight - snappedHeight);
}
// 음수 방지
snappedX = Math.max(0, snappedX);
snappedY = Math.max(0, snappedY);
return {
position: { x: snappedX, y: snappedY },
size: { width: snappedWidth, height: snappedHeight },
};
};
/**
*
* @param value -
* @returns (0 )
*/
export const getGridIndex = (value: number): number => {
const cellWithGap = getCellWithGap();
return Math.floor(value / cellWithGap);
};
/**
*
* @param index -
* @returns
*/
export const gridIndexToCoordinate = (index: number): number => {
const cellWithGap = getCellWithGap();
return index * cellWithGap;
};
/**
*
* @param value -
* @returns
*/
export const getNearbyGridLines = (value: number): number[] => {
const snapped = snapToGrid(value);
const cellWithGap = getCellWithGap();
return [snapped - cellWithGap, snapped, snapped + cellWithGap].filter((line) => line >= 0);
};
/**
*
* @param value -
* @param snapValue -
* @returns true
*/
export const isWithinSnapThreshold = (value: number, snapValue: number): boolean => {
return Math.abs(value - snapValue) <= GRID_CONFIG.SNAP_THRESHOLD;
};