From 9dca73f4c4f5d10a64b57a6ffa0f2b963c094c11 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 22 Oct 2025 16:37:14 +0900 Subject: [PATCH 01/15] =?UTF-8?q?12=EC=BB=AC=EB=9F=BC=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EB=93=9C=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/CanvasElement.tsx | 47 +++++--- .../admin/dashboard/DashboardCanvas.tsx | 108 ++++++++++-------- .../components/admin/dashboard/gridUtils.ts | 68 ++++++++++- 3 files changed, 157 insertions(+), 66 deletions(-) diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 9350642e..6214c8cb 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -4,7 +4,7 @@ import React, { useState, useCallback, useRef, useEffect } from "react"; import dynamic from "next/dynamic"; import { DashboardElement, QueryResult, Position } from "./types"; import { ChartRenderer } from "./charts/ChartRenderer"; -import { GRID_CONFIG } from "./gridUtils"; +import { GRID_CONFIG, magneticSnap } from "./gridUtils"; // 위젯 동적 임포트 const WeatherWidget = dynamic(() => import("@/components/dashboard/widgets/WeatherWidget"), { @@ -135,6 +135,8 @@ interface CanvasElementProps { cellSize: number; subGridSize: number; canvasWidth?: number; + verticalGuidelines: number[]; + horizontalGuidelines: number[]; onUpdate: (id: string, updates: Partial) => void; onUpdateMultiple?: (updates: { id: string; updates: Partial }[]) => void; // 🔥 다중 업데이트 onMultiDragStart?: (draggedId: string, otherOffsets: Record) => void; @@ -159,6 +161,8 @@ export function CanvasElement({ cellSize, subGridSize, canvasWidth = 1560, + verticalGuidelines, + horizontalGuidelines, onUpdate, onUpdateMultiple, onMultiDragStart, @@ -315,15 +319,18 @@ export function CanvasElement({ const maxX = canvasWidth - element.size.width; rawX = Math.min(rawX, maxX); - // 드래그 중 실시간 스냅 (서브그리드만 사용) - const snappedX = Math.round(rawX / subGridSize) * subGridSize; - const snappedY = Math.round(rawY / subGridSize) * subGridSize; + // 자석 스냅으로 변경 + const snappedX = magneticSnap(rawX, verticalGuidelines); + const snappedY = magneticSnap(rawY, horizontalGuidelines); - setTempPosition({ x: snappedX, y: snappedY }); + // 스냅 후 X 좌표 다시 체크 + const finalSnappedX = Math.min(snappedX, maxX); + + setTempPosition({ x: finalSnappedX, y: snappedY }); // 🔥 다중 드래그 중 - 다른 위젯들의 위치 업데이트 if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragMove) { - onMultiDragMove(element, { x: snappedX, y: snappedY }); + onMultiDragMove(element, { x: finalSnappedX, y: snappedY }); } } else if (isResizing) { const deltaX = e.clientX - resizeStart.x; @@ -367,14 +374,20 @@ export function CanvasElement({ const maxWidth = canvasWidth - newX; newWidth = Math.min(newWidth, maxWidth); - // 리사이즈 중 실시간 스냅 (서브그리드만 사용) - const snappedX = Math.round(newX / subGridSize) * subGridSize; - const snappedY = Math.round(newY / subGridSize) * subGridSize; - const snappedWidth = Math.round(newWidth / subGridSize) * subGridSize; - const snappedHeight = Math.round(newHeight / subGridSize) * subGridSize; + // 자석 스냅으로 변경 + const snappedX = magneticSnap(newX, verticalGuidelines); + const snappedY = magneticSnap(newY, horizontalGuidelines); + + // 크기는 12px 단위로 스냅 + const snappedWidth = Math.round(newWidth / 12) * 12; + const snappedHeight = Math.round(newHeight / 12) * 12; + + // 스냅 후 경계 체크 + const finalSnappedX = Math.max(0, Math.min(snappedX, canvasWidth - snappedWidth)); + const finalSnappedY = Math.max(0, snappedY); // 임시 크기/위치 저장 (스냅됨) - setTempPosition({ x: Math.max(0, snappedX), y: Math.max(0, snappedY) }); + setTempPosition({ x: finalSnappedX, y: finalSnappedY }); setTempSize({ width: snappedWidth, height: snappedHeight }); } }, @@ -386,7 +399,8 @@ export function CanvasElement({ element, canvasWidth, cellSize, - subGridSize, + verticalGuidelines, + horizontalGuidelines, selectedElements, allElements, onUpdateMultiple, @@ -891,12 +905,7 @@ export function CanvasElement({ ) : element.type === "widget" && element.subtype === "list" ? ( // 리스트 위젯 렌더링
- { - onUpdate(element.id, { listConfig: newConfig as any }); - }} - /> +
) : element.type === "widget" && element.subtype === "yard-management-3d" ? ( // 야드 관리 3D 위젯 렌더링 diff --git a/frontend/components/admin/dashboard/DashboardCanvas.tsx b/frontend/components/admin/dashboard/DashboardCanvas.tsx index 1c2414c1..51351475 100644 --- a/frontend/components/admin/dashboard/DashboardCanvas.tsx +++ b/frontend/components/admin/dashboard/DashboardCanvas.tsx @@ -3,7 +3,15 @@ import React, { forwardRef, useState, useCallback, useMemo, useEffect } from "react"; import { DashboardElement, ElementType, ElementSubtype, DragData } from "./types"; import { CanvasElement } from "./CanvasElement"; -import { GRID_CONFIG, snapToGrid, calculateGridConfig } from "./gridUtils"; +import { + GRID_CONFIG, + snapToGrid, + calculateGridConfig, + calculateVerticalGuidelines, + calculateHorizontalGuidelines, + calculateBoxSize, + magneticSnap, +} from "./gridUtils"; import { resolveAllCollisions } from "./collisionUtils"; interface DashboardCanvasProps { @@ -47,7 +55,7 @@ export const DashboardCanvas = forwardRef( ref, ) => { const [isDragOver, setIsDragOver] = useState(false); - + // 🔥 선택 박스 상태 const [selectionBox, setSelectionBox] = useState<{ startX: number; @@ -58,10 +66,10 @@ export const DashboardCanvas = forwardRef( const [isSelecting, setIsSelecting] = useState(false); const [justSelected, setJustSelected] = useState(false); // 🔥 방금 선택했는지 플래그 const [isDraggingAny, setIsDraggingAny] = useState(false); // 🔥 현재 드래그 중인지 플래그 - + // 🔥 다중 선택된 위젯들의 임시 위치 (드래그 중 시각적 피드백) const [multiDragOffsets, setMultiDragOffsets] = useState>({}); - + // 🔥 선택 박스 드래그 중 자동 스크롤 const lastMouseYForSelectionRef = React.useRef(window.innerHeight / 2); const selectionAutoScrollFrameRef = React.useRef(null); @@ -70,6 +78,14 @@ export const DashboardCanvas = forwardRef( const gridConfig = useMemo(() => calculateGridConfig(canvasWidth), [canvasWidth]); const cellSize = gridConfig.CELL_SIZE; + // 🔥 그리드 박스 시스템 - 12개 박스가 캔버스 너비에 꽉 차게 + const verticalGuidelines = useMemo(() => calculateVerticalGuidelines(canvasWidth), [canvasWidth]); + const horizontalGuidelines = useMemo( + () => calculateHorizontalGuidelines(canvasHeight, canvasWidth), + [canvasHeight, canvasWidth], + ); + const boxSize = useMemo(() => calculateBoxSize(canvasWidth), [canvasWidth]); + // 충돌 방지 기능이 포함된 업데이트 핸들러 const handleUpdateWithCollisionDetection = useCallback( (id: string, updates: Partial) => { @@ -177,23 +193,13 @@ export const DashboardCanvas = forwardRef( const rawX = e.clientX - rect.left + (ref.current?.scrollLeft || 0); const rawY = e.clientY - rect.top + (ref.current?.scrollTop || 0); - // 마그네틱 스냅 (큰 그리드 우선, 없으면 서브그리드) - const gridSize = cellSize + GRID_CONFIG.GAP; // GAP 포함한 실제 그리드 크기 - const magneticThreshold = 15; + // 자석 스냅 적용 + let snappedX = magneticSnap(rawX, verticalGuidelines); + let snappedY = magneticSnap(rawY, horizontalGuidelines); - // X 좌표 스냅 - const nearestGridX = Math.round(rawX / gridSize) * gridSize; - const distToGridX = Math.abs(rawX - nearestGridX); - let snappedX = distToGridX <= magneticThreshold ? nearestGridX : Math.round(rawX / subGridSize) * subGridSize; - - // Y 좌표 스냅 - const nearestGridY = Math.round(rawY / gridSize) * gridSize; - const distToGridY = Math.abs(rawY - nearestGridY); - const snappedY = - distToGridY <= magneticThreshold ? nearestGridY : Math.round(rawY / subGridSize) * subGridSize; - - // X 좌표가 캔버스 너비를 벗어나지 않도록 제한 - const maxX = canvasWidth - cellSize * 2; // 최소 2칸 너비 보장 + // X 좌표가 캔버스 너비를 벗어나지 않도록 제한 (최소 2칸 너비 보장) + const minElementWidth = cellSize * 2 + GRID_CONFIG.GAP; + const maxX = canvasWidth - minElementWidth; snappedX = Math.max(0, Math.min(snappedX, maxX)); onCreateElement(dragData.type, dragData.subtype, snappedX, snappedY); @@ -201,7 +207,7 @@ export const DashboardCanvas = forwardRef( // 드롭 데이터 파싱 오류 무시 } }, - [ref, onCreateElement, canvasWidth, cellSize], + [ref, onCreateElement, canvasWidth, cellSize, verticalGuidelines, horizontalGuidelines], ); // 🔥 선택 박스 드래그 시작 @@ -210,14 +216,14 @@ export const DashboardCanvas = forwardRef( // 🔥 위젯 내부 클릭이 아닌 경우만 (data-element-id가 없는 경우) const target = e.target as HTMLElement; const isWidget = target.closest("[data-element-id]"); - + if (isWidget) { // console.log("🚫 위젯 내부 클릭 - 선택 박스 시작 안함"); return; } - + // console.log("✅ 빈 공간 클릭 - 선택 박스 시작"); - + if (!ref || typeof ref === "function") return; const rect = ref.current?.getBoundingClientRect(); if (!rect) return; @@ -274,20 +280,20 @@ export const DashboardCanvas = forwardRef( // 겹치는 영역의 넓이 const overlapArea = (overlapRight - overlapLeft) * (overlapBottom - overlapTop); - + // 요소의 전체 넓이 const elementArea = el.size.width * el.size.height; // 70% 이상 겹치면 선택 const overlapPercentage = overlapArea / elementArea; - + // console.log(`📦 요소 ${el.id}:`, { // position: el.position, // size: el.size, // overlapPercentage: (overlapPercentage * 100).toFixed(1) + "%", // selected: overlapPercentage >= 0.7, // }); - + return overlapPercentage >= 0.7; }) .map((el) => el.id); @@ -327,9 +333,9 @@ export const DashboardCanvas = forwardRef( if (!isSelecting) { const deltaX = Math.abs(x - selectionBox.startX); const deltaY = Math.abs(y - selectionBox.startY); - + // console.log("📏 이동 거리:", { deltaX, deltaY }); - + // 🔥 5px 이상 움직이면 선택 박스 활성화 (위젯 드래그와 구분) if (deltaX > 5 || deltaY > 5) { // console.log("🎯 선택 박스 활성화 (5px 이상 이동)"); @@ -374,10 +380,10 @@ export const DashboardCanvas = forwardRef( const autoScrollLoop = (currentTime: number) => { const viewportHeight = window.innerHeight; const lastMouseY = lastMouseYForSelectionRef.current; - + let shouldScroll = false; let scrollDirection = 0; - + if (lastMouseY < scrollThreshold) { shouldScroll = true; scrollDirection = -scrollSpeed; @@ -387,9 +393,9 @@ export const DashboardCanvas = forwardRef( scrollDirection = scrollSpeed; // console.log("⬇️ 아래로 스크롤 (선택 박스):", { lastMouseY, boundary: viewportHeight - scrollThreshold }); } - + const deltaTime = currentTime - lastTime; - + if (shouldScroll && deltaTime >= 10) { window.scrollBy(0, scrollDirection); // console.log("✅ 스크롤 실행 (선택 박스):", { scrollDirection, deltaTime }); @@ -418,7 +424,7 @@ export const DashboardCanvas = forwardRef( // console.log("🚫 방금 선택했거나 드래그 중이므로 클릭 이벤트 무시"); return; } - + if (e.target === e.currentTarget) { // console.log("✅ 빈 공간 클릭 - 선택 해제"); onSelectElement(null); @@ -433,7 +439,7 @@ export const DashboardCanvas = forwardRef( // 동적 그리드 크기 계산 const cellWithGap = cellSize + GRID_CONFIG.GAP; const gridSize = `${cellWithGap}px ${cellWithGap}px`; - + // 서브그리드 크기 계산 (gridConfig에서 정확하게 계산된 값 사용) const subGridSize = gridConfig.SUB_GRID_SIZE; @@ -443,7 +449,7 @@ export const DashboardCanvas = forwardRef( // 🔥 선택 박스 스타일 계산 const selectionBoxStyle = useMemo(() => { if (!selectionBox) return null; - + const minX = Math.min(selectionBox.startX, selectionBox.endX); const maxX = Math.max(selectionBox.startX, selectionBox.endX); const minY = Math.min(selectionBox.startY, selectionBox.endY); @@ -465,14 +471,6 @@ export const DashboardCanvas = forwardRef( backgroundColor, height: `${canvasHeight}px`, minHeight: `${canvasHeight}px`, - // 서브그리드 배경 (세밀한 점선) - backgroundImage: ` - linear-gradient(rgba(59, 130, 246, 0.05) 1px, transparent 1px), - linear-gradient(90deg, rgba(59, 130, 246, 0.05) 1px, transparent 1px) - `, - backgroundSize: `${subGridSize}px ${subGridSize}px`, - backgroundPosition: "0 0", - backgroundRepeat: "repeat", cursor: isSelecting ? "crosshair" : "default", }} onDragOver={handleDragOver} @@ -494,6 +492,23 @@ export const DashboardCanvas = forwardRef( }} /> ))} */} + {/* 그리드 박스들 (12px 간격, 캔버스 너비에 꽉 차게) */} + {verticalGuidelines.map((x, xIdx) => + horizontalGuidelines.map((y, yIdx) => ( +
+ )), + )} {/* 배치된 요소들 렌더링 */} {elements.length === 0 && (
@@ -513,6 +528,8 @@ export const DashboardCanvas = forwardRef( cellSize={cellSize} subGridSize={subGridSize} canvasWidth={canvasWidth} + verticalGuidelines={verticalGuidelines} + horizontalGuidelines={horizontalGuidelines} onUpdate={handleUpdateWithCollisionDetection} onUpdateMultiple={(updates) => { // 🔥 여러 요소 동시 업데이트 (충돌 감지 건너뛰기) @@ -552,10 +569,9 @@ export const DashboardCanvas = forwardRef( }} onRemove={onRemoveElement} onSelect={onSelectElement} - onConfigure={onConfigureElement} /> ))} - + {/* 🔥 선택 박스 렌더링 */} {selectionBox && selectionBoxStyle && (
{ export const snapToGrid = (value: number, subGridSize?: number): number => { // 서브 그리드 크기가 지정되지 않으면 기본 그리드 크기의 1/3 사용 (3x3 서브그리드) const snapSize = subGridSize ?? Math.floor(GRID_CONFIG.CELL_SIZE / 3); - + // 서브 그리드 단위로 스냅 const gridIndex = Math.round(value / snapSize); return gridIndex * snapSize; @@ -198,3 +205,62 @@ export const getNearbyGridLines = (value: number): number[] => { export const isWithinSnapThreshold = (value: number, snapValue: number): boolean => { return Math.abs(value - snapValue) <= GRID_CONFIG.SNAP_THRESHOLD; }; + +// 박스 크기 계산 (캔버스 너비에 맞게) +export function calculateBoxSize(canvasWidth: number): number { + const totalGaps = 11 * GRID_CONFIG.GRID_BOX_GAP; // 12개 박스 사이 간격 11개 + const availableWidth = canvasWidth - totalGaps; + return availableWidth / 12; +} + +// 수직 그리드 박스 좌표 계산 (12개, 너비에 꽉 차게) +export function calculateVerticalGuidelines(canvasWidth: number): number[] { + const lines: number[] = []; + const boxSize = calculateBoxSize(canvasWidth); + + for (let i = 0; i < 12; i++) { + const x = i * (boxSize + GRID_CONFIG.GRID_BOX_GAP); + lines.push(x); + } + return lines; +} + +// 수평 그리드 박스 좌표 계산 (캔버스 너비 기준으로 정사각형 유지) +export function calculateHorizontalGuidelines(canvasHeight: number, canvasWidth: number): number[] { + const lines: number[] = []; + const boxSize = calculateBoxSize(canvasWidth); // 수직과 동일한 박스 크기 사용 + const cellSize = boxSize + GRID_CONFIG.GRID_BOX_GAP; + + for (let y = 0; y <= canvasHeight; y += cellSize) { + lines.push(y); + } + return lines; +} + +// 가장 가까운 가이드라인 찾기 +export function findNearestGuideline( + value: number, + guidelines: number[], +): { + nearest: number; + distance: number; +} { + let nearest = guidelines[0]; + let minDistance = Math.abs(value - guidelines[0]); + + for (const guideline of guidelines) { + const distance = Math.abs(value - guideline); + if (distance < minDistance) { + minDistance = distance; + nearest = guideline; + } + } + + return { nearest, distance: minDistance }; +} + +// 자석 스냅 (10px 이내면 스냅) +export function magneticSnap(value: number, guidelines: number[]): number { + const { nearest, distance } = findNearestGuideline(value, guidelines); + return distance <= GRID_CONFIG.SNAP_DISTANCE ? nearest : value; +} From 0a28445abe2f2337a7e5923ccd8eb3fb2624af87 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 22 Oct 2025 16:45:15 +0900 Subject: [PATCH 02/15] =?UTF-8?q?12=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EB=B0=8F=20=EC=BA=94?= =?UTF-8?q?=EB=B2=84=EC=8A=A4=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/admin/dashboard/DashboardCanvas.tsx | 9 +++++---- .../components/admin/dashboard/DashboardDesigner.tsx | 6 +++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/frontend/components/admin/dashboard/DashboardCanvas.tsx b/frontend/components/admin/dashboard/DashboardCanvas.tsx index 51351475..ac0d69a4 100644 --- a/frontend/components/admin/dashboard/DashboardCanvas.tsx +++ b/frontend/components/admin/dashboard/DashboardCanvas.tsx @@ -48,7 +48,7 @@ export const DashboardCanvas = forwardRef( onSelectElement, onSelectMultiple, onConfigureElement, - backgroundColor = "#f9fafb", + backgroundColor = "transparent", canvasWidth = 1560, canvasHeight = 768, }, @@ -466,7 +466,7 @@ export const DashboardCanvas = forwardRef( return (
( horizontalGuidelines.map((y, yIdx) => (
diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 315179f6..36601623 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -47,7 +47,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D const [dashboardId, setDashboardId] = useState(initialDashboardId || null); const [dashboardTitle, setDashboardTitle] = useState(""); const [isLoading, setIsLoading] = useState(false); - const [canvasBackgroundColor, setCanvasBackgroundColor] = useState("#f9fafb"); + const [canvasBackgroundColor, setCanvasBackgroundColor] = useState("transparent"); const canvasRef = useRef(null); // 저장 모달 상태 @@ -218,7 +218,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D // 기본 크기 설정 (서브그리드 기준) const gridConfig = calculateGridConfig(canvasConfig.width); const subGridSize = gridConfig.SUB_GRID_SIZE; - + // 서브그리드 기준 기본 크기 (픽셀) let defaultWidth = subGridSize * 10; // 기본 위젯: 서브그리드 10칸 let defaultHeight = subGridSize * 10; // 기본 위젯: 서브그리드 10칸 @@ -550,7 +550,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D {/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */}
Date: Wed, 22 Oct 2025 16:49:57 +0900 Subject: [PATCH 03/15] =?UTF-8?q?=EC=8A=A4=EB=83=85=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/admin/dashboard/gridUtils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/components/admin/dashboard/gridUtils.ts b/frontend/components/admin/dashboard/gridUtils.ts index a9aaeec8..083f819b 100644 --- a/frontend/components/admin/dashboard/gridUtils.ts +++ b/frontend/components/admin/dashboard/gridUtils.ts @@ -259,8 +259,8 @@ export function findNearestGuideline( return { nearest, distance: minDistance }; } -// 자석 스냅 (10px 이내면 스냅) +// 강제 스냅 (항상 가장 가까운 가이드라인에 스냅) export function magneticSnap(value: number, guidelines: number[]): number { - const { nearest, distance } = findNearestGuideline(value, guidelines); - return distance <= GRID_CONFIG.SNAP_DISTANCE ? nearest : value; + const { nearest } = findNearestGuideline(value, guidelines); + return nearest; // 거리 체크 없이 무조건 스냅 } From 41c763c0196be01563b12101f848d4d359ecd2b1 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 22 Oct 2025 16:58:07 +0900 Subject: [PATCH 04/15] =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EB=B0=95?= =?UTF-8?q?=EC=8A=A4=20=EA=B8=B0=EB=B0=98=20=EC=8A=A4=EB=83=85=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/CanvasElement.tsx | 8 +-- .../admin/dashboard/DashboardDesigner.tsx | 36 +++++++++----- .../components/admin/dashboard/gridUtils.ts | 49 +++++++++++-------- 3 files changed, 56 insertions(+), 37 deletions(-) diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 6214c8cb..2ecf8245 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -4,7 +4,7 @@ import React, { useState, useCallback, useRef, useEffect } from "react"; import dynamic from "next/dynamic"; import { DashboardElement, QueryResult, Position } from "./types"; import { ChartRenderer } from "./charts/ChartRenderer"; -import { GRID_CONFIG, magneticSnap } from "./gridUtils"; +import { GRID_CONFIG, magneticSnap, snapSizeToGrid } from "./gridUtils"; // 위젯 동적 임포트 const WeatherWidget = dynamic(() => import("@/components/dashboard/widgets/WeatherWidget"), { @@ -378,9 +378,9 @@ export function CanvasElement({ const snappedX = magneticSnap(newX, verticalGuidelines); const snappedY = magneticSnap(newY, horizontalGuidelines); - // 크기는 12px 단위로 스냅 - const snappedWidth = Math.round(newWidth / 12) * 12; - const snappedHeight = Math.round(newHeight / 12) * 12; + // 크기는 그리드 박스 단위로 스냅 + const snappedWidth = snapSizeToGrid(newWidth, canvasWidth || 1560); + const snappedHeight = snapSizeToGrid(newHeight, canvasWidth || 1560); // 스냅 후 경계 체크 const finalSnappedX = Math.max(0, Math.min(snappedX, canvasWidth - snappedWidth)); diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 36601623..83097678 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -7,7 +7,14 @@ import { DashboardTopMenu } from "./DashboardTopMenu"; import { ElementConfigSidebar } from "./ElementConfigSidebar"; import { DashboardSaveModal } from "./DashboardSaveModal"; import { DashboardElement, ElementType, ElementSubtype } from "./types"; -import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateGridConfig } from "./gridUtils"; +import { + GRID_CONFIG, + snapToGrid, + snapSizeToGrid, + calculateCellSize, + calculateGridConfig, + calculateBoxSize, +} from "./gridUtils"; import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector"; import { DashboardProvider } from "@/contexts/DashboardContext"; import { useMenu } from "@/contexts/MenuContext"; @@ -89,8 +96,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D // 그리드에 스냅 (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); + const snappedWidth = snapSizeToGrid(scaledWidth, newConfig.width); + const snappedHeight = snapSizeToGrid(el.size.height, newConfig.width); return { ...el, @@ -215,22 +222,25 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D return; } - // 기본 크기 설정 (서브그리드 기준) - const gridConfig = calculateGridConfig(canvasConfig.width); - const subGridSize = gridConfig.SUB_GRID_SIZE; + // 기본 크기 설정 (그리드 박스 단위) + const boxSize = calculateBoxSize(canvasConfig.width); - // 서브그리드 기준 기본 크기 (픽셀) - let defaultWidth = subGridSize * 10; // 기본 위젯: 서브그리드 10칸 - let defaultHeight = subGridSize * 10; // 기본 위젯: 서브그리드 10칸 + // 그리드 박스 단위 기본 크기 + let boxesWidth = 3; // 기본 위젯: 박스 3개 + let boxesHeight = 3; // 기본 위젯: 박스 3개 if (type === "chart") { - defaultWidth = subGridSize * 20; // 차트: 서브그리드 20칸 - defaultHeight = subGridSize * 15; // 차트: 서브그리드 15칸 + boxesWidth = 4; // 차트: 박스 4개 + boxesHeight = 3; // 차트: 박스 3개 } else if (type === "widget" && subtype === "calendar") { - defaultWidth = subGridSize * 10; // 달력: 서브그리드 10칸 - defaultHeight = subGridSize * 15; // 달력: 서브그리드 15칸 + boxesWidth = 3; // 달력: 박스 3개 + boxesHeight = 4; // 달력: 박스 4개 } + // 박스 개수를 픽셀로 변환 (마지막 간격 제거) + const defaultWidth = boxesWidth * boxSize + (boxesWidth - 1) * GRID_CONFIG.GRID_BOX_GAP; + const defaultHeight = boxesHeight * boxSize + (boxesHeight - 1) * GRID_CONFIG.GRID_BOX_GAP; + // 크기 유효성 검사 if (isNaN(defaultWidth) || isNaN(defaultHeight) || defaultWidth <= 0 || defaultHeight <= 0) { // console.error("Invalid size calculated:", { diff --git a/frontend/components/admin/dashboard/gridUtils.ts b/frontend/components/admin/dashboard/gridUtils.ts index 083f819b..6094aa0e 100644 --- a/frontend/components/admin/dashboard/gridUtils.ts +++ b/frontend/components/admin/dashboard/gridUtils.ts @@ -54,9 +54,11 @@ export function calculateGridConfig(canvasWidth: number) { /** * 실제 그리드 셀 크기 계산 (gap 포함) + * @param canvasWidth - 캔버스 너비 */ -export const getCellWithGap = () => { - return GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP; +export const getCellWithGap = (canvasWidth: number = 1560) => { + const boxSize = calculateBoxSize(canvasWidth); + return boxSize + GRID_CONFIG.GRID_BOX_GAP; }; /** @@ -70,14 +72,14 @@ export const getCanvasWidth = () => { /** * 좌표를 서브 그리드에 스냅 (세밀한 조정 가능) * @param value - 스냅할 좌표값 - * @param subGridSize - 서브 그리드 크기 (선택사항, 기본값: cellSize/3 ≈ 43px) + * @param subGridSize - 서브 그리드 크기 (선택사항) * @returns 스냅된 좌표값 */ export const snapToGrid = (value: number, subGridSize?: number): number => { - // 서브 그리드 크기가 지정되지 않으면 기본 그리드 크기의 1/3 사용 (3x3 서브그리드) - const snapSize = subGridSize ?? Math.floor(GRID_CONFIG.CELL_SIZE / 3); + // 서브 그리드 크기가 지정되지 않으면 기본 박스 크기 사용 + const snapSize = subGridSize ?? calculateBoxSize(1560); - // 서브 그리드 단위로 스냅 + // 그리드 단위로 스냅 const gridIndex = Math.round(value / snapSize); return gridIndex * snapSize; }; @@ -88,8 +90,9 @@ export const snapToGrid = (value: number, subGridSize?: number): number => { * @param cellSize - 셀 크기 (선택사항) * @returns 스냅된 좌표값 (임계값 내에 있으면 스냅, 아니면 원래 값) */ -export const snapToGridWithThreshold = (value: number, cellSize: number = GRID_CONFIG.CELL_SIZE): number => { - const snapped = snapToGrid(value, cellSize); +export const snapToGridWithThreshold = (value: number, cellSize?: number): number => { + const snapSize = cellSize ?? calculateBoxSize(1560); + const snapped = snapToGrid(value, snapSize); const distance = Math.abs(value - snapped); return distance <= GRID_CONFIG.SNAP_THRESHOLD ? snapped : value; @@ -102,15 +105,7 @@ export const snapToGridWithThreshold = (value: number, cellSize: number = GRID_C * @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; -}; +// 기존 snapSizeToGrid 제거 - 새 버전은 269번 줄에 있음 /** * 위치와 크기를 모두 그리드에 스냅 @@ -142,9 +137,10 @@ export const snapBoundsToGrid = (bounds: GridBounds, canvasWidth?: number, canva let snappedX = snapToGrid(bounds.position.x); let snappedY = snapToGrid(bounds.position.y); - // 크기 스냅 - const snappedWidth = snapSizeToGrid(bounds.size.width); - const snappedHeight = snapSizeToGrid(bounds.size.height); + // 크기 스냅 (canvasWidth 기본값 1560) + const width = canvasWidth || 1560; + const snappedWidth = snapSizeToGrid(bounds.size.width, width); + const snappedHeight = snapSizeToGrid(bounds.size.height, width); // 캔버스 경계 체크 if (canvasWidth) { @@ -264,3 +260,16 @@ export function magneticSnap(value: number, guidelines: number[]): number { const { nearest } = findNearestGuideline(value, guidelines); return nearest; // 거리 체크 없이 무조건 스냅 } + +// 크기를 그리드 박스 단위로 스냅 (박스 크기의 배수로만 가능) +export function snapSizeToGrid(size: number, canvasWidth: number): number { + const boxSize = calculateBoxSize(canvasWidth); + const cellSize = boxSize + GRID_CONFIG.GRID_BOX_GAP; // 박스 + 간격 + + // 최소 1개 박스 크기 + const minBoxes = 1; + const boxes = Math.max(minBoxes, Math.round(size / cellSize)); + + // 박스 개수에서 마지막 간격 제거 + return boxes * boxSize + (boxes - 1) * GRID_CONFIG.GRID_BOX_GAP; +} From 9bd84f898a96f2225c8b03331c3ada217c5d17af Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 23 Oct 2025 09:36:30 +0900 Subject: [PATCH 05/15] =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=EC=97=90=20=EB=93=9C=EB=9E=98=EA=B7=B8=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/CanvasElement.tsx | 58 +++++++------------ 1 file changed, 22 insertions(+), 36 deletions(-) diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 2ecf8245..3db56497 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -311,7 +311,7 @@ export function CanvasElement({ const deltaX = e.clientX - dragStartRef.current.x; const deltaY = e.clientY - dragStartRef.current.y + scrollDelta; // 🔥 스크롤 변화량 반영 - // 임시 위치 계산 + // 임시 위치 계산 (드래그 중에는 부드럽게 이동) let rawX = Math.max(0, dragStart.elementX + deltaX); const rawY = Math.max(0, dragStart.elementY + deltaY); @@ -319,18 +319,12 @@ export function CanvasElement({ const maxX = canvasWidth - element.size.width; rawX = Math.min(rawX, maxX); - // 자석 스냅으로 변경 - const snappedX = magneticSnap(rawX, verticalGuidelines); - const snappedY = magneticSnap(rawY, horizontalGuidelines); - - // 스냅 후 X 좌표 다시 체크 - const finalSnappedX = Math.min(snappedX, maxX); - - setTempPosition({ x: finalSnappedX, y: snappedY }); + // 드래그 중에는 스냅 없이 부드럽게 이동 + setTempPosition({ x: rawX, y: rawY }); // 🔥 다중 드래그 중 - 다른 위젯들의 위치 업데이트 if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragMove) { - onMultiDragMove(element, { x: finalSnappedX, y: snappedY }); + onMultiDragMove(element, { x: rawX, y: rawY }); } } else if (isResizing) { const deltaX = e.clientX - resizeStart.x; @@ -374,21 +368,13 @@ export function CanvasElement({ const maxWidth = canvasWidth - newX; newWidth = Math.min(newWidth, maxWidth); - // 자석 스냅으로 변경 - const snappedX = magneticSnap(newX, verticalGuidelines); - const snappedY = magneticSnap(newY, horizontalGuidelines); + // 리사이즈 중에는 스냅 없이 부드럽게 조절 + const boundedX = Math.max(0, Math.min(newX, canvasWidth - newWidth)); + const boundedY = Math.max(0, newY); - // 크기는 그리드 박스 단위로 스냅 - const snappedWidth = snapSizeToGrid(newWidth, canvasWidth || 1560); - const snappedHeight = snapSizeToGrid(newHeight, canvasWidth || 1560); - - // 스냅 후 경계 체크 - const finalSnappedX = Math.max(0, Math.min(snappedX, canvasWidth - snappedWidth)); - const finalSnappedY = Math.max(0, snappedY); - - // 임시 크기/위치 저장 (스냅됨) - setTempPosition({ x: finalSnappedX, y: finalSnappedY }); - setTempSize({ width: snappedWidth, height: snappedHeight }); + // 임시 크기/위치 저장 (부드러운 이동) + setTempPosition({ x: boundedX, y: boundedY }); + setTempSize({ width: newWidth, height: newHeight }); } }, [ @@ -412,10 +398,9 @@ export function CanvasElement({ // 마우스 업 처리 (이미 스냅된 위치 사용) const handleMouseUp = useCallback(() => { if (isDragging && tempPosition) { - // tempPosition은 이미 드래그 중에 마그네틱 스냅 적용됨 - // 다시 스냅하지 않고 그대로 사용! - let finalX = tempPosition.x; - const finalY = tempPosition.y; + // 마우스를 놓을 때 그리드에 스냅 + let finalX = magneticSnap(tempPosition.x, verticalGuidelines); + const finalY = magneticSnap(tempPosition.y, horizontalGuidelines); // X 좌표가 캔버스 너비를 벗어나지 않도록 최종 제한 const maxX = canvasWidth - element.size.width; @@ -473,20 +458,19 @@ export function CanvasElement({ } if (isResizing && tempPosition && tempSize) { - // tempPosition과 tempSize는 이미 리사이즈 중에 마그네틱 스냅 적용됨 - // 다시 스냅하지 않고 그대로 사용! - const finalX = tempPosition.x; - const finalY = tempPosition.y; - let finalWidth = tempSize.width; - const finalHeight = tempSize.height; + // 마우스를 놓을 때 그리드에 스냅 + const finalX = magneticSnap(tempPosition.x, verticalGuidelines); + const finalY = magneticSnap(tempPosition.y, horizontalGuidelines); + const finalWidth = snapSizeToGrid(tempSize.width, canvasWidth || 1560); + const finalHeight = snapSizeToGrid(tempSize.height, canvasWidth || 1560); // 가로 너비가 캔버스를 벗어나지 않도록 최종 제한 const maxWidth = canvasWidth - finalX; - finalWidth = Math.min(finalWidth, maxWidth); + const boundedWidth = Math.min(finalWidth, maxWidth); onUpdate(element.id, { position: { x: finalX, y: finalY }, - size: { width: finalWidth, height: finalHeight }, + size: { width: boundedWidth, height: finalHeight }, }); setTempPosition(null); @@ -518,6 +502,8 @@ export function CanvasElement({ allElements, dragStart.elementX, dragStart.elementY, + verticalGuidelines, + horizontalGuidelines, ]); // 🔥 자동 스크롤 루프 (requestAnimationFrame 사용) From 298fd11169c61ced8cc5b8e8c18cba847c9c59c5 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 23 Oct 2025 09:52:14 +0900 Subject: [PATCH 06/15] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/DashboardDesigner.tsx | 46 +++++++++++++------ .../components/dashboard/DashboardViewer.tsx | 42 +++++++++-------- 2 files changed, 55 insertions(+), 33 deletions(-) diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 83097678..0f387301 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -72,7 +72,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D // 화면 해상도 자동 감지 const [screenResolution] = useState(() => detectScreenResolution()); - const [resolution, setResolution] = useState(screenResolution); + const [resolution, setResolution] = useState("fhd"); // 초기값은 FHD, 로드 시 덮어씀 // resolution 변경 감지 및 요소 자동 조정 const handleResolutionChange = useCallback( @@ -171,23 +171,19 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D settings, resolution: settings?.resolution, backgroundColor: settings?.backgroundColor, - currentResolution: resolution, }); - if (settings?.resolution) { - setResolution(settings.resolution); - console.log("✅ Resolution 설정됨:", settings.resolution); - } else { - console.log("⚠️ Resolution 없음, 기본값 유지:", resolution); - } - + // 배경색 설정 if (settings?.backgroundColor) { setCanvasBackgroundColor(settings.backgroundColor); console.log("✅ BackgroundColor 설정됨:", settings.backgroundColor); - } else { - console.log("⚠️ BackgroundColor 없음, 기본값 유지:", canvasBackgroundColor); } + // 해상도와 요소를 함께 설정 (해상도가 먼저 반영되어야 함) + const loadedResolution = settings?.resolution || "fhd"; + setResolution(loadedResolution); + console.log("✅ Resolution 설정됨:", loadedResolution); + // 요소들 설정 if (dashboard.elements && dashboard.elements.length > 0) { setElements(dashboard.elements); @@ -432,8 +428,15 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D id: el.id, type: el.type, subtype: el.subtype, - position: el.position, - size: el.size, + // 위치와 크기는 정수로 반올림 (DB integer 타입) + position: { + x: Math.round(el.position.x), + y: Math.round(el.position.y), + }, + size: { + width: Math.round(el.size.width), + height: Math.round(el.size.height), + }, title: el.title, customTitle: el.customTitle, showHeader: el.showHeader, @@ -459,6 +462,12 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D }, }; + console.log("💾 대시보드 업데이트 요청:", { + dashboardId, + updateData, + elementsCount: elementsData.length, + }); + savedDashboard = await dashboardApi.updateDashboard(dashboardId, updateData); } else { // 새 대시보드 생성 @@ -519,7 +528,18 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D // 성공 모달 표시 setSuccessModalOpen(true); } catch (error) { + console.error("❌ 대시보드 저장 실패:", error); const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류"; + + // 상세한 에러 정보 로깅 + if (error instanceof Error) { + console.error("Error details:", { + message: error.message, + stack: error.stack, + name: error.name, + }); + } + alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}`); throw error; } diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index 5438b0c0..f784c76f 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -328,26 +328,28 @@ export function DashboardViewer({
) : ( // 데스크톱: 기존 고정 캔버스 레이아웃 -
-
- {sortedElements.map((element) => ( - loadElementData(element)} - isMobile={false} - /> - ))} +
+
+
+ {sortedElements.map((element) => ( + loadElementData(element)} + isMobile={false} + /> + ))} +
)} From 73e3bf4159c24f2a8bb22f0a59ccc9805ead27d5 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 23 Oct 2025 09:56:35 +0900 Subject: [PATCH 07/15] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EA=B0=90=EC=A7=80=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/DashboardCanvas.tsx | 4 ++-- .../admin/dashboard/DashboardDesigner.tsx | 10 +++++++- .../admin/dashboard/ResolutionSelector.tsx | 24 +++++++++++++++---- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/frontend/components/admin/dashboard/DashboardCanvas.tsx b/frontend/components/admin/dashboard/DashboardCanvas.tsx index ac0d69a4..c77cf541 100644 --- a/frontend/components/admin/dashboard/DashboardCanvas.tsx +++ b/frontend/components/admin/dashboard/DashboardCanvas.tsx @@ -492,9 +492,9 @@ export const DashboardCanvas = forwardRef( }} /> ))} */} - {/* 그리드 박스들 (12px 간격, 캔버스 너비에 꽉 차게) */} + {/* 그리드 박스들 (12px 간격, 캔버스 너비에 꽉 차게, 마지막 행 제외) */} {verticalGuidelines.map((x, xIdx) => - horizontalGuidelines.map((y, yIdx) => ( + horizontalGuidelines.slice(0, -1).map((y, yIdx) => (
(() => detectScreenResolution()); - const [resolution, setResolution] = useState("fhd"); // 초기값은 FHD, 로드 시 덮어씀 + const [resolution, setResolution] = useState(() => { + // 새 대시보드인 경우 (dashboardId 없음) 화면 해상도 감지값 사용 + // 기존 대시보드 편집인 경우 FHD로 시작 (로드 시 덮어씀) + return initialDashboardId ? "fhd" : detectScreenResolution(); + }); // resolution 변경 감지 및 요소 자동 조정 const handleResolutionChange = useCallback( @@ -143,8 +147,12 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D // 대시보드 ID가 props로 전달되면 로드 React.useEffect(() => { if (initialDashboardId) { + console.log("📝 기존 대시보드 편집 모드"); loadDashboard(initialDashboardId); + } else { + console.log("✨ 새 대시보드 생성 모드 - 감지된 해상도:", resolution); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialDashboardId]); // 대시보드 데이터 로드 diff --git a/frontend/components/admin/dashboard/ResolutionSelector.tsx b/frontend/components/admin/dashboard/ResolutionSelector.tsx index 5f5bda53..62ba377d 100644 --- a/frontend/components/admin/dashboard/ResolutionSelector.tsx +++ b/frontend/components/admin/dashboard/ResolutionSelector.tsx @@ -57,11 +57,27 @@ export function detectScreenResolution(): Resolution { const width = window.screen.width; const height = window.screen.height; + let detectedResolution: Resolution; + // 화면 해상도에 따라 적절한 캔버스 해상도 반환 - if (width >= 3840 || height >= 2160) return "uhd"; - if (width >= 2560 || height >= 1440) return "qhd"; - if (width >= 1920 || height >= 1080) return "fhd"; - return "hd"; + if (width >= 3840 || height >= 2160) { + detectedResolution = "uhd"; + } else if (width >= 2560 || height >= 1440) { + detectedResolution = "qhd"; + } else if (width >= 1920 || height >= 1080) { + detectedResolution = "fhd"; + } else { + detectedResolution = "hd"; + } + + console.log("🖥️ 화면 해상도 자동 감지:", { + screenWidth: width, + screenHeight: height, + detectedResolution, + canvasSize: RESOLUTIONS[detectedResolution], + }); + + return detectedResolution; } /** From d29d4b596db28ee08240696f5629cdb6067c0ab3 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 23 Oct 2025 10:06:00 +0900 Subject: [PATCH 08/15] =?UTF-8?q?=ED=99=94=EB=A9=B4=EB=84=88=EB=B9=84=20?= =?UTF-8?q?=EA=B0=90=EC=A7=80=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/ResolutionSelector.tsx | 41 +++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/frontend/components/admin/dashboard/ResolutionSelector.tsx b/frontend/components/admin/dashboard/ResolutionSelector.tsx index 62ba377d..33b444f0 100644 --- a/frontend/components/admin/dashboard/ResolutionSelector.tsx +++ b/frontend/components/admin/dashboard/ResolutionSelector.tsx @@ -54,25 +54,50 @@ interface ResolutionSelectorProps { export function detectScreenResolution(): Resolution { if (typeof window === "undefined") return "fhd"; - const width = window.screen.width; - const height = window.screen.height; + // 1. 브라우저 뷰포트 크기 (실제 사용 가능한 공간) + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + // 2. 화면 해상도 + devicePixelRatio (Retina 디스플레이 대응) + const pixelRatio = window.devicePixelRatio || 1; + const physicalWidth = window.screen.width; + const physicalHeight = window.screen.height; + const logicalWidth = physicalWidth / pixelRatio; + const logicalHeight = physicalHeight / pixelRatio; let detectedResolution: Resolution; - // 화면 해상도에 따라 적절한 캔버스 해상도 반환 - if (width >= 3840 || height >= 2160) { + // 뷰포트와 논리적 해상도 중 더 큰 값을 기준으로 결정 + // (크램쉘 모드나 특수한 경우에도 대응) + const effectiveWidth = Math.max(viewportWidth, logicalWidth); + const effectiveHeight = Math.max(viewportHeight, logicalHeight); + + // 캔버스가 여유있게 들어갈 수 있는 크기로 결정 + // 여유 공간: 좌우 패딩, 사이드바 등을 고려하여 약 400-500px 여유 + if (effectiveWidth >= 3400) { + // UHD 캔버스 2940px + 여유 460px detectedResolution = "uhd"; - } else if (width >= 2560 || height >= 1440) { + } else if (effectiveWidth >= 2400) { + // QHD 캔버스 1960px + 여유 440px detectedResolution = "qhd"; - } else if (width >= 1920 || height >= 1080) { + } else if (effectiveWidth >= 1900) { + // FHD 캔버스 1560px + 여유 340px detectedResolution = "fhd"; } else { + // HD 캔버스 1160px 이하 detectedResolution = "hd"; } console.log("🖥️ 화면 해상도 자동 감지:", { - screenWidth: width, - screenHeight: height, + viewportWidth, + viewportHeight, + physicalWidth, + physicalHeight, + pixelRatio, + logicalWidth: Math.round(logicalWidth), + logicalHeight: Math.round(logicalHeight), + effectiveWidth: Math.round(effectiveWidth), + effectiveHeight: Math.round(effectiveHeight), detectedResolution, canvasSize: RESOLUTIONS[detectedResolution], }); From 1b6d63bf74aac56219208e2a9eccf2f2bfabfc80 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 23 Oct 2025 12:50:38 +0900 Subject: [PATCH 09/15] =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=ED=96=89?= =?UTF-8?q?=20=EC=A4=84=EB=AC=B4=EB=8A=AC=20=EC=9E=AC=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/admin/dashboard/widgets/ListWidget.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/admin/dashboard/widgets/ListWidget.tsx b/frontend/components/admin/dashboard/widgets/ListWidget.tsx index e3a2a7e7..378d8825 100644 --- a/frontend/components/admin/dashboard/widgets/ListWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/ListWidget.tsx @@ -255,7 +255,7 @@ export function ListWidget({ element }: ListWidgetProps) { ) : ( paginatedRows.map((row, idx) => ( - + {displayColumns .filter((col) => col.visible) .map((col) => ( From a4473eee3343128b62974ee8bc6563b1361fe3fe Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 23 Oct 2025 12:54:46 +0900 Subject: [PATCH 10/15] =?UTF-8?q?=EC=A0=81=EC=9A=A9=20=EB=B2=84=ED=8A=BC?= =?UTF-8?q?=20=EB=88=8C=EB=A0=80=EC=9D=84=20=EB=95=8C=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=20=EC=9C=84=EC=B9=98,=20=EC=B4=88=EA=B8=B0=20=ED=81=AC?= =?UTF-8?q?=EA=B8=B0=EB=A1=9C=20=EB=90=98=EB=8F=8C=EC=95=84=EA=B0=80?= =?UTF-8?q?=EB=8A=94=20=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/DashboardDesigner.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index ee90063c..7d7e3cd6 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -392,12 +392,21 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D // 사이드바 적용 const handleApplySidebar = useCallback( (updatedElement: DashboardElement) => { - updateElement(updatedElement.id, updatedElement); - // 사이드바는 열린 채로 유지하여 연속 수정 가능 - // 단, sidebarElement도 업데이트해서 최신 상태 반영 - setSidebarElement(updatedElement); + // 현재 요소의 최신 상태를 가져와서 position과 size는 유지 + const currentElement = elements.find((el) => el.id === updatedElement.id); + if (currentElement) { + // position과 size는 현재 상태 유지, 나머지만 업데이트 + const finalElement = { + ...updatedElement, + position: currentElement.position, + size: currentElement.size, + }; + updateElement(finalElement.id, finalElement); + // 사이드바도 최신 상태로 업데이트 + setSidebarElement(finalElement); + } }, - [updateElement], + [elements, updateElement], ); // 레이아웃 저장 From ce7f02409c7e34b666fb6686dfc46ed171e4826a Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 23 Oct 2025 12:59:45 +0900 Subject: [PATCH 11/15] =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EC=9A=94=EC=86=8C=20=EB=93=9C=EB=9E=98=EA=B1=B0=EB=B8=94=20?= =?UTF-8?q?=EC=86=8D=EC=84=B1=20=EB=B2=94=EC=9C=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../list-widget/UnifiedColumnEditor.tsx | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/list-widget/UnifiedColumnEditor.tsx b/frontend/components/admin/dashboard/widgets/list-widget/UnifiedColumnEditor.tsx index 53eb30b9..2ddaafdc 100644 --- a/frontend/components/admin/dashboard/widgets/list-widget/UnifiedColumnEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/list-widget/UnifiedColumnEditor.tsx @@ -119,22 +119,13 @@ export function UnifiedColumnEditor({ queryResult, config, onConfigChange }: Uni return (
{ - handleDragStart(index); - e.currentTarget.style.cursor = "grabbing"; - }} onDragOver={(e) => handleDragOver(e, index)} onDrop={handleDrop} - onDragEnd={(e) => { - handleDragEnd(); - e.currentTarget.style.cursor = "grab"; - }} className={`group relative rounded-md border transition-all ${ col.visible ? "border-primary/40 bg-primary/5 shadow-sm" : "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm" - } cursor-grab active:cursor-grabbing ${draggedIndex === index ? "scale-95 opacity-50" : ""}`} + } ${draggedIndex === index ? "scale-95 opacity-50" : ""}`} > {/* 헤더 */}
@@ -143,7 +134,20 @@ export function UnifiedColumnEditor({ queryResult, config, onConfigChange }: Uni onCheckedChange={() => handleToggle(col.id)} className="data-[state=checked]:bg-primary data-[state=checked]:border-primary h-4 w-4 shrink-0 rounded-full" /> - +
{ + handleDragStart(index); + e.currentTarget.style.cursor = "grabbing"; + }} + onDragEnd={(e) => { + handleDragEnd(); + e.currentTarget.style.cursor = "grab"; + }} + className="cursor-grab active:cursor-grabbing" + > + +
From 60ef6a6a959f2c233d3a969bef5965d54a591fc7 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 23 Oct 2025 13:30:13 +0900 Subject: [PATCH 12/15] =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/DashboardSidebar.tsx | 264 ------------------ .../admin/dashboard/DashboardTopMenu.tsx | 2 +- 2 files changed, 1 insertion(+), 265 deletions(-) delete mode 100644 frontend/components/admin/dashboard/DashboardSidebar.tsx diff --git a/frontend/components/admin/dashboard/DashboardSidebar.tsx b/frontend/components/admin/dashboard/DashboardSidebar.tsx deleted file mode 100644 index 62c50fdc..00000000 --- a/frontend/components/admin/dashboard/DashboardSidebar.tsx +++ /dev/null @@ -1,264 +0,0 @@ -"use client"; - -import React, { useState } from "react"; -import { DragData, ElementType, ElementSubtype } from "./types"; -import { ChevronDown, ChevronRight } from "lucide-react"; - -/** - * 대시보드 사이드바 컴포넌트 - * - 드래그 가능한 차트/위젯 목록 - * - 아코디언 방식으로 카테고리별 구분 - */ -export function DashboardSidebar() { - const [expandedSections, setExpandedSections] = useState({ - charts: true, - widgets: true, - operations: true, - }); - - // 섹션 토글 - const toggleSection = (section: keyof typeof expandedSections) => { - setExpandedSections((prev) => ({ ...prev, [section]: !prev[section] })); - }; - - // 드래그 시작 처리 - const handleDragStart = (e: React.DragEvent, type: ElementType, subtype: ElementSubtype) => { - const dragData: DragData = { type, subtype }; - e.dataTransfer.setData("application/json", JSON.stringify(dragData)); - e.dataTransfer.effectAllowed = "copy"; - }; - - return ( -
- {/* 차트 섹션 */} -
- - - {expandedSections.charts && ( -
- - - - - - - - -
- )} -
- - {/* 위젯 섹션 */} -
- - - {expandedSections.widgets && ( -
- - - - - - {/* */} - - - - -
- )} -
- - {/* 운영/작업 지원 섹션 */} -
- - - {expandedSections.operations && ( -
- - {/* 예약알림 위젯 - 필요시 주석 해제 */} - {/* */} - {/* 정비 일정 관리 위젯 제거 - 커스텀 목록 카드로 대체 가능 */} - - - - -
- )} -
-
- ); -} - -interface DraggableItemProps { - icon?: string; - title: string; - type: ElementType; - subtype: ElementSubtype; - className?: string; - onDragStart: (e: React.DragEvent, type: ElementType, subtype: ElementSubtype) => void; -} - -/** - * 드래그 가능한 아이템 컴포넌트 - */ -function DraggableItem({ title, type, subtype, className = "", onDragStart }: DraggableItemProps) { - return ( -
onDragStart(e, type, subtype)} - > - {title} -
- ); -} diff --git a/frontend/components/admin/dashboard/DashboardTopMenu.tsx b/frontend/components/admin/dashboard/DashboardTopMenu.tsx index f2a11b29..fdac3319 100644 --- a/frontend/components/admin/dashboard/DashboardTopMenu.tsx +++ b/frontend/components/admin/dashboard/DashboardTopMenu.tsx @@ -198,7 +198,7 @@ export function DashboardTopMenu({ 할 일 {/* 예약 알림 */} 정비 일정 - 문서 + {/* 문서 */} 리스크 알림 {/* 범용 위젯으로 대체 가능하여 주석처리 */} From 6422dac2a4acee9af0a90b8c7937829ec1cdded6 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 23 Oct 2025 14:24:41 +0900 Subject: [PATCH 13/15] =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=BB=A4?= =?UTF-8?q?=EC=8A=A4=ED=85=80=20=EC=B9=B4=EB=93=9C=20=EC=9C=84=EC=A0=AF=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/services/DashboardService.ts | 15 +- backend-node/src/types/dashboard.ts | 11 + .../admin/dashboard/CanvasElement.tsx | 11 + .../admin/dashboard/DashboardDesigner.tsx | 1 + .../admin/dashboard/DashboardTopMenu.tsx | 7 +- .../admin/dashboard/ElementConfigSidebar.tsx | 15 + frontend/components/admin/dashboard/types.ts | 17 +- .../CustomMetricConfigSidebar.tsx | 392 ++++++++++++++++++ .../components/dashboard/DashboardViewer.tsx | 6 + .../dashboard/widgets/CustomMetricWidget.tsx | 193 +++++++++ 10 files changed, 659 insertions(+), 9 deletions(-) create mode 100644 frontend/components/admin/dashboard/widgets/custom-metric/CustomMetricConfigSidebar.tsx create mode 100644 frontend/components/dashboard/widgets/CustomMetricWidget.tsx diff --git a/backend-node/src/services/DashboardService.ts b/backend-node/src/services/DashboardService.ts index 68cc582f..92b5ed39 100644 --- a/backend-node/src/services/DashboardService.ts +++ b/backend-node/src/services/DashboardService.ts @@ -63,9 +63,9 @@ export class DashboardService { id, dashboard_id, element_type, element_subtype, position_x, position_y, width, height, title, custom_title, show_header, content, data_source_config, chart_config, - list_config, yard_config, + list_config, yard_config, custom_metric_config, display_order, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) `, [ elementId, @@ -84,6 +84,7 @@ export class DashboardService { JSON.stringify(element.chartConfig || {}), JSON.stringify(element.listConfig || null), JSON.stringify(element.yardConfig || null), + JSON.stringify(element.customMetricConfig || null), i, now, now, @@ -391,6 +392,11 @@ export class DashboardService { ? JSON.parse(row.yard_config) : row.yard_config : undefined, + customMetricConfig: row.custom_metric_config + ? typeof row.custom_metric_config === "string" + ? JSON.parse(row.custom_metric_config) + : row.custom_metric_config + : undefined, }) ); @@ -514,9 +520,9 @@ export class DashboardService { id, dashboard_id, element_type, element_subtype, position_x, position_y, width, height, title, custom_title, show_header, content, data_source_config, chart_config, - list_config, yard_config, + list_config, yard_config, custom_metric_config, display_order, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) `, [ elementId, @@ -535,6 +541,7 @@ export class DashboardService { JSON.stringify(element.chartConfig || {}), JSON.stringify(element.listConfig || null), JSON.stringify(element.yardConfig || null), + JSON.stringify(element.customMetricConfig || null), i, now, now, diff --git a/backend-node/src/types/dashboard.ts b/backend-node/src/types/dashboard.ts index b03acbff..7d6267a7 100644 --- a/backend-node/src/types/dashboard.ts +++ b/backend-node/src/types/dashboard.ts @@ -45,6 +45,17 @@ export interface DashboardElement { layoutId: number; layoutName?: string; }; + customMetricConfig?: { + metrics: Array<{ + id: string; + field: string; + label: string; + aggregation: "count" | "sum" | "avg" | "min" | "max"; + unit: string; + color: "indigo" | "green" | "blue" | "purple" | "orange" | "gray"; + decimals: number; + }>; + }; } export interface Dashboard { diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 3db56497..27a20dcb 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -126,6 +126,12 @@ const CustomStatsWidget = dynamic(() => import("@/components/dashboard/widgets/C loading: () =>
로딩 중...
, }); +// 사용자 커스텀 카드 위젯 +const CustomMetricWidget = dynamic(() => import("@/components/dashboard/widgets/CustomMetricWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + interface CanvasElementProps { element: DashboardElement; isSelected: boolean; @@ -915,6 +921,11 @@ export function CanvasElement({
+ ) : element.type === "widget" && element.subtype === "custom-metric" ? ( + // 사용자 커스텀 카드 위젯 렌더링 +
+ +
) : element.type === "widget" && element.subtype === "todo" ? ( // To-Do 위젯 렌더링
diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 7d7e3cd6..5b39a8f7 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -462,6 +462,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D chartConfig: el.chartConfig, listConfig: el.listConfig, yardConfig: el.yardConfig, + customMetricConfig: el.customMetricConfig, }; }); diff --git a/frontend/components/admin/dashboard/DashboardTopMenu.tsx b/frontend/components/admin/dashboard/DashboardTopMenu.tsx index fdac3319..b5357dd2 100644 --- a/frontend/components/admin/dashboard/DashboardTopMenu.tsx +++ b/frontend/components/admin/dashboard/DashboardTopMenu.tsx @@ -181,12 +181,11 @@ export function DashboardTopMenu({ 데이터 위젯 리스트 위젯 + 사용자 커스텀 카드 야드 관리 3D - 커스텀 통계 카드 - {/* 지도 */} + {/* 커스텀 통계 카드 */} 커스텀 지도 카드 - {/* 커스텀 목록 카드 */} - 커스텀 상태 카드 + {/* 커스텀 상태 카드 */} 일반 위젯 diff --git a/frontend/components/admin/dashboard/ElementConfigSidebar.tsx b/frontend/components/admin/dashboard/ElementConfigSidebar.tsx index 97332944..e661eead 100644 --- a/frontend/components/admin/dashboard/ElementConfigSidebar.tsx +++ b/frontend/components/admin/dashboard/ElementConfigSidebar.tsx @@ -12,6 +12,7 @@ import { YardWidgetConfigSidebar } from "./widgets/YardWidgetConfigSidebar"; import { X } from "lucide-react"; import { cn } from "@/lib/utils"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import CustomMetricConfigSidebar from "./widgets/custom-metric/CustomMetricConfigSidebar"; interface ElementConfigSidebarProps { element: DashboardElement | null; @@ -145,6 +146,20 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem ); } + // 사용자 커스텀 카드 위젯은 사이드바로 처리 + if (element.subtype === "custom-metric") { + return ( + { + onApply({ ...element, ...updates }); + }} + /> + ); + } + // 차트 설정이 필요 없는 위젯 (쿼리/API만 필요) const isSimpleWidget = element.subtype === "todo" || diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index 7ae9b4d8..5c82f805 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -38,7 +38,8 @@ export type ElementSubtype = | "list" | "yard-management-3d" // 야드 관리 3D 위젯 | "work-history" // 작업 이력 위젯 - | "transport-stats"; // 커스텀 통계 카드 위젯 + | "transport-stats" // 커스텀 통계 카드 위젯 + | "custom-metric"; // 사용자 커스텀 카드 위젯 export interface Position { x: number; @@ -68,6 +69,7 @@ export interface DashboardElement { driverManagementConfig?: DriverManagementConfig; // 기사 관리 설정 listConfig?: ListWidgetConfig; // 리스트 위젯 설정 yardConfig?: YardManagementConfig; // 야드 관리 3D 설정 + customMetricConfig?: CustomMetricConfig; // 사용자 커스텀 카드 설정 } export interface DragData { @@ -282,3 +284,16 @@ export interface YardManagementConfig { layoutId: number; // 선택된 야드 레이아웃 ID layoutName?: string; // 레이아웃 이름 (표시용) } + +// 사용자 커스텀 카드 설정 +export interface CustomMetricConfig { + metrics: Array<{ + id: string; // 고유 ID + field: string; // 집계할 컬럼명 + label: string; // 표시할 라벨 + aggregation: "count" | "sum" | "avg" | "min" | "max"; // 집계 함수 + unit: string; // 단위 (%, 건, 일, km, 톤 등) + color: "indigo" | "green" | "blue" | "purple" | "orange" | "gray"; + decimals: number; // 소수점 자릿수 + }>; +} diff --git a/frontend/components/admin/dashboard/widgets/custom-metric/CustomMetricConfigSidebar.tsx b/frontend/components/admin/dashboard/widgets/custom-metric/CustomMetricConfigSidebar.tsx new file mode 100644 index 00000000..c08fd501 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/custom-metric/CustomMetricConfigSidebar.tsx @@ -0,0 +1,392 @@ +"use client"; + +import React, { useState } from "react"; +import { DashboardElement, CustomMetricConfig } from "@/components/admin/dashboard/types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { GripVertical, Plus, Trash2, ChevronDown, ChevronUp, X } from "lucide-react"; +import { DatabaseConfig } from "../../data-sources/DatabaseConfig"; +import { ChartDataSource } from "../../types"; +import { ApiConfig } from "../../data-sources/ApiConfig"; +import { QueryEditor } from "../../QueryEditor"; +import { v4 as uuidv4 } from "uuid"; +import { cn } from "@/lib/utils"; + +interface CustomMetricConfigSidebarProps { + element: DashboardElement; + isOpen: boolean; + onClose: () => void; + onApply: (updates: Partial) => void; +} + +export default function CustomMetricConfigSidebar({ + element, + isOpen, + onClose, + onApply, +}: CustomMetricConfigSidebarProps) { + const [metrics, setMetrics] = useState(element.customMetricConfig?.metrics || []); + const [expandedMetric, setExpandedMetric] = useState(null); + const [queryColumns, setQueryColumns] = useState([]); + const [dataSourceType, setDataSourceType] = useState<"database" | "api">(element.dataSource?.type || "database"); + const [dataSource, setDataSource] = useState( + element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 }, + ); + const [draggedIndex, setDraggedIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); + + // 쿼리 실행 결과 처리 + const handleQueryTest = (result: any) => { + // QueryEditor에서 오는 경우: { success: true, data: { columns: [...], rows: [...] } } + if (result.success && result.data?.columns) { + setQueryColumns(result.data.columns); + } + // ApiConfig에서 오는 경우: { columns: [...], data: [...] } 또는 { success: true, columns: [...] } + else if (result.columns && Array.isArray(result.columns)) { + setQueryColumns(result.columns); + } + // 오류 처리 + else { + setQueryColumns([]); + } + }; + + // 메트릭 추가 + const addMetric = () => { + const newMetric = { + id: uuidv4(), + field: "", + label: "새 지표", + aggregation: "count" as const, + unit: "", + color: "gray" as const, + decimals: 1, + }; + setMetrics([...metrics, newMetric]); + setExpandedMetric(newMetric.id); + }; + + // 메트릭 삭제 + const deleteMetric = (id: string) => { + setMetrics(metrics.filter((m) => m.id !== id)); + if (expandedMetric === id) { + setExpandedMetric(null); + } + }; + + // 메트릭 업데이트 + const updateMetric = (id: string, field: string, value: any) => { + setMetrics(metrics.map((m) => (m.id === id ? { ...m, [field]: value } : m))); + }; + + // 메트릭 순서 변경 + // 드래그 앤 드롭 핸들러 + const handleDragStart = (index: number) => { + setDraggedIndex(index); + }; + + const handleDragOver = (e: React.DragEvent, index: number) => { + e.preventDefault(); + setDragOverIndex(index); + }; + + const handleDrop = (e: React.DragEvent, dropIndex: number) => { + e.preventDefault(); + if (draggedIndex === null || draggedIndex === dropIndex) { + setDraggedIndex(null); + setDragOverIndex(null); + return; + } + + const newMetrics = [...metrics]; + const [draggedItem] = newMetrics.splice(draggedIndex, 1); + newMetrics.splice(dropIndex, 0, draggedItem); + + setMetrics(newMetrics); + setDraggedIndex(null); + setDragOverIndex(null); + }; + + const handleDragEnd = () => { + setDraggedIndex(null); + setDragOverIndex(null); + }; + + // 데이터 소스 업데이트 + const handleDataSourceUpdate = (updates: Partial) => { + const newDataSource = { ...dataSource, ...updates }; + setDataSource(newDataSource); + onApply({ dataSource: newDataSource }); + }; + + // 데이터 소스 타입 변경 + const handleDataSourceTypeChange = (type: "database" | "api") => { + setDataSourceType(type); + const newDataSource: ChartDataSource = + type === "database" + ? { type: "database", connectionType: "current", refreshInterval: 0 } + : { type: "api", method: "GET", refreshInterval: 0 }; + + setDataSource(newDataSource); + onApply({ dataSource: newDataSource }); + setQueryColumns([]); + }; + + // 저장 + const handleSave = () => { + onApply({ + customMetricConfig: { + metrics, + }, + }); + }; + + if (!isOpen) return null; + + return ( +
+ {/* 헤더 */} +
+
+
+ 📊 +
+ 커스텀 카드 설정 +
+ +
+ + {/* 본문: 스크롤 가능 영역 */} +
+
+ {/* 데이터 소스 타입 선택 */} +
+
데이터 소스 타입
+
+ + +
+
+ + {/* 데이터 소스 설정 */} + {dataSourceType === "database" ? ( + <> + + + + ) : ( + + )} + + {/* 지표 설정 섹션 - 쿼리 실행 후에만 표시 */} + {queryColumns.length > 0 && ( +
+
+
지표
+ +
+ +
+ {metrics.length === 0 ? ( +

추가된 지표가 없습니다

+ ) : ( + metrics.map((metric, index) => ( +
handleDragOver(e, index)} + onDrop={(e) => handleDrop(e, index)} + className={cn( + "rounded-md border bg-white p-2 transition-all", + draggedIndex === index && "opacity-50", + dragOverIndex === index && draggedIndex !== index && "border-primary border-2", + )} + > + {/* 헤더 */} +
+
handleDragStart(index)} + onDragEnd={handleDragEnd} + className="cursor-grab active:cursor-grabbing" + > + +
+
+ + {metric.label || "새 지표"} + + {metric.aggregation.toUpperCase()} + +
+
+ + {/* 설정 영역 */} + {expandedMetric === metric.id && ( +
+ {/* 2열 그리드 레이아웃 */} +
+ {/* 컬럼 */} +
+ + +
+ + {/* 집계 함수 */} +
+ + +
+ + {/* 단위 */} +
+ + updateMetric(metric.id, "unit", e.target.value)} + className="h-6 w-full text-[10px]" + placeholder="건, %, km" + /> +
+ + {/* 소수점 */} +
+ + +
+
+ + {/* 표시 이름 (전체 너비) */} +
+ + updateMetric(metric.id, "label", e.target.value)} + className="h-6 w-full text-[10px]" + placeholder="라벨" + /> +
+ + {/* 삭제 버튼 */} +
+ +
+
+ )} +
+ )) + )} +
+
+ )} +
+
+ + {/* 푸터 */} +
+ + +
+
+ ); +} diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index f784c76f..3b8b790d 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -51,6 +51,10 @@ const CustomStatsWidget = dynamic(() => import("./widgets/CustomStatsWidget"), { ssr: false, }); +const CustomMetricWidget = dynamic(() => import("./widgets/CustomMetricWidget"), { + ssr: false, +}); + /** * 위젯 렌더링 함수 - DashboardSidebar의 모든 subtype 처리 * ViewerElement에서 사용하기 위해 컴포넌트 외부에 정의 @@ -76,6 +80,8 @@ function renderWidget(element: DashboardElement) { return ; case "status-summary": return ; + case "custom-metric": + return ; // === 운영/작업 지원 === case "todo": diff --git a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx new file mode 100644 index 00000000..b98bd5cb --- /dev/null +++ b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx @@ -0,0 +1,193 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { DashboardElement } from "@/components/admin/dashboard/types"; + +interface CustomMetricWidgetProps { + element?: DashboardElement; +} + +// 집계 함수 실행 +const calculateMetric = (rows: any[], field: string, aggregation: string): number => { + if (rows.length === 0) return 0; + + switch (aggregation) { + case "count": + return rows.length; + case "sum": { + return rows.reduce((sum, row) => sum + (parseFloat(row[field]) || 0), 0); + } + case "avg": { + const sum = rows.reduce((s, row) => s + (parseFloat(row[field]) || 0), 0); + return rows.length > 0 ? sum / rows.length : 0; + } + case "min": { + return Math.min(...rows.map((row) => parseFloat(row[field]) || 0)); + } + case "max": { + return Math.max(...rows.map((row) => parseFloat(row[field]) || 0)); + } + default: + return 0; + } +}; + +// 색상 스타일 매핑 +const colorMap = { + indigo: { bg: "bg-indigo-50", text: "text-indigo-600", border: "border-indigo-200" }, + green: { bg: "bg-green-50", text: "text-green-600", border: "border-green-200" }, + blue: { bg: "bg-blue-50", text: "text-blue-600", border: "border-blue-200" }, + purple: { bg: "bg-purple-50", text: "text-purple-600", border: "border-purple-200" }, + orange: { bg: "bg-orange-50", text: "text-orange-600", border: "border-orange-200" }, + gray: { bg: "bg-gray-50", text: "text-gray-600", border: "border-gray-200" }, +}; + +export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) { + const [metrics, setMetrics] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + console.log("🎯 CustomMetricWidget mounted, element:", element); + console.log("📊 dataSource:", element?.dataSource); + console.log("📈 customMetricConfig:", element?.customMetricConfig); + loadData(); + + // 자동 새로고침 (30초마다) + const interval = setInterval(loadData, 30000); + return () => clearInterval(interval); + }, [element]); + + const loadData = async () => { + try { + setLoading(true); + setError(null); + + // 쿼리나 설정이 없으면 초기 상태로 반환 + if (!element?.dataSource?.query || !element?.customMetricConfig?.metrics) { + console.log("⚠️ 쿼리 또는 지표 설정이 없습니다"); + console.log("- dataSource.query:", element?.dataSource?.query); + console.log("- customMetricConfig.metrics:", element?.customMetricConfig?.metrics); + setMetrics([]); + setLoading(false); + return; + } + + console.log("✅ 쿼리 실행 시작:", element.dataSource.query); + + const token = localStorage.getItem("authToken"); + const response = await fetch("/api/dashboards/execute-query", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + query: element.dataSource.query, + connectionType: element.dataSource.connectionType || "current", + connectionId: element.dataSource.connectionId, + }), + }); + + if (!response.ok) throw new Error("데이터 로딩 실패"); + + const result = await response.json(); + + if (result.success && result.data?.rows) { + const rows = result.data.rows; + + // 각 메트릭 계산 + const calculatedMetrics = element.customMetricConfig.metrics.map((metric) => { + const value = calculateMetric(rows, metric.field, metric.aggregation); + return { + ...metric, + calculatedValue: value, + }; + }); + + setMetrics(calculatedMetrics); + } else { + throw new Error(result.message || "데이터 로드 실패"); + } + } catch (err) { + console.error("메트릭 로드 실패:", err); + setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다"); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( +
+
+
+

데이터 로딩 중...

+
+
+ ); + } + + if (error) { + return ( +
+
+

⚠️ {error}

+ +
+
+ ); + } + + if (!element?.dataSource?.query || !element?.customMetricConfig?.metrics || metrics.length === 0) { + return ( +
+
+

사용자 커스텀 카드

+
+

📊 맞춤형 지표 위젯

+
    +
  • • SQL 쿼리로 데이터를 불러옵니다
  • +
  • • 선택한 컬럼의 데이터로 지표를 계산합니다
  • +
  • • COUNT, SUM, AVG, MIN, MAX 등 집계 함수 지원
  • +
  • • 사용자 정의 단위 설정 가능
  • +
+
+
+

⚙️ 설정 방법

+

SQL 쿼리를 입력하고 지표를 추가하세요

+
+
+
+ ); + } + + return ( +
+ {/* 스크롤 가능한 콘텐츠 영역 */} +
+
+ {metrics.map((metric) => { + const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray; + const formattedValue = metric.calculatedValue.toFixed(metric.decimals); + + return ( +
+
{metric.label}
+
+ {formattedValue} + {metric.unit} +
+
+ ); + })} +
+
+
+ ); +} From afe4074d372d9a9c1cde57308d637e0dcf555c71 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 23 Oct 2025 14:27:27 +0900 Subject: [PATCH 14/15] =?UTF-8?q?=EC=A0=9C=EB=AA=A9=20=EB=B0=8F=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94=20=EC=84=A4=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CustomMetricConfigSidebar.tsx | 49 ++++++++++++++++--- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/custom-metric/CustomMetricConfigSidebar.tsx b/frontend/components/admin/dashboard/widgets/custom-metric/CustomMetricConfigSidebar.tsx index c08fd501..0a1dd39b 100644 --- a/frontend/components/admin/dashboard/widgets/custom-metric/CustomMetricConfigSidebar.tsx +++ b/frontend/components/admin/dashboard/widgets/custom-metric/CustomMetricConfigSidebar.tsx @@ -35,6 +35,8 @@ export default function CustomMetricConfigSidebar({ ); const [draggedIndex, setDraggedIndex] = useState(null); const [dragOverIndex, setDragOverIndex] = useState(null); + const [customTitle, setCustomTitle] = useState(element.customTitle || element.title || ""); + const [showHeader, setShowHeader] = useState(element.showHeader !== false); // 쿼리 실행 결과 처리 const handleQueryTest = (result: any) => { @@ -136,6 +138,8 @@ export default function CustomMetricConfigSidebar({ // 저장 const handleSave = () => { onApply({ + customTitle: customTitle, + showHeader: showHeader, customMetricConfig: { metrics, }, @@ -170,31 +174,64 @@ export default function CustomMetricConfigSidebar({ {/* 본문: 스크롤 가능 영역 */}
+ {/* 헤더 설정 */} +
+
헤더 설정
+
+ {/* 제목 입력 */} +
+ + setCustomTitle(e.target.value)} + placeholder="위젯 제목을 입력하세요" + className="h-8 text-xs" + style={{ fontSize: "12px" }} + /> +
+ + {/* 헤더 표시 여부 */} +
+ + +
+
+
+ {/* 데이터 소스 타입 선택 */}
데이터 소스 타입
From 84ce175d95255487b963f9211029d16733d92e9b Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 23 Oct 2025 14:36:14 +0900 Subject: [PATCH 15/15] =?UTF-8?q?rest=20api=20=EC=9E=91=EB=8F=99=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/widgets/CustomMetricWidget.tsx | 158 +++++++++++++----- 1 file changed, 118 insertions(+), 40 deletions(-) diff --git a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx index b98bd5cb..4dfc289e 100644 --- a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx +++ b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx @@ -48,9 +48,6 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) const [error, setError] = useState(null); useEffect(() => { - console.log("🎯 CustomMetricWidget mounted, element:", element); - console.log("📊 dataSource:", element?.dataSource); - console.log("📈 customMetricConfig:", element?.customMetricConfig); loadData(); // 자동 새로고침 (30초마다) @@ -63,51 +60,127 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) setLoading(true); setError(null); - // 쿼리나 설정이 없으면 초기 상태로 반환 - if (!element?.dataSource?.query || !element?.customMetricConfig?.metrics) { - console.log("⚠️ 쿼리 또는 지표 설정이 없습니다"); - console.log("- dataSource.query:", element?.dataSource?.query); - console.log("- customMetricConfig.metrics:", element?.customMetricConfig?.metrics); + // 데이터 소스 타입 확인 + const dataSourceType = element?.dataSource?.type; + + // 설정이 없으면 초기 상태로 반환 + if (!element?.customMetricConfig?.metrics) { setMetrics([]); setLoading(false); return; } - console.log("✅ 쿼리 실행 시작:", element.dataSource.query); + // Database 타입 + if (dataSourceType === "database") { + if (!element?.dataSource?.query) { + setMetrics([]); + setLoading(false); + return; + } - const token = localStorage.getItem("authToken"); - const response = await fetch("/api/dashboards/execute-query", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - query: element.dataSource.query, - connectionType: element.dataSource.connectionType || "current", - connectionId: element.dataSource.connectionId, - }), - }); - - if (!response.ok) throw new Error("데이터 로딩 실패"); - - const result = await response.json(); - - if (result.success && result.data?.rows) { - const rows = result.data.rows; - - // 각 메트릭 계산 - const calculatedMetrics = element.customMetricConfig.metrics.map((metric) => { - const value = calculateMetric(rows, metric.field, metric.aggregation); - return { - ...metric, - calculatedValue: value, - }; + const token = localStorage.getItem("authToken"); + const response = await fetch("/api/dashboards/execute-query", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + query: element.dataSource.query, + connectionType: element.dataSource.connectionType || "current", + connectionId: element.dataSource.connectionId, + }), }); - setMetrics(calculatedMetrics); + if (!response.ok) throw new Error("데이터 로딩 실패"); + + const result = await response.json(); + + if (result.success && result.data?.rows) { + const rows = result.data.rows; + + const calculatedMetrics = element.customMetricConfig.metrics.map((metric) => { + const value = calculateMetric(rows, metric.field, metric.aggregation); + return { + ...metric, + calculatedValue: value, + }; + }); + + setMetrics(calculatedMetrics); + } else { + throw new Error(result.message || "데이터 로드 실패"); + } + } + // API 타입 + else if (dataSourceType === "api") { + if (!element?.dataSource?.endpoint) { + setMetrics([]); + setLoading(false); + return; + } + + const token = localStorage.getItem("authToken"); + const response = await fetch("/api/dashboards/fetch-external-api", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + method: element.dataSource.method || "GET", + url: element.dataSource.endpoint, + headers: element.dataSource.headers || {}, + body: element.dataSource.body, + authType: element.dataSource.authType, + authConfig: element.dataSource.authConfig, + }), + }); + + if (!response.ok) throw new Error("API 호출 실패"); + + const result = await response.json(); + + if (result.success && result.data) { + // API 응답 데이터 구조 확인 및 처리 + let rows: any[] = []; + + // result.data가 배열인 경우 + if (Array.isArray(result.data)) { + rows = result.data; + } + // result.data.results가 배열인 경우 (일반적인 API 응답 구조) + else if (result.data.results && Array.isArray(result.data.results)) { + rows = result.data.results; + } + // result.data.items가 배열인 경우 + else if (result.data.items && Array.isArray(result.data.items)) { + rows = result.data.items; + } + // result.data.data가 배열인 경우 + else if (result.data.data && Array.isArray(result.data.data)) { + rows = result.data.data; + } + // 그 외의 경우 단일 객체를 배열로 래핑 + else { + rows = [result.data]; + } + + const calculatedMetrics = element.customMetricConfig.metrics.map((metric) => { + const value = calculateMetric(rows, metric.field, metric.aggregation); + return { + ...metric, + calculatedValue: value, + }; + }); + + setMetrics(calculatedMetrics); + } else { + throw new Error("API 응답 형식 오류"); + } } else { - throw new Error(result.message || "데이터 로드 실패"); + setMetrics([]); + setLoading(false); } } catch (err) { console.error("메트릭 로드 실패:", err); @@ -144,7 +217,12 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) ); } - if (!element?.dataSource?.query || !element?.customMetricConfig?.metrics || metrics.length === 0) { + // 데이터 소스가 없거나 설정이 없는 경우 + const hasDataSource = + (element?.dataSource?.type === "database" && element?.dataSource?.query) || + (element?.dataSource?.type === "api" && element?.dataSource?.endpoint); + + if (!hasDataSource || !element?.customMetricConfig?.metrics || metrics.length === 0) { return (