From fa08a26079c6779bef1de25be1663c52ca3ae261 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Fri, 17 Oct 2025 09:49:02 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8B=AC=EB=A0=A5=EA=B3=BC=20=ED=88=AC?= =?UTF-8?q?=EB=91=90=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=ED=95=A9=EC=B9=A8,=20?= =?UTF-8?q?=EB=B0=B0=EA=B2=BD=EC=83=89=EB=B3=80=EA=B2=BD=EA=B0=80=EB=8A=A5?= =?UTF-8?q?,=20=EC=9C=84=EC=A0=AF=EB=81=BC=EB=A6=AC=20=EB=B0=80=EC=96=B4?= =?UTF-8?q?=EB=82=B4=EB=8A=94=20=EA=B8=B0=EB=8A=A5=EA=B3=BC=20=EC=84=B8?= =?UTF-8?q?=EB=B0=80=ED=95=9C=20=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80,=20=EB=B2=94=EC=9A=A9=EC=9C=84=EC=A0=AF=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/CanvasElement.tsx | 102 ++++-- .../admin/dashboard/DashboardCanvas.tsx | 64 +++- .../admin/dashboard/DashboardDesigner.tsx | 50 ++- .../admin/dashboard/DashboardSidebar.tsx | 12 +- .../admin/dashboard/DashboardTopMenu.tsx | 10 +- .../admin/dashboard/collisionUtils.ts | 162 +++++++++ .../components/admin/dashboard/gridUtils.ts | 31 +- .../dashboard/widgets/CalendarWidget.tsx | 19 +- .../admin/dashboard/widgets/MonthView.tsx | 44 ++- .../widgets/TodoWidgetConfigModal.tsx | 336 ++++++++++++++++++ .../components/dashboard/DashboardViewer.tsx | 73 ++-- .../dashboard/widgets/TodoWidget.tsx | 168 ++++++++- frontend/contexts/DashboardContext.tsx | 34 ++ 13 files changed, 992 insertions(+), 113 deletions(-) create mode 100644 frontend/components/admin/dashboard/collisionUtils.ts create mode 100644 frontend/components/admin/dashboard/widgets/TodoWidgetConfigModal.tsx create mode 100644 frontend/contexts/DashboardContext.tsx diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 7050b1f2..ccf8ff13 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -208,7 +208,7 @@ export function CanvasElement({ const deltaX = e.clientX - dragStart.x; const deltaY = e.clientY - dragStart.y; - // 임시 위치 계산 (스냅 안 됨) + // 임시 위치 계산 let rawX = Math.max(0, dragStart.elementX + deltaX); const rawY = Math.max(0, dragStart.elementY + deltaY); @@ -216,7 +216,26 @@ export function CanvasElement({ const maxX = canvasWidth - element.size.width; rawX = Math.min(rawX, maxX); - setTempPosition({ x: rawX, y: rawY }); + // 드래그 중 실시간 스냅 (마그네틱 스냅) + const subGridSize = Math.floor(cellSize / 3); + const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기 + const magneticThreshold = 15; // 큰 그리드에 끌리는 거리 (px) + + // X 좌표 스냅 (큰 그리드 우선, 없으면 서브그리드) + const nearestGridX = Math.round(rawX / gridSize) * gridSize; + const distToGridX = Math.abs(rawX - nearestGridX); + const 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; + + setTempPosition({ x: snappedX, y: snappedY }); } else if (isResizing) { const deltaX = e.clientX - resizeStart.x; const deltaY = e.clientY - resizeStart.y; @@ -259,46 +278,85 @@ export function CanvasElement({ const maxWidth = canvasWidth - newX; newWidth = Math.min(newWidth, maxWidth); - // 임시 크기/위치 저장 (스냅 안 됨) - setTempPosition({ x: Math.max(0, newX), y: Math.max(0, newY) }); - setTempSize({ width: newWidth, height: newHeight }); + // 리사이즈 중 실시간 스냅 (마그네틱 스냅) + const subGridSize = Math.floor(cellSize / 3); + const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기 + const magneticThreshold = 15; + + // 위치 스냅 + const nearestGridX = Math.round(newX / gridSize) * gridSize; + const distToGridX = Math.abs(newX - nearestGridX); + const snappedX = distToGridX <= magneticThreshold + ? nearestGridX + : Math.round(newX / subGridSize) * subGridSize; + + const nearestGridY = Math.round(newY / gridSize) * gridSize; + const distToGridY = Math.abs(newY - nearestGridY); + const snappedY = distToGridY <= magneticThreshold + ? nearestGridY + : Math.round(newY / subGridSize) * subGridSize; + + // 크기 스냅 (그리드 칸 단위로 스냅하되, 마지막 GAP은 제외) + // 예: 1칸 = cellSize, 2칸 = cellSize*2 + GAP, 3칸 = cellSize*3 + GAP*2 + const calculateGridWidth = (cells: number) => cells * cellSize + Math.max(0, cells - 1) * 5; + + // 가장 가까운 그리드 칸 수 계산 + const nearestWidthCells = Math.round(newWidth / gridSize); + const nearestGridWidth = calculateGridWidth(nearestWidthCells); + const distToGridWidth = Math.abs(newWidth - nearestGridWidth); + const snappedWidth = distToGridWidth <= magneticThreshold + ? nearestGridWidth + : Math.round(newWidth / subGridSize) * subGridSize; + + const nearestHeightCells = Math.round(newHeight / gridSize); + const nearestGridHeight = calculateGridWidth(nearestHeightCells); + const distToGridHeight = Math.abs(newHeight - nearestGridHeight); + const snappedHeight = distToGridHeight <= magneticThreshold + ? nearestGridHeight + : Math.round(newHeight / subGridSize) * subGridSize; + + // 임시 크기/위치 저장 (스냅됨) + setTempPosition({ x: Math.max(0, snappedX), y: Math.max(0, snappedY) }); + setTempSize({ width: snappedWidth, height: snappedHeight }); } }, - [isDragging, isResizing, dragStart, resizeStart, element.size.width, element.type, element.subtype, canvasWidth], + [isDragging, isResizing, dragStart, resizeStart, element.size.width, element.type, element.subtype, canvasWidth, cellSize], ); - // 마우스 업 처리 (그리드 스냅 적용) + // 마우스 업 처리 (이미 스냅된 위치 사용) const handleMouseUp = useCallback(() => { if (isDragging && tempPosition) { - // 드래그 종료 시 그리드에 스냅 (동적 셀 크기 사용) - let snappedX = snapToGrid(tempPosition.x, cellSize); - const snappedY = snapToGrid(tempPosition.y, cellSize); + // tempPosition은 이미 드래그 중에 마그네틱 스냅 적용됨 + // 다시 스냅하지 않고 그대로 사용! + let finalX = tempPosition.x; + const finalY = tempPosition.y; // X 좌표가 캔버스 너비를 벗어나지 않도록 최종 제한 const maxX = canvasWidth - element.size.width; - snappedX = Math.min(snappedX, maxX); + finalX = Math.min(finalX, maxX); onUpdate(element.id, { - position: { x: snappedX, y: snappedY }, + position: { x: finalX, y: finalY }, }); setTempPosition(null); } if (isResizing && tempPosition && tempSize) { - // 리사이즈 종료 시 그리드에 스냅 (동적 셀 크기 사용) - const snappedX = snapToGrid(tempPosition.x, cellSize); - const snappedY = snapToGrid(tempPosition.y, cellSize); - let snappedWidth = snapSizeToGrid(tempSize.width, 2, cellSize); - const snappedHeight = snapSizeToGrid(tempSize.height, 2, cellSize); + // tempPosition과 tempSize는 이미 리사이즈 중에 마그네틱 스냅 적용됨 + // 다시 스냅하지 않고 그대로 사용! + let finalX = tempPosition.x; + const finalY = tempPosition.y; + let finalWidth = tempSize.width; + const finalHeight = tempSize.height; // 가로 너비가 캔버스를 벗어나지 않도록 최종 제한 - const maxWidth = canvasWidth - snappedX; - snappedWidth = Math.min(snappedWidth, maxWidth); + const maxWidth = canvasWidth - finalX; + finalWidth = Math.min(finalWidth, maxWidth); onUpdate(element.id, { - position: { x: snappedX, y: snappedY }, - size: { width: snappedWidth, height: snappedHeight }, + position: { x: finalX, y: finalY }, + size: { width: finalWidth, height: finalHeight }, }); setTempPosition(null); @@ -652,7 +710,7 @@ export function CanvasElement({ ) : element.type === "widget" && element.subtype === "todo" ? ( // To-Do 위젯 렌더링
- +
) : element.type === "widget" && element.subtype === "booking-alert" ? ( // 예약 요청 알림 위젯 렌더링 diff --git a/frontend/components/admin/dashboard/DashboardCanvas.tsx b/frontend/components/admin/dashboard/DashboardCanvas.tsx index d05f73cf..eecf12ec 100644 --- a/frontend/components/admin/dashboard/DashboardCanvas.tsx +++ b/frontend/components/admin/dashboard/DashboardCanvas.tsx @@ -4,6 +4,7 @@ import React, { forwardRef, useState, useCallback, useMemo } from "react"; import { DashboardElement, ElementType, ElementSubtype, DragData } from "./types"; import { CanvasElement } from "./CanvasElement"; import { GRID_CONFIG, snapToGrid, calculateGridConfig } from "./gridUtils"; +import { resolveAllCollisions } from "./collisionUtils"; interface DashboardCanvasProps { elements: DashboardElement[]; @@ -47,6 +48,46 @@ export const DashboardCanvas = forwardRef( const gridConfig = useMemo(() => calculateGridConfig(canvasWidth), [canvasWidth]); const cellSize = gridConfig.CELL_SIZE; + // 충돌 방지 기능이 포함된 업데이트 핸들러 + const handleUpdateWithCollisionDetection = useCallback( + (id: string, updates: Partial) => { + // 업데이트할 요소 찾기 + const elementIndex = elements.findIndex((el) => el.id === id); + if (elementIndex === -1) { + onUpdateElement(id, updates); + return; + } + + // 임시로 업데이트된 요소 배열 생성 + const updatedElements = elements.map((el) => + el.id === id ? { ...el, ...updates, position: updates.position || el.position, size: updates.size || el.size } : el + ); + + // 서브 그리드 크기 계산 (cellSize / 3) + const subGridSize = Math.floor(cellSize / 3); + + // 충돌 해결 (서브 그리드 단위로 스냅 및 충돌 감지) + const resolvedElements = resolveAllCollisions(updatedElements, id, subGridSize, canvasWidth, cellSize); + + // 변경된 요소들만 업데이트 + resolvedElements.forEach((resolvedEl, idx) => { + const originalEl = elements[idx]; + if ( + resolvedEl.position.x !== originalEl.position.x || + resolvedEl.position.y !== originalEl.position.y || + resolvedEl.size.width !== originalEl.size.width || + resolvedEl.size.height !== originalEl.size.height + ) { + onUpdateElement(resolvedEl.id, { + position: resolvedEl.position, + size: resolvedEl.size, + }); + } + }); + }, + [elements, onUpdateElement, cellSize, canvasWidth] + ); + // 드래그 오버 처리 const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -79,9 +120,24 @@ export const DashboardCanvas = forwardRef( const rawX = e.clientX - rect.left + (ref.current?.scrollLeft || 0); const rawY = e.clientY - rect.top + (ref.current?.scrollTop || 0); - // 그리드에 스냅 (동적 셀 크기 사용) - let snappedX = snapToGrid(rawX, cellSize); - const snappedY = snapToGrid(rawY, cellSize); + // 마그네틱 스냅 (큰 그리드 우선, 없으면 서브그리드) + const subGridSize = Math.floor(cellSize / 3); + const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기 + const magneticThreshold = 15; + + // 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칸 너비 보장 @@ -161,7 +217,7 @@ export const DashboardCanvas = forwardRef( isSelected={selectedElement === element.id} cellSize={cellSize} canvasWidth={canvasWidth} - onUpdate={onUpdateElement} + onUpdate={handleUpdateWithCollisionDetection} onRemove={onRemoveElement} onSelect={onSelectElement} onConfigure={onConfigureElement} diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 9cbff1b8..2972ca2f 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -6,9 +6,11 @@ import { DashboardCanvas } from "./DashboardCanvas"; import { DashboardTopMenu } from "./DashboardTopMenu"; import { ElementConfigModal } from "./ElementConfigModal"; import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal"; +import { TodoWidgetConfigModal } from "./widgets/TodoWidgetConfigModal"; import { DashboardElement, ElementType, ElementSubtype } from "./types"; import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize } from "./gridUtils"; import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector"; +import { DashboardProvider } from "@/contexts/DashboardContext"; interface DashboardDesignerProps { dashboardId?: string; @@ -302,6 +304,15 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D [configModalElement, updateElement], ); + const saveTodoWidgetConfig = useCallback( + (updates: Partial) => { + if (configModalElement) { + updateElement(configModalElement.id, updates); + } + }, + [configModalElement, updateElement], + ); + // 레이아웃 저장 const saveLayout = useCallback(async () => { if (elements.length === 0) { @@ -400,20 +411,21 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D } return ( -
- {/* 상단 메뉴바 */} - router.push(`/dashboard/${dashboardId}`) : undefined} - dashboardTitle={dashboardTitle} - onAddElement={addElementFromMenu} - resolution={resolution} - onResolutionChange={handleResolutionChange} - currentScreenResolution={screenResolution} - backgroundColor={canvasBackgroundColor} - onBackgroundColorChange={setCanvasBackgroundColor} - /> + +
+ {/* 상단 메뉴바 */} + router.push(`/dashboard/${dashboardId}`) : undefined} + dashboardTitle={dashboardTitle} + onAddElement={addElementFromMenu} + resolution={resolution} + onResolutionChange={handleResolutionChange} + currentScreenResolution={screenResolution} + backgroundColor={canvasBackgroundColor} + onBackgroundColorChange={setCanvasBackgroundColor} + /> {/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */}
@@ -450,6 +462,13 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D onClose={closeConfigModal} onSave={saveListWidgetConfig} /> + ) : configModalElement.type === "widget" && configModalElement.subtype === "todo" ? ( + ) : ( )} -
+
+
); } diff --git a/frontend/components/admin/dashboard/DashboardSidebar.tsx b/frontend/components/admin/dashboard/DashboardSidebar.tsx index 6c03b398..4ae3b3be 100644 --- a/frontend/components/admin/dashboard/DashboardSidebar.tsx +++ b/frontend/components/admin/dashboard/DashboardSidebar.tsx @@ -157,14 +157,13 @@ export function DashboardSidebar() { subtype="map-summary" onDragStart={handleDragStart} /> - {/* 주석: 다른 분이 범용 리스트 작업 중 - 충돌 방지를 위해 임시 주석처리 */} - {/* */} + /> + 데이터 위젯 리스트 위젯 - 지도 + {/* 지도 */} + 커스텀 지도 카드 + {/* 커스텀 목록 카드 */} + 커스텀 상태 카드 일반 위젯 @@ -198,12 +201,13 @@ export function DashboardTopMenu({ 문서 리스크 알림 - + {/* 범용 위젯으로 대체 가능하여 주석처리 */} + {/* 차량 관리 차량 상태 차량 목록 차량 위치 - + */}
diff --git a/frontend/components/admin/dashboard/collisionUtils.ts b/frontend/components/admin/dashboard/collisionUtils.ts new file mode 100644 index 00000000..ff597eda --- /dev/null +++ b/frontend/components/admin/dashboard/collisionUtils.ts @@ -0,0 +1,162 @@ +/** + * 대시보드 위젯 충돌 감지 및 자동 재배치 유틸리티 + */ + +import { DashboardElement } from "./types"; + +export interface Rectangle { + x: number; + y: number; + width: number; + height: number; +} + +/** + * 두 사각형이 겹치는지 확인 (여유있는 충돌 감지) + * @param rect1 첫 번째 사각형 + * @param rect2 두 번째 사각형 + * @param cellSize 한 그리드 칸의 크기 (기본: 130px) + */ +export function isColliding(rect1: Rectangle, rect2: Rectangle, cellSize: number = 130): boolean { + // 겹친 영역 계산 + const overlapX = Math.max( + 0, + Math.min(rect1.x + rect1.width, rect2.x + rect2.width) - Math.max(rect1.x, rect2.x) + ); + const overlapY = Math.max( + 0, + Math.min(rect1.y + rect1.height, rect2.y + rect2.height) - Math.max(rect1.y, rect2.y) + ); + + // 큰 그리드의 절반(cellSize/2 ≈ 65px) 이상 겹쳐야 충돌로 간주 + const collisionThreshold = Math.floor(cellSize / 2); + return overlapX >= collisionThreshold && overlapY >= collisionThreshold; +} + +/** + * 특정 위젯과 충돌하는 다른 위젯들을 찾기 + */ +export function findCollisions( + element: DashboardElement, + allElements: DashboardElement[], + cellSize: number = 130, + excludeId?: string +): DashboardElement[] { + const elementRect: Rectangle = { + x: element.position.x, + y: element.position.y, + width: element.size.width, + height: element.size.height, + }; + + return allElements.filter((other) => { + if (other.id === element.id || other.id === excludeId) { + return false; + } + + const otherRect: Rectangle = { + x: other.position.x, + y: other.position.y, + width: other.size.width, + height: other.size.height, + }; + + return isColliding(elementRect, otherRect, cellSize); + }); +} + +/** + * 충돌을 해결하기 위해 위젯을 아래로 이동 + */ +export function resolveCollisionVertically( + movingElement: DashboardElement, + collidingElement: DashboardElement, + gridSize: number = 10 +): { x: number; y: number } { + // 충돌하는 위젯 아래로 이동 + const newY = collidingElement.position.y + collidingElement.size.height + gridSize; + + return { + x: collidingElement.position.x, + y: Math.round(newY / gridSize) * gridSize, // 그리드에 스냅 + }; +} + +/** + * 여러 위젯의 충돌을 재귀적으로 해결 + */ +export function resolveAllCollisions( + elements: DashboardElement[], + movedElementId: string, + subGridSize: number = 10, + canvasWidth: number = 1560, + cellSize: number = 130, + maxIterations: number = 50 +): DashboardElement[] { + let result = [...elements]; + let iterations = 0; + + // 이동한 위젯부터 시작 + const movedIndex = result.findIndex((el) => el.id === movedElementId); + if (movedIndex === -1) return result; + + // Y 좌표로 정렬 (위에서 아래로 처리) + const sortedIndices = result + .map((el, idx) => ({ el, idx })) + .sort((a, b) => a.el.position.y - b.el.position.y) + .map((item) => item.idx); + + while (iterations < maxIterations) { + let hasCollision = false; + + for (const idx of sortedIndices) { + const element = result[idx]; + const collisions = findCollisions(element, result, cellSize); + + if (collisions.length > 0) { + hasCollision = true; + + // 첫 번째 충돌만 처리 (가장 위에 있는 것) + const collision = collisions.sort((a, b) => a.position.y - b.position.y)[0]; + + // 충돌하는 위젯을 아래로 이동 + const collisionIdx = result.findIndex((el) => el.id === collision.id); + if (collisionIdx !== -1) { + const newY = element.position.y + element.size.height + subGridSize; + + result[collisionIdx] = { + ...result[collisionIdx], + position: { + ...result[collisionIdx].position, + y: Math.round(newY / subGridSize) * subGridSize, + }, + }; + } + } + } + + if (!hasCollision) break; + iterations++; + } + + return result; +} + +/** + * 위젯이 캔버스 경계를 벗어나지 않도록 제한 + */ +export function constrainToCanvas( + element: DashboardElement, + canvasWidth: number, + canvasHeight: number, + gridSize: number = 10 +): { x: number; y: number } { + const maxX = canvasWidth - element.size.width; + const maxY = canvasHeight - element.size.height; + + return { + x: Math.max(0, Math.min(Math.round(element.position.x / gridSize) * gridSize, maxX)), + y: Math.max(0, Math.min(Math.round(element.position.y / gridSize) * gridSize, maxY)), + }; +} + diff --git a/frontend/components/admin/dashboard/gridUtils.ts b/frontend/components/admin/dashboard/gridUtils.ts index 54149222..3864e861 100644 --- a/frontend/components/admin/dashboard/gridUtils.ts +++ b/frontend/components/admin/dashboard/gridUtils.ts @@ -8,9 +8,10 @@ // 기본 그리드 설정 (FHD 기준) export const GRID_CONFIG = { COLUMNS: 12, // 모든 해상도에서 12칸 고정 - GAP: 8, // 셀 간격 고정 - SNAP_THRESHOLD: 15, // 스냅 임계값 (px) + GAP: 5, // 셀 간격 고정 + SNAP_THRESHOLD: 10, // 스냅 임계값 (px) ELEMENT_PADDING: 4, // 요소 주위 여백 (px) + SUB_GRID_DIVISIONS: 5, // 각 그리드 칸을 5x5로 세분화 (세밀한 조정용) // CELL_SIZE와 CANVAS_WIDTH는 해상도에 따라 동적 계산 } as const; @@ -23,14 +24,23 @@ export function calculateCellSize(canvasWidth: number): number { return Math.floor((canvasWidth + GRID_CONFIG.GAP) / GRID_CONFIG.COLUMNS) - GRID_CONFIG.GAP; } +/** + * 서브 그리드 크기 계산 (세밀한 조정용) + */ +export function calculateSubGridSize(cellSize: number): number { + return Math.floor(cellSize / GRID_CONFIG.SUB_GRID_DIVISIONS); +} + /** * 해상도별 그리드 설정 계산 */ export function calculateGridConfig(canvasWidth: number) { const cellSize = calculateCellSize(canvasWidth); + const subGridSize = calculateSubGridSize(cellSize); return { ...GRID_CONFIG, CELL_SIZE: cellSize, + SUB_GRID_SIZE: subGridSize, CANVAS_WIDTH: canvasWidth, }; } @@ -51,15 +61,18 @@ export const getCanvasWidth = () => { }; /** - * 좌표를 가장 가까운 그리드 포인트로 스냅 (여백 포함) + * 좌표를 서브 그리드에 스냅 (세밀한 조정 가능) * @param value - 스냅할 좌표값 - * @param cellSize - 셀 크기 (선택사항, 기본값은 GRID_CONFIG.CELL_SIZE) - * @returns 스냅된 좌표값 (여백 포함) + * @param subGridSize - 서브 그리드 크기 (선택사항, 기본값: cellSize/3 ≈ 43px) + * @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; +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; }; /** diff --git a/frontend/components/admin/dashboard/widgets/CalendarWidget.tsx b/frontend/components/admin/dashboard/widgets/CalendarWidget.tsx index 4f54ac65..b5638f94 100644 --- a/frontend/components/admin/dashboard/widgets/CalendarWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/CalendarWidget.tsx @@ -8,6 +8,7 @@ import { generateCalendarDays, getMonthName, navigateMonth } from "./calendarUti import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; import { Settings, ChevronLeft, ChevronRight, Calendar } from "lucide-react"; +import { useDashboard } from "@/contexts/DashboardContext"; interface CalendarWidgetProps { element: DashboardElement; @@ -21,11 +22,19 @@ interface CalendarWidgetProps { * - 내장 설정 UI */ export function CalendarWidget({ element, onConfigUpdate }: CalendarWidgetProps) { + // Context에서 선택된 날짜 관리 + const { selectedDate, setSelectedDate } = useDashboard(); + // 현재 표시 중인 년/월 const today = new Date(); const [currentYear, setCurrentYear] = useState(today.getFullYear()); const [currentMonth, setCurrentMonth] = useState(today.getMonth()); const [settingsOpen, setSettingsOpen] = useState(false); + + // 날짜 클릭 핸들러 + const handleDateClick = (date: Date) => { + setSelectedDate(date); + }; // 기본 설정값 const config = element.calendarConfig || { @@ -98,7 +107,15 @@ export function CalendarWidget({ element, onConfigUpdate }: CalendarWidgetProps) {/* 달력 콘텐츠 */}
- {config.view === "month" && } + {config.view === "month" && ( + + )} {/* 추후 WeekView, DayView 추가 가능 */}
diff --git a/frontend/components/admin/dashboard/widgets/MonthView.tsx b/frontend/components/admin/dashboard/widgets/MonthView.tsx index c0fd3871..67c1596c 100644 --- a/frontend/components/admin/dashboard/widgets/MonthView.tsx +++ b/frontend/components/admin/dashboard/widgets/MonthView.tsx @@ -7,12 +7,14 @@ interface MonthViewProps { days: CalendarDay[]; config: CalendarConfig; isCompact?: boolean; // 작은 크기 (2x2, 3x3) + selectedDate?: Date | null; // 선택된 날짜 + onDateClick?: (date: Date) => void; // 날짜 클릭 핸들러 } /** * 월간 달력 뷰 컴포넌트 */ -export function MonthView({ days, config, isCompact = false }: MonthViewProps) { +export function MonthView({ days, config, isCompact = false, selectedDate, onDateClick }: MonthViewProps) { const weekDayNames = getWeekDayNames(config.startWeekOn); // 테마별 스타일 @@ -43,10 +45,27 @@ export function MonthView({ days, config, isCompact = false }: MonthViewProps) { const themeStyles = getThemeStyles(); + // 날짜가 선택된 날짜인지 확인 + const isSelected = (day: CalendarDay) => { + if (!selectedDate || !day.isCurrentMonth) return false; + return ( + selectedDate.getFullYear() === day.date.getFullYear() && + selectedDate.getMonth() === day.date.getMonth() && + selectedDate.getDate() === day.date.getDate() + ); + }; + + // 날짜 클릭 핸들러 + const handleDayClick = (day: CalendarDay) => { + if (!day.isCurrentMonth || !onDateClick) return; + onDateClick(day.date); + }; + // 날짜 셀 스타일 클래스 const getDayCellClass = (day: CalendarDay) => { const baseClass = "flex aspect-square items-center justify-center rounded-lg transition-colors"; const sizeClass = isCompact ? "text-xs" : "text-sm"; + const cursorClass = day.isCurrentMonth ? "cursor-pointer" : "cursor-default"; let colorClass = "text-gray-700"; @@ -54,6 +73,10 @@ export function MonthView({ days, config, isCompact = false }: MonthViewProps) { if (!day.isCurrentMonth) { colorClass = "text-gray-300"; } + // 선택된 날짜 + else if (isSelected(day)) { + colorClass = "text-white font-bold"; + } // 오늘 else if (config.highlightToday && day.isToday) { colorClass = "text-white font-bold"; @@ -67,9 +90,16 @@ export function MonthView({ days, config, isCompact = false }: MonthViewProps) { colorClass = "text-red-600"; } - const bgClass = config.highlightToday && day.isToday ? "" : "hover:bg-gray-100"; + let bgClass = ""; + if (isSelected(day)) { + bgClass = ""; // 선택된 날짜는 배경색이 style로 적용됨 + } else if (config.highlightToday && day.isToday) { + bgClass = ""; + } else { + bgClass = "hover:bg-gray-100"; + } - return `${baseClass} ${sizeClass} ${colorClass} ${bgClass}`; + return `${baseClass} ${sizeClass} ${colorClass} ${bgClass} ${cursorClass}`; }; return ( @@ -97,9 +127,13 @@ export function MonthView({ days, config, isCompact = false }: MonthViewProps) {
handleDayClick(day)} style={{ - backgroundColor: - config.highlightToday && day.isToday ? themeStyles.todayBg : undefined, + backgroundColor: isSelected(day) + ? "#10b981" // 선택된 날짜는 초록색 + : config.highlightToday && day.isToday + ? themeStyles.todayBg + : undefined, color: config.showHolidays && day.isHoliday && day.isCurrentMonth ? themeStyles.holidayText diff --git a/frontend/components/admin/dashboard/widgets/TodoWidgetConfigModal.tsx b/frontend/components/admin/dashboard/widgets/TodoWidgetConfigModal.tsx new file mode 100644 index 00000000..3c238873 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/TodoWidgetConfigModal.tsx @@ -0,0 +1,336 @@ +"use client"; + +import React, { useState, useCallback, useEffect } from "react"; +import { DashboardElement, ChartDataSource, QueryResult } from "../types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ChevronLeft, ChevronRight, Save, X } from "lucide-react"; +import { DataSourceSelector } from "../data-sources/DataSourceSelector"; +import { DatabaseConfig } from "../data-sources/DatabaseConfig"; +import { ApiConfig } from "../data-sources/ApiConfig"; +import { QueryEditor } from "../QueryEditor"; + +interface TodoWidgetConfigModalProps { + isOpen: boolean; + element: DashboardElement; + onClose: () => void; + onSave: (updates: Partial) => void; +} + +/** + * To-Do 위젯 설정 모달 + * - 2단계 설정: 데이터 소스 → 쿼리 입력/테스트 + */ +export function TodoWidgetConfigModal({ isOpen, element, onClose, onSave }: TodoWidgetConfigModalProps) { + const [currentStep, setCurrentStep] = useState<1 | 2>(1); + const [title, setTitle] = useState(element.title || "✅ To-Do / 긴급 지시"); + const [dataSource, setDataSource] = useState( + element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 }, + ); + const [queryResult, setQueryResult] = useState(null); + + // 모달 열릴 때 element에서 설정 로드 + useEffect(() => { + if (isOpen) { + setTitle(element.title || "✅ To-Do / 긴급 지시"); + if (element.dataSource) { + setDataSource(element.dataSource); + } + setCurrentStep(1); + } + }, [isOpen, element.id]); + + // 데이터 소스 타입 변경 + const handleDataSourceTypeChange = useCallback((type: "database" | "api") => { + if (type === "database") { + setDataSource((prev) => ({ + ...prev, + type: "database", + connectionType: "current", + })); + } else { + setDataSource((prev) => ({ + ...prev, + type: "api", + method: "GET", + })); + } + setQueryResult(null); + }, []); + + // 데이터 소스 업데이트 + const handleDataSourceUpdate = useCallback((updates: Partial) => { + setDataSource((prev) => ({ ...prev, ...updates })); + }, []); + + // 쿼리 실행 결과 처리 + const handleQueryTest = useCallback( + (result: QueryResult) => { + console.log("🎯 TodoWidget - handleQueryTest 호출됨!"); + console.log("📊 쿼리 결과:", result); + console.log("📝 rows 개수:", result.rows?.length); + console.log("❌ error:", result.error); + setQueryResult(result); + console.log("✅ setQueryResult 호출 완료!"); + + // 강제 리렌더링 확인 + setTimeout(() => { + console.log("🔄 1초 후 queryResult 상태:", result); + }, 1000); + }, + [], + ); + + // 저장 + const handleSave = useCallback(() => { + if (!dataSource.query || !queryResult || queryResult.error) { + alert("쿼리를 입력하고 테스트를 먼저 실행해주세요."); + return; + } + + if (!queryResult.rows || queryResult.rows.length === 0) { + alert("쿼리 결과가 없습니다. 데이터가 반환되는 쿼리를 입력해주세요."); + return; + } + + onSave({ + title, + dataSource, + }); + + onClose(); + }, [title, dataSource, queryResult, onSave, onClose]); + + // 다음 단계로 + const handleNext = useCallback(() => { + if (currentStep === 1) { + if (dataSource.type === "database") { + if (!dataSource.connectionId && dataSource.connectionType === "external") { + alert("외부 데이터베이스를 선택해주세요."); + return; + } + } else if (dataSource.type === "api") { + if (!dataSource.url) { + alert("API URL을 입력해주세요."); + return; + } + } + setCurrentStep(2); + } + }, [currentStep, dataSource]); + + // 이전 단계로 + const handlePrev = useCallback(() => { + if (currentStep === 2) { + setCurrentStep(1); + } + }, [currentStep]); + + if (!isOpen) return null; + + return ( +
+
+ {/* 헤더 */} +
+
+

To-Do 위젯 설정

+

+ 데이터 소스와 쿼리를 설정하면 자동으로 To-Do 목록이 표시됩니다 +

+
+ +
+ + {/* 진행 상태 */} +
+
+
+
+ 1 +
+ 데이터 소스 선택 +
+ +
+
+ 2 +
+ 쿼리 입력 및 테스트 +
+
+
+ + {/* 본문 */} +
+ {/* Step 1: 데이터 소스 선택 */} + {currentStep === 1 && ( +
+
+ + setTitle(e.target.value)} + placeholder="예: ✅ 오늘의 할 일" + className="mt-2" + /> +
+ +
+ + +
+ + {dataSource.type === "database" && ( + + )} + + {dataSource.type === "api" && } +
+ )} + + {/* Step 2: 쿼리 입력 및 테스트 */} + {currentStep === 2 && ( +
+
+
+

💡 컬럼명 가이드

+

+ 쿼리 결과에 다음 컬럼명이 있으면 자동으로 To-Do 항목으로 변환됩니다: +

+
    +
  • + id - 고유 ID (없으면 자동 생성) +
  • +
  • + title,{" "} + task,{" "} + name - 제목 (필수) +
  • +
  • + description,{" "} + desc,{" "} + content - 상세 설명 +
  • +
  • + priority - 우선순위 (urgent, high, + normal, low) +
  • +
  • + status - 상태 (pending, in_progress, + completed) +
  • +
  • + assigned_to,{" "} + assignedTo,{" "} + user - 담당자 +
  • +
  • + due_date,{" "} + dueDate,{" "} + deadline - 마감일 +
  • +
  • + is_urgent,{" "} + isUrgent,{" "} + urgent - 긴급 여부 +
  • +
+
+ + +
+ + {/* 디버그: 항상 표시되는 테스트 메시지 */} +
+

+ 🔍 디버그: queryResult 상태 = {queryResult ? "있음" : "없음"} +

+ {queryResult && ( +

+ rows: {queryResult.rows?.length}개, error: {queryResult.error || "없음"} +

+ )} +
+ + {queryResult && !queryResult.error && queryResult.rows && queryResult.rows.length > 0 && ( +
+

✅ 쿼리 테스트 성공!

+

+ 총 {queryResult.rows.length}개의 To-Do 항목을 찾았습니다. +

+
+

첫 번째 데이터 미리보기:

+
+                      {JSON.stringify(queryResult.rows[0], null, 2)}
+                    
+
+
+ )} +
+ )} +
+ + {/* 하단 버튼 */} +
+
+ {currentStep > 1 && ( + + )} +
+ +
+ + + {currentStep < 2 ? ( + + ) : ( + + )} +
+
+
+
+ ); +} + diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index 1c9fd7ed..cbc2738d 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -3,6 +3,7 @@ import React, { useState, useEffect, useCallback } from "react"; import { DashboardElement, QueryResult } from "@/components/admin/dashboard/types"; import { ChartRenderer } from "@/components/admin/dashboard/charts/ChartRenderer"; +import { DashboardProvider } from "@/contexts/DashboardContext"; import dynamic from "next/dynamic"; // 위젯 동적 import - 모든 위젯 @@ -231,28 +232,30 @@ export function DashboardViewer({ elements, dashboardId, refreshInterval, backgr } return ( -
- {/* 새로고침 상태 표시 */} -
- 마지막 업데이트: {lastRefresh.toLocaleTimeString()} - {Array.from(loadingElements).length > 0 && ( - ({Array.from(loadingElements).length}개 로딩 중...) - )} -
+ +
+ {/* 새로고침 상태 표시 */} +
+ 마지막 업데이트: {lastRefresh.toLocaleTimeString()} + {Array.from(loadingElements).length > 0 && ( + ({Array.from(loadingElements).length}개 로딩 중...) + )} +
- {/* 대시보드 요소들 */} -
- {elements.map((element) => ( - loadElementData(element)} - /> - ))} + {/* 대시보드 요소들 */} +
+ {elements.map((element) => ( + loadElementData(element)} + /> + ))} +
-
+
); } @@ -286,21 +289,21 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro

{element.customTitle || element.title}

- {/* 새로고침 버튼 (호버 시에만 표시) */} - {isHovered && ( - - )} + {/* 새로고침 버튼 (항상 렌더링하되 opacity로 제어) */} +
)} diff --git a/frontend/components/dashboard/widgets/TodoWidget.tsx b/frontend/components/dashboard/widgets/TodoWidget.tsx index f43ba325..977a9b6c 100644 --- a/frontend/components/dashboard/widgets/TodoWidget.tsx +++ b/frontend/components/dashboard/widgets/TodoWidget.tsx @@ -1,8 +1,9 @@ "use client"; import React, { useState, useEffect } from "react"; -import { Plus, Check, X, Clock, AlertCircle, GripVertical, ChevronDown } from "lucide-react"; +import { Plus, Check, X, Clock, AlertCircle, GripVertical, ChevronDown, Calendar as CalendarIcon } from "lucide-react"; import { DashboardElement } from "@/components/admin/dashboard/types"; +import { useDashboard } from "@/contexts/DashboardContext"; interface TodoItem { id: string; @@ -33,6 +34,9 @@ interface TodoWidgetProps { } export default function TodoWidget({ element }: TodoWidgetProps) { + // Context에서 선택된 날짜 가져오기 + const { selectedDate } = useDashboard(); + const [todos, setTodos] = useState([]); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); @@ -51,22 +55,85 @@ export default function TodoWidget({ element }: TodoWidgetProps) { fetchTodos(); const interval = setInterval(fetchTodos, 30000); // 30초마다 갱신 return () => clearInterval(interval); - }, [filter]); + }, [filter, selectedDate]); // selectedDate도 의존성에 추가 const fetchTodos = async () => { try { const token = localStorage.getItem("authToken"); - const filterParam = filter !== "all" ? `?status=${filter}` : ""; - const response = await fetch(`http://localhost:9771/api/todos${filterParam}`, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); + const userLang = localStorage.getItem("userLang") || "KR"; + + // 외부 DB 조회 (dataSource가 설정된 경우) + if (element?.dataSource?.query) { + console.log("🔍 TodoWidget - 외부 DB 조회 시작"); + console.log("📝 Query:", element.dataSource.query); + console.log("🔗 ConnectionId:", element.dataSource.externalConnectionId); + console.log("🔗 ConnectionType:", element.dataSource.connectionType); + + // 현재 DB vs 외부 DB 분기 + const apiUrl = element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId + ? `http://localhost:9771/api/external-db/query?userLang=${userLang}` + : `http://localhost:9771/api/dashboards/execute-query?userLang=${userLang}`; + + const requestBody = element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId + ? { + connectionId: parseInt(element.dataSource.externalConnectionId), + query: element.dataSource.query, + } + : { + query: element.dataSource.query, + }; - if (response.ok) { - const result = await response.json(); - setTodos(result.data || []); - setStats(result.stats); + console.log("🌐 API URL:", apiUrl); + console.log("📦 Request Body:", requestBody); + + const response = await fetch(apiUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(requestBody), + }); + + console.log("📡 Response status:", response.status); + + if (response.ok) { + const result = await response.json(); + console.log("✅ API 응답:", result); + console.log("📦 result.data:", result.data); + console.log("📦 result.data.rows:", result.data?.rows); + + // API 응답 형식에 따라 데이터 추출 + const rows = result.data?.rows || result.data || []; + console.log("📊 추출된 rows:", rows); + + const externalTodos = mapExternalDataToTodos(rows); + console.log("📋 변환된 Todos:", externalTodos); + console.log("📋 변환된 Todos 개수:", externalTodos.length); + + setTodos(externalTodos); + setStats(calculateStatsFromTodos(externalTodos)); + + console.log("✅ setTodos, setStats 호출 완료!"); + } else { + const errorText = await response.text(); + console.error("❌ API 오류:", errorText); + } + } + // 내장 API 조회 (기본) + else { + const filterParam = filter !== "all" ? `?status=${filter}` : ""; + const response = await fetch(`http://localhost:9771/api/todos${filterParam}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (response.ok) { + const result = await response.json(); + setTodos(result.data || []); + setStats(result.stats); + } } } catch (error) { // console.error("To-Do 로딩 오류:", error); @@ -75,8 +142,48 @@ export default function TodoWidget({ element }: TodoWidgetProps) { } }; + // 외부 DB 데이터를 TodoItem 형식으로 변환 + const mapExternalDataToTodos = (data: any[]): TodoItem[] => { + return data.map((row, index) => ({ + id: row.id || `todo-${index}`, + title: row.title || row.task || row.name || "제목 없음", + description: row.description || row.desc || row.content, + priority: row.priority || "normal", + status: row.status || "pending", + assignedTo: row.assigned_to || row.assignedTo || row.user, + dueDate: row.due_date || row.dueDate || row.deadline, + createdAt: row.created_at || row.createdAt || new Date().toISOString(), + updatedAt: row.updated_at || row.updatedAt || new Date().toISOString(), + completedAt: row.completed_at || row.completedAt, + isUrgent: row.is_urgent || row.isUrgent || row.urgent || false, + order: row.display_order || row.order || index, + })); + }; + + // Todo 배열로부터 통계 계산 + const calculateStatsFromTodos = (todoList: TodoItem[]): TodoStats => { + return { + total: todoList.length, + pending: todoList.filter((t) => t.status === "pending").length, + inProgress: todoList.filter((t) => t.status === "in_progress").length, + completed: todoList.filter((t) => t.status === "completed").length, + urgent: todoList.filter((t) => t.isUrgent).length, + overdue: todoList.filter((t) => { + if (!t.dueDate) return false; + return new Date(t.dueDate) < new Date() && t.status !== "completed"; + }).length, + }; + }; + + // 외부 DB 조회 여부 확인 + const isExternalData = !!element?.dataSource?.query; + const handleAddTodo = async () => { if (!newTodo.title.trim()) return; + if (isExternalData) { + alert("외부 데이터베이스 조회 모드에서는 추가할 수 없습니다."); + return; + } try { const token = localStorage.getItem("authToken"); @@ -185,6 +292,27 @@ export default function TodoWidget({ element }: TodoWidgetProps) { return "⚠️ 오늘 마감"; }; + // 선택된 날짜로 필터링 + const filteredTodos = selectedDate + ? todos.filter((todo) => { + if (!todo.dueDate) return false; + const todoDate = new Date(todo.dueDate); + return ( + todoDate.getFullYear() === selectedDate.getFullYear() && + todoDate.getMonth() === selectedDate.getMonth() && + todoDate.getDate() === selectedDate.getDate() + ); + }) + : todos; + + const formatSelectedDate = () => { + if (!selectedDate) return null; + const year = selectedDate.getFullYear(); + const month = selectedDate.getMonth() + 1; + const day = selectedDate.getDate(); + return `${year}년 ${month}월 ${day}일`; + }; + if (loading) { return (
@@ -198,7 +326,15 @@ export default function TodoWidget({ element }: TodoWidgetProps) { {/* 헤더 */}
-

✅ {element?.customTitle || "To-Do / 긴급 지시"}

+
+

✅ {element?.customTitle || "To-Do / 긴급 지시"}

+ {selectedDate && ( +
+ + {formatSelectedDate()} 할일 +
+ )} +