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 목록이 표시됩니다
+
+
+
+
+
+
+
+ {/* 진행 상태 */}
+
+
+ {/* 본문 */}
+
+ {/* 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 ? (
+
+ 다음
+
+
+ ) : (
+ {
+ const isDisabled = !queryResult || queryResult.error || !queryResult.rows || queryResult.rows.length === 0;
+ console.log("💾 저장 버튼 disabled:", isDisabled);
+ console.log("💾 queryResult:", queryResult);
+ return isDisabled;
+ })()}
+ >
+
+ 저장
+
+ )}
+
+
+
+
+ );
+}
+
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 && (
-
- {isLoading ? (
-
- ) : (
- "🔄"
- )}
-
- )}
+ {/* 새로고침 버튼 (항상 렌더링하되 opacity로 제어) */}
+
+ {isLoading ? (
+
+ ) : (
+ "🔄"
+ )}
+
)}
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()} 할일
+
+ )}
+
setShowAddForm(!showAddForm)}
className="flex items-center gap-1 rounded-lg bg-primary px-3 py-1.5 text-sm text-white transition-colors hover:bg-primary/90"
@@ -315,16 +451,16 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
{/* To-Do 리스트 */}
- {todos.length === 0 ? (
+ {filteredTodos.length === 0 ? (
📝
-
할 일이 없습니다
+
{selectedDate ? `${formatSelectedDate()} 할 일이 없습니다` : "할 일이 없습니다"}
) : (
- {todos.map((todo) => (
+ {filteredTodos.map((todo) => (
void;
+}
+
+const DashboardContext = createContext(undefined);
+
+export function DashboardProvider({ children }: { children: ReactNode }) {
+ const [selectedDate, setSelectedDate] = useState(null);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useDashboard() {
+ const context = useContext(DashboardContext);
+ if (context === undefined) {
+ throw new Error("useDashboard must be used within a DashboardProvider");
+ }
+ return context;
+}
+