달력과 투두리스트 합침, 배경색변경가능, 위젯끼리 밀어내는 기능과 세밀한 그리드 추가, 범용위젯 복구
This commit is contained in:
parent
7097775343
commit
fa08a26079
|
|
@ -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 위젯 렌더링
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<TodoWidget />
|
||||
<TodoWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "booking-alert" ? (
|
||||
// 예약 요청 알림 위젯 렌더링
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement, DashboardCanvasProps>(
|
|||
const gridConfig = useMemo(() => calculateGridConfig(canvasWidth), [canvasWidth]);
|
||||
const cellSize = gridConfig.CELL_SIZE;
|
||||
|
||||
// 충돌 방지 기능이 포함된 업데이트 핸들러
|
||||
const handleUpdateWithCollisionDetection = useCallback(
|
||||
(id: string, updates: Partial<DashboardElement>) => {
|
||||
// 업데이트할 요소 찾기
|
||||
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<HTMLDivElement, DashboardCanvasProps>(
|
|||
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<HTMLDivElement, DashboardCanvasProps>(
|
|||
isSelected={selectedElement === element.id}
|
||||
cellSize={cellSize}
|
||||
canvasWidth={canvasWidth}
|
||||
onUpdate={onUpdateElement}
|
||||
onUpdate={handleUpdateWithCollisionDetection}
|
||||
onRemove={onRemoveElement}
|
||||
onSelect={onSelectElement}
|
||||
onConfigure={onConfigureElement}
|
||||
|
|
|
|||
|
|
@ -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<DashboardElement>) => {
|
||||
if (configModalElement) {
|
||||
updateElement(configModalElement.id, updates);
|
||||
}
|
||||
},
|
||||
[configModalElement, updateElement],
|
||||
);
|
||||
|
||||
// 레이아웃 저장
|
||||
const saveLayout = useCallback(async () => {
|
||||
if (elements.length === 0) {
|
||||
|
|
@ -400,6 +411,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
}
|
||||
|
||||
return (
|
||||
<DashboardProvider>
|
||||
<div className="flex h-full flex-col bg-gray-50">
|
||||
{/* 상단 메뉴바 */}
|
||||
<DashboardTopMenu
|
||||
|
|
@ -450,6 +462,13 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
onClose={closeConfigModal}
|
||||
onSave={saveListWidgetConfig}
|
||||
/>
|
||||
) : configModalElement.type === "widget" && configModalElement.subtype === "todo" ? (
|
||||
<TodoWidgetConfigModal
|
||||
element={configModalElement}
|
||||
isOpen={true}
|
||||
onClose={closeConfigModal}
|
||||
onSave={saveTodoWidgetConfig}
|
||||
/>
|
||||
) : (
|
||||
<ElementConfigModal
|
||||
element={configModalElement}
|
||||
|
|
@ -461,6 +480,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
</DashboardProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -157,14 +157,13 @@ export function DashboardSidebar() {
|
|||
subtype="map-summary"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
{/* 주석: 다른 분이 범용 리스트 작업 중 - 충돌 방지를 위해 임시 주석처리 */}
|
||||
{/* <DraggableItem
|
||||
<DraggableItem
|
||||
icon="📋"
|
||||
title="커스텀 목록 카드"
|
||||
type="widget"
|
||||
subtype="list-summary"
|
||||
onDragStart={handleDragStart}
|
||||
/> */}
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="⚠️"
|
||||
title="리스크/알림 위젯"
|
||||
|
|
@ -172,6 +171,13 @@ export function DashboardSidebar() {
|
|||
subtype="risk-alert"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="✅"
|
||||
title="To-Do / 긴급 지시"
|
||||
type="widget"
|
||||
subtype="todo"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📅"
|
||||
title="달력 위젯"
|
||||
|
|
|
|||
|
|
@ -183,7 +183,10 @@ export function DashboardTopMenu({
|
|||
<SelectGroup>
|
||||
<SelectLabel>데이터 위젯</SelectLabel>
|
||||
<SelectItem value="list">리스트 위젯</SelectItem>
|
||||
<SelectItem value="map">지도</SelectItem>
|
||||
{/* <SelectItem value="map">지도</SelectItem> */}
|
||||
<SelectItem value="map-summary">커스텀 지도 카드</SelectItem>
|
||||
{/* <SelectItem value="list-summary">커스텀 목록 카드</SelectItem> */}
|
||||
<SelectItem value="status-summary">커스텀 상태 카드</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>일반 위젯</SelectLabel>
|
||||
|
|
@ -198,12 +201,13 @@ export function DashboardTopMenu({
|
|||
<SelectItem value="document">문서</SelectItem>
|
||||
<SelectItem value="risk-alert">리스크 알림</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
{/* 범용 위젯으로 대체 가능하여 주석처리 */}
|
||||
{/* <SelectGroup>
|
||||
<SelectLabel>차량 관리</SelectLabel>
|
||||
<SelectItem value="vehicle-status">차량 상태</SelectItem>
|
||||
<SelectItem value="vehicle-list">차량 목록</SelectItem>
|
||||
<SelectItem value="vehicle-map">차량 위치</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectGroup> */}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,12 +22,20 @@ 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 || {
|
||||
view: "month",
|
||||
|
|
@ -98,7 +107,15 @@ export function CalendarWidget({ element, onConfigUpdate }: CalendarWidgetProps)
|
|||
|
||||
{/* 달력 콘텐츠 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{config.view === "month" && <MonthView days={calendarDays} config={config} isCompact={isCompact} />}
|
||||
{config.view === "month" && (
|
||||
<MonthView
|
||||
days={calendarDays}
|
||||
config={config}
|
||||
isCompact={isCompact}
|
||||
selectedDate={selectedDate}
|
||||
onDateClick={handleDateClick}
|
||||
/>
|
||||
)}
|
||||
{/* 추후 WeekView, DayView 추가 가능 */}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
<div
|
||||
key={index}
|
||||
className={getDayCellClass(day)}
|
||||
onClick={() => 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
|
||||
|
|
|
|||
|
|
@ -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<DashboardElement>) => 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<ChartDataSource>(
|
||||
element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
|
||||
);
|
||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(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<ChartDataSource>) => {
|
||||
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 (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50">
|
||||
<div className="relative flex h-[90vh] w-[90vw] max-w-6xl flex-col rounded-lg bg-white shadow-xl">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-800">To-Do 위젯 설정</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
데이터 소스와 쿼리를 설정하면 자동으로 To-Do 목록이 표시됩니다
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 진행 상태 */}
|
||||
<div className="border-b border-gray-200 bg-gray-50 px-6 py-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`flex items-center gap-2 ${currentStep === 1 ? "text-primary" : "text-gray-400"}`}>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full font-semibold ${
|
||||
currentStep === 1 ? "bg-primary text-white" : "bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<span className="font-medium">데이터 소스 선택</span>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
<div className={`flex items-center gap-2 ${currentStep === 2 ? "text-primary" : "text-gray-400"}`}>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full font-semibold ${
|
||||
currentStep === 2 ? "bg-primary text-white" : "bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<span className="font-medium">쿼리 입력 및 테스트</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{/* Step 1: 데이터 소스 선택 */}
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label className="text-base font-semibold">제목</Label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="예: ✅ 오늘의 할 일"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-base font-semibold">데이터 소스 타입</Label>
|
||||
<DataSourceSelector
|
||||
dataSource={dataSource}
|
||||
onTypeChange={handleDataSourceTypeChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{dataSource.type === "database" && (
|
||||
<DatabaseConfig dataSource={dataSource} onUpdate={handleDataSourceUpdate} />
|
||||
)}
|
||||
|
||||
{dataSource.type === "api" && <ApiConfig dataSource={dataSource} onUpdate={handleDataSourceUpdate} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: 쿼리 입력 및 테스트 */}
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="mb-4 rounded-lg bg-blue-50 p-4">
|
||||
<h3 className="mb-2 font-semibold text-blue-900">💡 컬럼명 가이드</h3>
|
||||
<p className="mb-2 text-sm text-blue-700">
|
||||
쿼리 결과에 다음 컬럼명이 있으면 자동으로 To-Do 항목으로 변환됩니다:
|
||||
</p>
|
||||
<ul className="space-y-1 text-sm text-blue-600">
|
||||
<li>
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">id</code> - 고유 ID (없으면 자동 생성)
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">title</code>,{" "}
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">task</code>,{" "}
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">name</code> - 제목 (필수)
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">description</code>,{" "}
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">desc</code>,{" "}
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">content</code> - 상세 설명
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">priority</code> - 우선순위 (urgent, high,
|
||||
normal, low)
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">status</code> - 상태 (pending, in_progress,
|
||||
completed)
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">assigned_to</code>,{" "}
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">assignedTo</code>,{" "}
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">user</code> - 담당자
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">due_date</code>,{" "}
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">dueDate</code>,{" "}
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">deadline</code> - 마감일
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">is_urgent</code>,{" "}
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">isUrgent</code>,{" "}
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">urgent</code> - 긴급 여부
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<QueryEditor
|
||||
dataSource={dataSource}
|
||||
onDataSourceChange={handleDataSourceUpdate}
|
||||
onQueryTest={handleQueryTest}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 디버그: 항상 표시되는 테스트 메시지 */}
|
||||
<div className="mt-4 rounded-lg bg-yellow-50 border-2 border-yellow-500 p-4">
|
||||
<p className="text-sm font-bold text-yellow-900">
|
||||
🔍 디버그: queryResult 상태 = {queryResult ? "있음" : "없음"}
|
||||
</p>
|
||||
{queryResult && (
|
||||
<p className="text-xs text-yellow-700 mt-1">
|
||||
rows: {queryResult.rows?.length}개, error: {queryResult.error || "없음"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{queryResult && !queryResult.error && queryResult.rows && queryResult.rows.length > 0 && (
|
||||
<div className="mt-4 rounded-lg bg-green-50 border-2 border-green-500 p-4">
|
||||
<h3 className="mb-2 font-semibold text-green-900">✅ 쿼리 테스트 성공!</h3>
|
||||
<p className="text-sm text-green-700">
|
||||
총 <strong>{queryResult.rows.length}개</strong>의 To-Do 항목을 찾았습니다.
|
||||
</p>
|
||||
<div className="mt-3 rounded bg-white p-3">
|
||||
<p className="mb-2 text-xs font-semibold text-gray-600">첫 번째 데이터 미리보기:</p>
|
||||
<pre className="overflow-x-auto text-xs text-gray-700">
|
||||
{JSON.stringify(queryResult.rows[0], null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="flex items-center justify-between border-t border-gray-200 px-6 py-4">
|
||||
<div>
|
||||
{currentStep > 1 && (
|
||||
<Button onClick={handlePrev} variant="outline">
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
이전
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={onClose} variant="outline">
|
||||
취소
|
||||
</Button>
|
||||
|
||||
{currentStep < 2 ? (
|
||||
<Button onClick={handleNext}>
|
||||
다음
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={(() => {
|
||||
const isDisabled = !queryResult || queryResult.error || !queryResult.rows || queryResult.rows.length === 0;
|
||||
console.log("💾 저장 버튼 disabled:", isDisabled);
|
||||
console.log("💾 queryResult:", queryResult);
|
||||
return isDisabled;
|
||||
})()}
|
||||
>
|
||||
<Save className="mr-1 h-4 w-4" />
|
||||
저장
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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,6 +232,7 @@ export function DashboardViewer({ elements, dashboardId, refreshInterval, backgr
|
|||
}
|
||||
|
||||
return (
|
||||
<DashboardProvider>
|
||||
<div className="relative h-full w-full overflow-auto" style={{ backgroundColor }}>
|
||||
{/* 새로고침 상태 표시 */}
|
||||
<div className="text-muted-foreground absolute top-4 right-4 z-10 rounded-lg bg-white px-3 py-2 text-xs shadow-sm">
|
||||
|
|
@ -253,6 +255,7 @@ export function DashboardViewer({ elements, dashboardId, refreshInterval, backgr
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -286,12 +289,13 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
|
|||
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
|
||||
<h3 className="text-sm font-semibold text-gray-800">{element.customTitle || element.title}</h3>
|
||||
|
||||
{/* 새로고침 버튼 (호버 시에만 표시) */}
|
||||
{isHovered && (
|
||||
{/* 새로고침 버튼 (항상 렌더링하되 opacity로 제어) */}
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
className="hover:text-muted-foreground text-gray-400 disabled:opacity-50"
|
||||
className={`hover:text-muted-foreground text-gray-400 transition-opacity disabled:opacity-50 ${
|
||||
isHovered ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
title="새로고침"
|
||||
>
|
||||
{isLoading ? (
|
||||
|
|
@ -300,7 +304,6 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
|
|||
"🔄"
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<TodoItem[]>([]);
|
||||
const [stats, setStats] = useState<TodoStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -51,11 +55,73 @@ 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 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,
|
||||
};
|
||||
|
||||
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: {
|
||||
|
|
@ -68,6 +134,7 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
|||
setTodos(result.data || []);
|
||||
setStats(result.stats);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error("To-Do 로딩 오류:", error);
|
||||
} finally {
|
||||
|
|
@ -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 (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
|
|
@ -198,7 +326,15 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
|||
{/* 헤더 */}
|
||||
<div className="border-b border-gray-200 bg-white px-4 py-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-800">✅ {element?.customTitle || "To-Do / 긴급 지시"}</h3>
|
||||
{selectedDate && (
|
||||
<div className="mt-1 flex items-center gap-1 text-xs text-green-600">
|
||||
<CalendarIcon className="h-3 w-3" />
|
||||
<span className="font-semibold">{formatSelectedDate()} 할일</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => 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 리스트 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{todos.length === 0 ? (
|
||||
{filteredTodos.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-gray-400">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">📝</div>
|
||||
<div>할 일이 없습니다</div>
|
||||
<div>{selectedDate ? `${formatSelectedDate()} 할 일이 없습니다` : "할 일이 없습니다"}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{todos.map((todo) => (
|
||||
{filteredTodos.map((todo) => (
|
||||
<div
|
||||
key={todo.id}
|
||||
className={`group relative rounded-lg border-2 bg-white p-3 shadow-sm transition-all hover:shadow-md ${
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* 대시보드 위젯 간 데이터 공유를 위한 Context
|
||||
* - 달력에서 날짜 선택 시 할일/긴급지시 위젯에 전달
|
||||
*/
|
||||
|
||||
interface DashboardContextType {
|
||||
selectedDate: Date | null;
|
||||
setSelectedDate: (date: Date | null) => void;
|
||||
}
|
||||
|
||||
const DashboardContext = createContext<DashboardContextType | undefined>(undefined);
|
||||
|
||||
export function DashboardProvider({ children }: { children: ReactNode }) {
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||
|
||||
return (
|
||||
<DashboardContext.Provider value={{ selectedDate, setSelectedDate }}>
|
||||
{children}
|
||||
</DashboardContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDashboard() {
|
||||
const context = useContext(DashboardContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useDashboard must be used within a DashboardProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue