달력과 투두리스트 합침, 배경색변경가능, 위젯끼리 밀어내는 기능과 세밀한 그리드 추가, 범용위젯 복구

This commit is contained in:
leeheejin 2025-10-17 09:49:02 +09:00
parent 7097775343
commit fa08a26079
13 changed files with 992 additions and 113 deletions

View File

@ -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" ? (
// 예약 요청 알림 위젯 렌더링

View File

@ -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}

View File

@ -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>
);
}

View File

@ -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="달력 위젯"

View File

@ -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>

View File

@ -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)),
};
}

View File

@ -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;
};
/**

View File

@ -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>

View File

@ -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

View File

@ -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>
);
}

View File

@ -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>
)}

View File

@ -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 ${

View File

@ -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;
}