From 18e2280623513900bf7a3cd2dc15d953dd687ba2 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 16 Oct 2025 09:55:14 +0900 Subject: [PATCH 01/10] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EC=9D=B4=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/(main)/admin/dashboard/page.tsx | 23 +- .../admin/dashboard/CanvasElement.tsx | 39 ++- .../admin/dashboard/DashboardCanvas.tsx | 69 ++++-- .../admin/dashboard/DashboardDesigner.tsx | 89 +++++-- .../admin/dashboard/DashboardTopMenu.tsx | 223 ++++++++++++++++++ .../admin/dashboard/ResolutionSelector.tsx | 122 ++++++++++ .../components/admin/dashboard/gridUtils.ts | 32 ++- 7 files changed, 498 insertions(+), 99 deletions(-) create mode 100644 frontend/components/admin/dashboard/DashboardTopMenu.tsx create mode 100644 frontend/components/admin/dashboard/ResolutionSelector.tsx diff --git a/frontend/app/(main)/admin/dashboard/page.tsx b/frontend/app/(main)/admin/dashboard/page.tsx index dcf81963..cde559ee 100644 --- a/frontend/app/(main)/admin/dashboard/page.tsx +++ b/frontend/app/(main)/admin/dashboard/page.tsx @@ -156,8 +156,6 @@ export default function DashboardListPage() { 제목 설명 - 요소 수 - 상태 생성일 수정일 작업 @@ -166,29 +164,10 @@ export default function DashboardListPage() { {dashboards.map((dashboard) => ( - -
- {dashboard.title} - {dashboard.isPublic && ( - - 공개 - - )} -
-
+ {dashboard.title} {dashboard.description || "-"} - - {dashboard.elementsCount || 0}개 - - - {dashboard.isPublic ? ( - 공개 - ) : ( - 비공개 - )} - {formatDate(dashboard.createdAt)} {formatDate(dashboard.updatedAt)} diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 5c75acb7..2340007c 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -110,6 +110,7 @@ interface CanvasElementProps { element: DashboardElement; isSelected: boolean; cellSize: number; + canvasWidth?: number; onUpdate: (id: string, updates: Partial) => void; onRemove: (id: string) => void; onSelect: (id: string | null) => void; @@ -126,6 +127,7 @@ export function CanvasElement({ element, isSelected, cellSize, + canvasWidth = 1560, onUpdate, onRemove, onSelect, @@ -207,7 +209,7 @@ export function CanvasElement({ const rawY = Math.max(0, dragStart.elementY + deltaY); // X 좌표가 캔버스 너비를 벗어나지 않도록 제한 - const maxX = GRID_CONFIG.CANVAS_WIDTH - element.size.width; + const maxX = canvasWidth - element.size.width; rawX = Math.min(rawX, maxX); setTempPosition({ x: rawX, y: rawY }); @@ -250,7 +252,7 @@ export function CanvasElement({ } // 가로 너비가 캔버스를 벗어나지 않도록 제한 - const maxWidth = GRID_CONFIG.CANVAS_WIDTH - newX; + const maxWidth = canvasWidth - newX; newWidth = Math.min(newWidth, maxWidth); // 임시 크기/위치 저장 (스냅 안 됨) @@ -258,7 +260,7 @@ export function CanvasElement({ setTempSize({ width: newWidth, height: newHeight }); } }, - [isDragging, isResizing, dragStart, resizeStart, element.size.width, element.type, element.subtype], + [isDragging, isResizing, dragStart, resizeStart, element.size.width, element.type, element.subtype, canvasWidth], ); // 마우스 업 처리 (그리드 스냅 적용) @@ -269,7 +271,7 @@ export function CanvasElement({ const snappedY = snapToGrid(tempPosition.y, cellSize); // X 좌표가 캔버스 너비를 벗어나지 않도록 최종 제한 - const maxX = GRID_CONFIG.CANVAS_WIDTH - element.size.width; + const maxX = canvasWidth - element.size.width; snappedX = Math.min(snappedX, maxX); onUpdate(element.id, { @@ -287,7 +289,7 @@ export function CanvasElement({ const snappedHeight = snapSizeToGrid(tempSize.height, 2, cellSize); // 가로 너비가 캔버스를 벗어나지 않도록 최종 제한 - const maxWidth = GRID_CONFIG.CANVAS_WIDTH - snappedX; + const maxWidth = canvasWidth - snappedX; snappedWidth = Math.min(snappedWidth, maxWidth); onUpdate(element.id, { @@ -301,7 +303,7 @@ export function CanvasElement({ setIsDragging(false); setIsResizing(false); - }, [isDragging, isResizing, tempPosition, tempSize, element.id, element.size.width, onUpdate, cellSize]); + }, [isDragging, isResizing, tempPosition, tempSize, element.id, element.size.width, onUpdate, cellSize, canvasWidth]); // 전역 마우스 이벤트 등록 React.useEffect(() => { @@ -545,12 +547,7 @@ export function CanvasElement({ ) : element.type === "widget" && element.subtype === "status-summary" ? ( // 커스텀 상태 카드 - 범용 위젯
- +
) : /* element.type === "widget" && element.subtype === "list-summary" ? ( // 커스텀 목록 카드 - 범용 위젯 (다른 분 작업 중 - 임시 주석) @@ -560,7 +557,7 @@ export function CanvasElement({ ) : */ element.type === "widget" && element.subtype === "delivery-status" ? ( // 배송/화물 현황 위젯 - 범용 위젯 사용 (구버전 호환)
- -
) : element.type === "widget" && element.subtype === "delivery-today-stats" ? ( // 오늘 처리 현황 - 범용 위젯 사용
- - - void; onConfigureElement?: (element: DashboardElement) => void; backgroundColor?: string; + canvasWidth?: number; + canvasHeight?: number; } /** @@ -34,11 +36,17 @@ export const DashboardCanvas = forwardRef( onSelectElement, onConfigureElement, backgroundColor = "#f9fafb", + canvasWidth = 1560, + canvasHeight = 768, }, ref, ) => { const [isDragOver, setIsDragOver] = useState(false); + // 현재 캔버스 크기에 맞는 그리드 설정 계산 + const gridConfig = useMemo(() => calculateGridConfig(canvasWidth), [canvasWidth]); + const cellSize = gridConfig.CELL_SIZE; + // 드래그 오버 처리 const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -71,20 +79,20 @@ 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, GRID_CONFIG.CELL_SIZE); - const snappedY = snapToGrid(rawY, GRID_CONFIG.CELL_SIZE); + // 그리드에 스냅 (동적 셀 크기 사용) + let snappedX = snapToGrid(rawX, cellSize); + const snappedY = snapToGrid(rawY, cellSize); // X 좌표가 캔버스 너비를 벗어나지 않도록 제한 - const maxX = GRID_CONFIG.CANVAS_WIDTH - GRID_CONFIG.CELL_SIZE * 2; // 최소 2칸 너비 보장 + const maxX = canvasWidth - cellSize * 2; // 최소 2칸 너비 보장 snappedX = Math.max(0, Math.min(snappedX, maxX)); onCreateElement(dragData.type, dragData.subtype, snappedX, snappedY); - } catch (error) { - // console.error('드롭 데이터 파싱 오류:', error); + } catch { + // 드롭 데이터 파싱 오류 무시 } }, - [ref, onCreateElement], + [ref, onCreateElement, canvasWidth, cellSize], ); // 캔버스 클릭 시 선택 해제 @@ -97,28 +105,23 @@ export const DashboardCanvas = forwardRef( [onSelectElement], ); - // 고정 그리드 크기 - const cellWithGap = GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP; + // 동적 그리드 크기 계산 + const cellWithGap = cellSize + GRID_CONFIG.GAP; const gridSize = `${cellWithGap}px ${cellWithGap}px`; - // 캔버스 높이를 요소들의 최대 y + height 기준으로 계산 (최소 화면 높이 보장) - const minCanvasHeight = Math.max( - typeof window !== "undefined" ? window.innerHeight : 800, - ...elements.map((el) => el.position.y + el.size.height + 100), // 하단 여백 100px - ); + // 12개 컬럼 구분선 위치 계산 + const columnLines = Array.from({ length: GRID_CONFIG.COLUMNS + 1 }, (_, i) => i * cellWithGap); return (
( onDrop={handleDrop} onClick={handleCanvasClick} > + {/* 12개 컬럼 메인 구분선 */} + {columnLines.map((x, i) => ( +
+ ))} {/* 배치된 요소들 렌더링 */} + {elements.length === 0 && ( +
+
+
상단 메뉴에서 차트나 위젯을 선택하세요
+
+
+ )} {elements.map((element) => ( ("#f9fafb"); const canvasRef = useRef(null); + // 화면 해상도 자동 감지 및 기본 해상도 설정 + const [screenResolution] = useState(() => detectScreenResolution()); + const [resolution, setResolution] = useState(screenResolution); + + // 현재 해상도 설정 + const canvasConfig = RESOLUTIONS[resolution]; + // 대시보드 ID가 props로 전달되면 로드 React.useEffect(() => { if (initialDashboardId) { @@ -81,9 +88,15 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D } }; - // 새로운 요소 생성 (고정 그리드 기반 기본 크기) + // 새로운 요소 생성 (동적 그리드 기반 기본 크기) const createElement = useCallback( (type: ElementType, subtype: ElementSubtype, x: number, y: number) => { + // 좌표 유효성 검사 + if (isNaN(x) || isNaN(y)) { + console.error("Invalid coordinates:", { x, y }); + return; + } + // 기본 크기 설정 let defaultCells = { width: 2, height: 2 }; // 기본 위젯 크기 @@ -93,7 +106,9 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D defaultCells = { width: 2, height: 3 }; // 달력 최소 크기 } - const cellWithGap = GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP; + // 현재 해상도에 맞는 셀 크기 계산 + const cellSize = Math.floor((canvasConfig.width + GRID_CONFIG.GAP) / GRID_CONFIG.COLUMNS) - GRID_CONFIG.GAP; + const cellWithGap = cellSize + GRID_CONFIG.GAP; const defaultWidth = defaultCells.width * cellWithGap - GRID_CONFIG.GAP; const defaultHeight = defaultCells.height * cellWithGap - GRID_CONFIG.GAP; @@ -112,7 +127,25 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D setElementCounter((prev) => prev + 1); setSelectedElement(newElement.id); }, - [elementCounter], + [elementCounter, canvasConfig.width], + ); + + // 메뉴에서 요소 추가 시 (캔버스 중앙에 배치) + const addElementFromMenu = useCallback( + (type: ElementType, subtype: ElementSubtype) => { + // 캔버스 중앙 좌표 계산 + const centerX = Math.floor(canvasConfig.width / 2); + const centerY = Math.floor(canvasConfig.height / 2); + + // 좌표 유효성 확인 + if (isNaN(centerX) || isNaN(centerY)) { + console.error("Invalid canvas config:", canvasConfig); + return; + } + + createElement(type, subtype, centerX, centerY); + }, + [canvasConfig.width, canvasConfig.height, createElement], ); // 요소 업데이트 @@ -245,25 +278,30 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D } return ( -
- {/* 캔버스 영역 */} -
- {/* 편집 중인 대시보드 표시 */} - {dashboardTitle && ( -
- 📝 편집 중: {dashboardTitle} -
- )} +
+ {/* 상단 메뉴바 */} + router.push(`/dashboard/${dashboardId}`) : undefined} + dashboardTitle={dashboardTitle} + onAddElement={addElementFromMenu} + resolution={resolution} + onResolutionChange={setResolution} + currentScreenResolution={screenResolution} + backgroundColor={canvasBackgroundColor} + onBackgroundColorChange={setCanvasBackgroundColor} + /> - - - {/* 캔버스 중앙 정렬 컨테이너 */} -
+ {/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */} +
+
- {/* 사이드바 */} - - {/* 요소 설정 모달 */} {configModalElement && ( <> diff --git a/frontend/components/admin/dashboard/DashboardTopMenu.tsx b/frontend/components/admin/dashboard/DashboardTopMenu.tsx new file mode 100644 index 00000000..cc56265a --- /dev/null +++ b/frontend/components/admin/dashboard/DashboardTopMenu.tsx @@ -0,0 +1,223 @@ +"use client"; + +import React from "react"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Save, Trash2, Eye, Palette } from "lucide-react"; +import { ElementType, ElementSubtype } from "./types"; +import { ResolutionSelector, Resolution } from "./ResolutionSelector"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; + +interface DashboardTopMenuProps { + onSaveLayout: () => void; + onClearCanvas: () => void; + onViewDashboard?: () => void; + dashboardTitle?: string; + onAddElement?: (type: ElementType, subtype: ElementSubtype) => void; + resolution?: Resolution; + onResolutionChange?: (resolution: Resolution) => void; + currentScreenResolution?: Resolution; + backgroundColor?: string; + onBackgroundColorChange?: (color: string) => void; +} + +/** + * 대시보드 편집 화면 상단 메뉴바 + * - 차트/위젯 선택 (셀렉트박스) + * - 저장/초기화/보기 버튼 + */ +export function DashboardTopMenu({ + onSaveLayout, + onClearCanvas, + onViewDashboard, + dashboardTitle, + onAddElement, + resolution = "fhd", + onResolutionChange, + currentScreenResolution, + backgroundColor = "#f9fafb", + onBackgroundColorChange, +}: DashboardTopMenuProps) { + // 차트 선택 시 캔버스 중앙에 추가 + const handleChartSelect = (value: string) => { + if (onAddElement) { + onAddElement("chart", value as ElementSubtype); + } + }; + + // 위젯 선택 시 캔버스 중앙에 추가 + const handleWidgetSelect = (value: string) => { + if (onAddElement) { + onAddElement("widget", value as ElementSubtype); + } + }; + + return ( +
+ {/* 좌측: 대시보드 제목 */} +
+ {dashboardTitle && ( +
+ {dashboardTitle} + 편집 중 +
+ )} +
+ + {/* 중앙: 해상도 선택 & 요소 추가 */} +
+ {/* 해상도 선택 */} + {onResolutionChange && ( + + )} + +
+ + {/* 배경색 선택 */} + {onBackgroundColorChange && ( + + + + + +
+
+ +
+
+ onBackgroundColorChange(e.target.value)} + className="h-10 w-20 cursor-pointer" + /> + onBackgroundColorChange(e.target.value)} + placeholder="#f9fafb" + className="flex-1" + /> +
+
+ {[ + "#ffffff", + "#f9fafb", + "#f3f4f6", + "#e5e7eb", + "#1f2937", + "#111827", + "#fef3c7", + "#fde68a", + "#dbeafe", + "#bfdbfe", + "#fecaca", + "#fca5a5", + ].map((color) => ( +
+
+
+
+ )} + +
+ {/* 차트 선택 */} + + + {/* 위젯 선택 */} + +
+ + {/* 우측: 액션 버튼 */} +
+ {onViewDashboard && ( + + )} + + +
+
+ ); +} diff --git a/frontend/components/admin/dashboard/ResolutionSelector.tsx b/frontend/components/admin/dashboard/ResolutionSelector.tsx new file mode 100644 index 00000000..5f5bda53 --- /dev/null +++ b/frontend/components/admin/dashboard/ResolutionSelector.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Monitor } from "lucide-react"; + +export type Resolution = "hd" | "fhd" | "qhd" | "uhd"; + +export interface ResolutionConfig { + width: number; + height: number; + label: string; +} + +export const RESOLUTIONS: Record = { + hd: { + width: 1280 - 360, + height: 720 - 312, + label: "HD (1280x720)", + }, + fhd: { + width: 1920 - 360, + height: 1080 - 312, + label: "Full HD (1920x1080)", + }, + qhd: { + width: 2560 - 360, + height: 1440 - 312, + label: "QHD (2560x1440)", + }, + uhd: { + width: 3840 - 360, + height: 2160 - 312, + label: "4K UHD (3840x2160)", + }, +}; + +interface ResolutionSelectorProps { + value: Resolution; + onChange: (resolution: Resolution) => void; + currentScreenResolution?: Resolution; +} + +/** + * 현재 화면 해상도 감지 + */ +export function detectScreenResolution(): Resolution { + if (typeof window === "undefined") return "fhd"; + + const width = window.screen.width; + const height = window.screen.height; + + // 화면 해상도에 따라 적절한 캔버스 해상도 반환 + if (width >= 3840 || height >= 2160) return "uhd"; + if (width >= 2560 || height >= 1440) return "qhd"; + if (width >= 1920 || height >= 1080) return "fhd"; + return "hd"; +} + +/** + * 해상도 선택 컴포넌트 + * - HD, Full HD, QHD, 4K UHD 지원 + * - 12칸 그리드 유지, 셀 크기만 변경 + * - 현재 화면 해상도 감지 및 경고 표시 + */ +export function ResolutionSelector({ value, onChange, currentScreenResolution }: ResolutionSelectorProps) { + const currentConfig = RESOLUTIONS[value]; + const screenConfig = currentScreenResolution ? RESOLUTIONS[currentScreenResolution] : null; + + // 현재 선택된 해상도가 화면보다 큰지 확인 + const isTooLarge = + screenConfig && + (currentConfig.width > screenConfig.width + 360 || currentConfig.height > screenConfig.height + 312); + + return ( +
+ + + {isTooLarge && ⚠️ 현재 화면보다 큽니다} +
+ ); +} diff --git a/frontend/components/admin/dashboard/gridUtils.ts b/frontend/components/admin/dashboard/gridUtils.ts index f5ec9d7c..54149222 100644 --- a/frontend/components/admin/dashboard/gridUtils.ts +++ b/frontend/components/admin/dashboard/gridUtils.ts @@ -5,18 +5,36 @@ * - 스냅 기능 */ -// 그리드 설정 (고정 크기) +// 기본 그리드 설정 (FHD 기준) export const GRID_CONFIG = { - COLUMNS: 12, - CELL_SIZE: 132, // 고정 셀 크기 - GAP: 8, // 셀 간격 + COLUMNS: 12, // 모든 해상도에서 12칸 고정 + GAP: 8, // 셀 간격 고정 SNAP_THRESHOLD: 15, // 스냅 임계값 (px) ELEMENT_PADDING: 4, // 요소 주위 여백 (px) - CANVAS_WIDTH: 1682, // 고정 캔버스 너비 (실제 측정값) - // 계산식: (132 + 8) × 12 - 8 = 1672px (그리드) - // 추가 여백 10px 포함 = 1682px + // CELL_SIZE와 CANVAS_WIDTH는 해상도에 따라 동적 계산 } as const; +/** + * 캔버스 너비에 맞춰 셀 크기 계산 + * 공식: (CELL_SIZE + GAP) * 12 - GAP = canvasWidth + * CELL_SIZE = (canvasWidth + GAP) / 12 - GAP + */ +export function calculateCellSize(canvasWidth: number): number { + return Math.floor((canvasWidth + GRID_CONFIG.GAP) / GRID_CONFIG.COLUMNS) - GRID_CONFIG.GAP; +} + +/** + * 해상도별 그리드 설정 계산 + */ +export function calculateGridConfig(canvasWidth: number) { + const cellSize = calculateCellSize(canvasWidth); + return { + ...GRID_CONFIG, + CELL_SIZE: cellSize, + CANVAS_WIDTH: canvasWidth, + }; +} + /** * 실제 그리드 셀 크기 계산 (gap 포함) */ From 3afcd3d9fb3d21b3b4ec55f69490bdeed821c226 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 16 Oct 2025 10:02:47 +0900 Subject: [PATCH 02/10] =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EC=9A=94=EC=86=8C?= =?UTF-8?q?=EB=A5=BC=20=EC=97=B0=EC=86=8D=EC=9C=BC=EB=A1=9C=20=EA=BA=BC?= =?UTF-8?q?=EB=82=BC=20=EC=88=98=20=EC=9E=88=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/admin/dashboard/CanvasElement.tsx | 8 ++++++-- .../components/admin/dashboard/DashboardTopMenu.tsx | 11 +++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 2340007c..fb8d9847 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -166,7 +166,11 @@ export function CanvasElement({ return; } - onSelect(element.id); + // 선택되지 않은 경우에만 선택 처리 + if (!isSelected) { + onSelect(element.id); + } + setIsDragging(true); setDragStart({ x: e.clientX, @@ -176,7 +180,7 @@ export function CanvasElement({ }); e.preventDefault(); }, - [element.id, element.position.x, element.position.y, onSelect], + [element.id, element.position.x, element.position.y, onSelect, isSelected], ); // 리사이즈 핸들 마우스다운 diff --git a/frontend/components/admin/dashboard/DashboardTopMenu.tsx b/frontend/components/admin/dashboard/DashboardTopMenu.tsx index cc56265a..1d58988f 100644 --- a/frontend/components/admin/dashboard/DashboardTopMenu.tsx +++ b/frontend/components/admin/dashboard/DashboardTopMenu.tsx @@ -47,10 +47,15 @@ export function DashboardTopMenu({ backgroundColor = "#f9fafb", onBackgroundColorChange, }: DashboardTopMenuProps) { + const [chartValue, setChartValue] = React.useState(""); + const [widgetValue, setWidgetValue] = React.useState(""); + // 차트 선택 시 캔버스 중앙에 추가 const handleChartSelect = (value: string) => { if (onAddElement) { onAddElement("chart", value as ElementSubtype); + // 선택 후 즉시 리셋하여 같은 항목을 연속으로 선택 가능하게 + setTimeout(() => setChartValue(""), 0); } }; @@ -58,6 +63,8 @@ export function DashboardTopMenu({ const handleWidgetSelect = (value: string) => { if (onAddElement) { onAddElement("widget", value as ElementSubtype); + // 선택 후 즉시 리셋하여 같은 항목을 연속으로 선택 가능하게 + setTimeout(() => setWidgetValue(""), 0); } }; @@ -148,7 +155,7 @@ export function DashboardTopMenu({
{/* 차트 선택 */} - @@ -168,7 +175,7 @@ export function DashboardTopMenu({ {/* 위젯 선택 */} - From ed9da3962a81a6ddd202a23819053049115b6d32 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 16 Oct 2025 10:05:43 +0900 Subject: [PATCH 03/10] =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/DashboardDesigner.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 340d9f41..4683ee65 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -37,8 +37,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D const [screenResolution] = useState(() => detectScreenResolution()); const [resolution, setResolution] = useState(screenResolution); - // 현재 해상도 설정 - const canvasConfig = RESOLUTIONS[resolution]; + // 현재 해상도 설정 (안전하게 기본값 제공) + const canvasConfig = RESOLUTIONS[resolution] || RESOLUTIONS.fhd; // 대시보드 ID가 props로 전달되면 로드 React.useEffect(() => { @@ -113,6 +113,19 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D const defaultWidth = defaultCells.width * cellWithGap - GRID_CONFIG.GAP; const defaultHeight = defaultCells.height * cellWithGap - GRID_CONFIG.GAP; + // 크기 유효성 검사 + if (isNaN(defaultWidth) || isNaN(defaultHeight) || defaultWidth <= 0 || defaultHeight <= 0) { + console.error("Invalid size calculated:", { + canvasConfig, + cellSize, + cellWithGap, + defaultCells, + defaultWidth, + defaultHeight, + }); + return; + } + const newElement: DashboardElement = { id: `element-${elementCounter + 1}`, type, From 337cc448d0ec55b0edbb1b93f9dbe2e44ba1349d Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 16 Oct 2025 10:09:10 +0900 Subject: [PATCH 04/10] =?UTF-8?q?=EB=A6=AC=EC=82=AC=EC=9D=B4=EC=A6=88=20?= =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=9F=AC=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/admin/dashboard/CanvasElement.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index fb8d9847..c1a682d3 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -229,8 +229,8 @@ export function CanvasElement({ // 최소 크기 설정: 달력은 2x3, 나머지는 2x2 const minWidthCells = 2; const minHeightCells = element.type === "widget" && element.subtype === "calendar" ? 3 : 2; - const minWidth = GRID_CONFIG.CELL_SIZE * minWidthCells; - const minHeight = GRID_CONFIG.CELL_SIZE * minHeightCells; + const minWidth = cellSize * minWidthCells; + const minHeight = cellSize * minHeightCells; switch (resizeStart.handle) { case "se": // 오른쪽 아래 From 9e1a7c53e16b1766556f91379fdbde172a99e6b2 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 16 Oct 2025 10:27:43 +0900 Subject: [PATCH 05/10] =?UTF-8?q?settings=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/services/DashboardService.ts | 438 ++++++++++-------- .../admin/dashboard/DashboardDesigner.tsx | 34 +- frontend/lib/api/dashboard.ts | 8 + 3 files changed, 283 insertions(+), 197 deletions(-) diff --git a/backend-node/src/services/DashboardService.ts b/backend-node/src/services/DashboardService.ts index fa0ce775..9829c49d 100644 --- a/backend-node/src/services/DashboardService.ts +++ b/backend-node/src/services/DashboardService.ts @@ -1,89 +1,98 @@ -import { v4 as uuidv4 } from 'uuid'; -import { PostgreSQLService } from '../database/PostgreSQLService'; -import { - Dashboard, - DashboardElement, - CreateDashboardRequest, +import { v4 as uuidv4 } from "uuid"; +import { PostgreSQLService } from "../database/PostgreSQLService"; +import { + Dashboard, + DashboardElement, + CreateDashboardRequest, UpdateDashboardRequest, - DashboardListQuery -} from '../types/dashboard'; + DashboardListQuery, +} from "../types/dashboard"; /** * 대시보드 서비스 - Raw Query 방식 * PostgreSQL 직접 연결을 통한 CRUD 작업 */ export class DashboardService { - /** * 대시보드 생성 */ - static async createDashboard(data: CreateDashboardRequest, userId: string): Promise { + static async createDashboard( + data: CreateDashboardRequest, + userId: string + ): Promise { const dashboardId = uuidv4(); const now = new Date(); - + try { // 트랜잭션으로 대시보드와 요소들을 함께 생성 const result = await PostgreSQLService.transaction(async (client) => { // 1. 대시보드 메인 정보 저장 - await client.query(` + await client.query( + ` INSERT INTO dashboards ( id, title, description, is_public, created_by, - created_at, updated_at, tags, category, view_count - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - `, [ - dashboardId, - data.title, - data.description || null, - data.isPublic || false, - userId, - now, - now, - JSON.stringify(data.tags || []), - data.category || null, - 0 - ]); - + created_at, updated_at, tags, category, view_count, settings + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + `, + [ + dashboardId, + data.title, + data.description || null, + data.isPublic || false, + userId, + now, + now, + JSON.stringify(data.tags || []), + data.category || null, + 0, + JSON.stringify(data.settings || {}), + ] + ); + // 2. 대시보드 요소들 저장 if (data.elements && data.elements.length > 0) { for (let i = 0; i < data.elements.length; i++) { const element = data.elements[i]; const elementId = uuidv4(); // 항상 새로운 UUID 생성 - - await client.query(` + + await client.query( + ` INSERT INTO dashboard_elements ( id, dashboard_id, element_type, element_subtype, position_x, position_y, width, height, title, content, data_source_config, chart_config, display_order, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) - `, [ - elementId, - dashboardId, - element.type, - element.subtype, - element.position.x, - element.position.y, - element.size.width, - element.size.height, - element.title, - element.content || null, - JSON.stringify(element.dataSource || {}), - JSON.stringify(element.chartConfig || {}), - i, - now, - now - ]); + `, + [ + elementId, + dashboardId, + element.type, + element.subtype, + element.position.x, + element.position.y, + element.size.width, + element.size.height, + element.title, + element.content || null, + JSON.stringify(element.dataSource || {}), + JSON.stringify(element.chartConfig || {}), + i, + now, + now, + ] + ); } } - + return dashboardId; }); - + // 생성된 대시보드 반환 try { const dashboard = await this.getDashboardById(dashboardId, userId); if (!dashboard) { - console.error('대시보드 생성은 성공했으나 조회에 실패:', dashboardId); + console.error("대시보드 생성은 성공했으나 조회에 실패:", dashboardId); // 생성은 성공했으므로 기본 정보만이라도 반환 return { id: dashboardId, @@ -97,13 +106,13 @@ export class DashboardService { tags: data.tags || [], category: data.category, viewCount: 0, - elements: data.elements || [] + elements: data.elements || [], }; } - + return dashboard; } catch (fetchError) { - console.error('생성된 대시보드 조회 중 오류:', fetchError); + console.error("생성된 대시보드 조회 중 오류:", fetchError); // 생성은 성공했으므로 기본 정보 반환 return { id: dashboardId, @@ -117,76 +126,79 @@ export class DashboardService { tags: data.tags || [], category: data.category, viewCount: 0, - elements: data.elements || [] + elements: data.elements || [], }; } - } catch (error) { - console.error('Dashboard creation error:', error); + console.error("Dashboard creation error:", error); throw error; } } - + /** * 대시보드 목록 조회 */ static async getDashboards(query: DashboardListQuery, userId?: string) { - const { - page = 1, - limit = 20, - search, - category, - isPublic, - createdBy + const { + page = 1, + limit = 20, + search, + category, + isPublic, + createdBy, } = query; - + const offset = (page - 1) * limit; - + try { // 기본 WHERE 조건 - let whereConditions = ['d.deleted_at IS NULL']; + let whereConditions = ["d.deleted_at IS NULL"]; let params: any[] = []; let paramIndex = 1; - + // 권한 필터링 if (userId) { - whereConditions.push(`(d.created_by = $${paramIndex} OR d.is_public = true)`); + whereConditions.push( + `(d.created_by = $${paramIndex} OR d.is_public = true)` + ); params.push(userId); paramIndex++; } else { - whereConditions.push('d.is_public = true'); + whereConditions.push("d.is_public = true"); } - + // 검색 조건 if (search) { - whereConditions.push(`(d.title ILIKE $${paramIndex} OR d.description ILIKE $${paramIndex + 1})`); + whereConditions.push( + `(d.title ILIKE $${paramIndex} OR d.description ILIKE $${paramIndex + 1})` + ); params.push(`%${search}%`, `%${search}%`); paramIndex += 2; } - + // 카테고리 필터 if (category) { whereConditions.push(`d.category = $${paramIndex}`); params.push(category); paramIndex++; } - + // 공개/비공개 필터 - if (typeof isPublic === 'boolean') { + if (typeof isPublic === "boolean") { whereConditions.push(`d.is_public = $${paramIndex}`); params.push(isPublic); paramIndex++; } - + // 작성자 필터 if (createdBy) { whereConditions.push(`d.created_by = $${paramIndex}`); params.push(createdBy); paramIndex++; } - - const whereClause = whereConditions.join(' AND '); - + + const whereClause = whereConditions.join(" AND "); + // 대시보드 목록 조회 (users 테이블 조인 제거) const dashboardQuery = ` SELECT @@ -211,22 +223,23 @@ export class DashboardService { ORDER BY d.updated_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `; - - const dashboardResult = await PostgreSQLService.query( - dashboardQuery, - [...params, limit, offset] - ); - + + const dashboardResult = await PostgreSQLService.query(dashboardQuery, [ + ...params, + limit, + offset, + ]); + // 전체 개수 조회 const countQuery = ` SELECT COUNT(DISTINCT d.id) as total FROM dashboards d WHERE ${whereClause} `; - + const countResult = await PostgreSQLService.query(countQuery, params); - const total = parseInt(countResult.rows[0]?.total || '0'); - + const total = parseInt(countResult.rows[0]?.total || "0"); + return { dashboards: dashboardResult.rows.map((row: any) => ({ id: row.id, @@ -237,33 +250,36 @@ export class DashboardService { createdBy: row.created_by, createdAt: row.created_at, updatedAt: row.updated_at, - tags: JSON.parse(row.tags || '[]'), + tags: JSON.parse(row.tags || "[]"), category: row.category, - viewCount: parseInt(row.view_count || '0'), - elementsCount: parseInt(row.elements_count || '0') + viewCount: parseInt(row.view_count || "0"), + elementsCount: parseInt(row.elements_count || "0"), })), pagination: { page, limit, total, - totalPages: Math.ceil(total / limit) - } + totalPages: Math.ceil(total / limit), + }, }; } catch (error) { - console.error('Dashboard list error:', error); + console.error("Dashboard list error:", error); throw error; } } - + /** * 대시보드 상세 조회 */ - static async getDashboardById(dashboardId: string, userId?: string): Promise { + static async getDashboardById( + dashboardId: string, + userId?: string + ): Promise { try { // 1. 대시보드 기본 정보 조회 (권한 체크 포함) let dashboardQuery: string; let dashboardParams: any[]; - + if (userId) { dashboardQuery = ` SELECT d.* @@ -281,43 +297,50 @@ export class DashboardService { `; dashboardParams = [dashboardId]; } - - const dashboardResult = await PostgreSQLService.query(dashboardQuery, dashboardParams); - + + const dashboardResult = await PostgreSQLService.query( + dashboardQuery, + dashboardParams + ); + if (dashboardResult.rows.length === 0) { return null; } - + const dashboard = dashboardResult.rows[0]; - + // 2. 대시보드 요소들 조회 const elementsQuery = ` SELECT * FROM dashboard_elements WHERE dashboard_id = $1 ORDER BY display_order ASC `; - - const elementsResult = await PostgreSQLService.query(elementsQuery, [dashboardId]); - + + const elementsResult = await PostgreSQLService.query(elementsQuery, [ + dashboardId, + ]); + // 3. 요소 데이터 변환 - const elements: DashboardElement[] = elementsResult.rows.map((row: any) => ({ - id: row.id, - type: row.element_type, - subtype: row.element_subtype, - position: { - x: row.position_x, - y: row.position_y - }, - size: { - width: row.width, - height: row.height - }, - title: row.title, - content: row.content, - dataSource: JSON.parse(row.data_source_config || '{}'), - chartConfig: JSON.parse(row.chart_config || '{}') - })); - + const elements: DashboardElement[] = elementsResult.rows.map( + (row: any) => ({ + id: row.id, + type: row.element_type, + subtype: row.element_subtype, + position: { + x: row.position_x, + y: row.position_y, + }, + size: { + width: row.width, + height: row.height, + }, + title: row.title, + content: row.content, + dataSource: JSON.parse(row.data_source_config || "{}"), + chartConfig: JSON.parse(row.chart_config || "{}"), + }) + ); + return { id: dashboard.id, title: dashboard.title, @@ -327,44 +350,47 @@ export class DashboardService { createdBy: dashboard.created_by, createdAt: dashboard.created_at, updatedAt: dashboard.updated_at, - tags: JSON.parse(dashboard.tags || '[]'), + tags: JSON.parse(dashboard.tags || "[]"), category: dashboard.category, - viewCount: parseInt(dashboard.view_count || '0'), - elements + viewCount: parseInt(dashboard.view_count || "0"), + elements, }; } catch (error) { - console.error('Dashboard get error:', error); + console.error("Dashboard get error:", error); throw error; } } - + /** * 대시보드 업데이트 */ static async updateDashboard( - dashboardId: string, - data: UpdateDashboardRequest, + dashboardId: string, + data: UpdateDashboardRequest, userId: string ): Promise { try { const result = await PostgreSQLService.transaction(async (client) => { // 권한 체크 - const authCheckResult = await client.query(` + const authCheckResult = await client.query( + ` SELECT id FROM dashboards WHERE id = $1 AND created_by = $2 AND deleted_at IS NULL - `, [dashboardId, userId]); - + `, + [dashboardId, userId] + ); + if (authCheckResult.rows.length === 0) { - throw new Error('대시보드를 찾을 수 없거나 수정 권한이 없습니다.'); + throw new Error("대시보드를 찾을 수 없거나 수정 권한이 없습니다."); } - + const now = new Date(); - + // 1. 대시보드 메인 정보 업데이트 const updateFields: string[] = []; const updateParams: any[] = []; let paramIndex = 1; - + if (data.title !== undefined) { updateFields.push(`title = $${paramIndex}`); updateParams.push(data.title); @@ -390,120 +416,141 @@ export class DashboardService { updateParams.push(data.category); paramIndex++; } - + if (data.settings !== undefined) { + updateFields.push(`settings = $${paramIndex}`); + updateParams.push(JSON.stringify(data.settings)); + paramIndex++; + } + updateFields.push(`updated_at = $${paramIndex}`); updateParams.push(now); paramIndex++; - + updateParams.push(dashboardId); - - if (updateFields.length > 1) { // updated_at 외에 다른 필드가 있는 경우 + + if (updateFields.length > 1) { + // updated_at 외에 다른 필드가 있는 경우 const updateQuery = ` UPDATE dashboards - SET ${updateFields.join(', ')} + SET ${updateFields.join(", ")} WHERE id = $${paramIndex} `; - + await client.query(updateQuery, updateParams); } - + // 2. 요소 업데이트 (있는 경우) if (data.elements) { // 기존 요소들 삭제 - await client.query(` + await client.query( + ` DELETE FROM dashboard_elements WHERE dashboard_id = $1 - `, [dashboardId]); - + `, + [dashboardId] + ); + // 새 요소들 추가 for (let i = 0; i < data.elements.length; i++) { const element = data.elements[i]; const elementId = uuidv4(); - - await client.query(` + + await client.query( + ` INSERT INTO dashboard_elements ( id, dashboard_id, element_type, element_subtype, position_x, position_y, width, height, title, content, data_source_config, chart_config, display_order, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) - `, [ - elementId, - dashboardId, - element.type, - element.subtype, - element.position.x, - element.position.y, - element.size.width, - element.size.height, - element.title, - element.content || null, - JSON.stringify(element.dataSource || {}), - JSON.stringify(element.chartConfig || {}), - i, - now, - now - ]); + `, + [ + elementId, + dashboardId, + element.type, + element.subtype, + element.position.x, + element.position.y, + element.size.width, + element.size.height, + element.title, + element.content || null, + JSON.stringify(element.dataSource || {}), + JSON.stringify(element.chartConfig || {}), + i, + now, + now, + ] + ); } } - + return dashboardId; }); - + // 업데이트된 대시보드 반환 return await this.getDashboardById(dashboardId, userId); - } catch (error) { - console.error('Dashboard update error:', error); + console.error("Dashboard update error:", error); throw error; } } - + /** * 대시보드 삭제 (소프트 삭제) */ - static async deleteDashboard(dashboardId: string, userId: string): Promise { + static async deleteDashboard( + dashboardId: string, + userId: string + ): Promise { try { const now = new Date(); - - const result = await PostgreSQLService.query(` + + const result = await PostgreSQLService.query( + ` UPDATE dashboards SET deleted_at = $1, updated_at = $2 WHERE id = $3 AND created_by = $4 AND deleted_at IS NULL - `, [now, now, dashboardId, userId]); - + `, + [now, now, dashboardId, userId] + ); + return (result.rowCount || 0) > 0; } catch (error) { - console.error('Dashboard delete error:', error); + console.error("Dashboard delete error:", error); throw error; } } - + /** * 조회수 증가 */ static async incrementViewCount(dashboardId: string): Promise { try { - await PostgreSQLService.query(` + await PostgreSQLService.query( + ` UPDATE dashboards SET view_count = view_count + 1 WHERE id = $1 AND deleted_at IS NULL - `, [dashboardId]); + `, + [dashboardId] + ); } catch (error) { - console.error('View count increment error:', error); + console.error("View count increment error:", error); // 조회수 증가 실패는 치명적이지 않으므로 에러를 던지지 않음 } } - + /** * 사용자 권한 체크 */ static async checkUserPermission( - dashboardId: string, - userId: string, - requiredPermission: 'view' | 'edit' | 'admin' = 'view' + dashboardId: string, + userId: string, + requiredPermission: "view" | "edit" | "admin" = "view" ): Promise { try { - const result = await PostgreSQLService.query(` + const result = await PostgreSQLService.query( + ` SELECT CASE WHEN d.created_by = $2 THEN 'admin' @@ -512,23 +559,26 @@ export class DashboardService { END as permission FROM dashboards d WHERE d.id = $1 AND d.deleted_at IS NULL - `, [dashboardId, userId]); - + `, + [dashboardId, userId] + ); + if (result.rows.length === 0) { return false; } - + const userPermission = result.rows[0].permission; - + // 권한 레벨 체크 - const permissionLevels = { 'view': 1, 'edit': 2, 'admin': 3 }; - const userLevel = permissionLevels[userPermission as keyof typeof permissionLevels] || 0; + const permissionLevels = { view: 1, edit: 2, admin: 3 }; + const userLevel = + permissionLevels[userPermission as keyof typeof permissionLevels] || 0; const requiredLevel = permissionLevels[requiredPermission]; - + return userLevel >= requiredLevel; } catch (error) { - console.error('Permission check error:', error); + console.error("Permission check error:", error); return false; } } -} \ No newline at end of file +} diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 4683ee65..63451284 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -33,7 +33,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D const [canvasBackgroundColor, setCanvasBackgroundColor] = useState("#f9fafb"); const canvasRef = useRef(null); - // 화면 해상도 자동 감지 및 기본 해상도 설정 + // 화면 해상도 자동 감지 const [screenResolution] = useState(() => detectScreenResolution()); const [resolution, setResolution] = useState(screenResolution); @@ -62,6 +62,22 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D setDashboardId(dashboard.id); setDashboardTitle(dashboard.title); + // 저장된 설정 복원 + console.log("🔍 로드된 대시보드:", dashboard); + console.log("📦 저장된 settings:", (dashboard as any).settings); + + if ((dashboard as any).settings?.resolution) { + const savedResolution = (dashboard as any).settings.resolution as Resolution; + console.log("✅ 저장된 해상도 복원:", savedResolution); + setResolution(savedResolution); + } else { + console.log("⚠️ 저장된 해상도 없음"); + } + + if ((dashboard as any).settings?.backgroundColor) { + setCanvasBackgroundColor((dashboard as any).settings.backgroundColor); + } + // 요소들 설정 if (dashboard.elements && dashboard.elements.length > 0) { setElements(dashboard.elements); @@ -241,9 +257,17 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D if (dashboardId) { // 기존 대시보드 업데이트 - savedDashboard = await dashboardApi.updateDashboard(dashboardId, { + const updateData = { elements: elementsData, - }); + settings: { + resolution, + backgroundColor: canvasBackgroundColor, + }, + }; + console.log("💾 저장할 데이터:", updateData); + console.log("💾 저장할 해상도:", resolution); + + savedDashboard = await dashboardApi.updateDashboard(dashboardId, updateData); alert(`대시보드 "${savedDashboard.title}"이 업데이트되었습니다!`); @@ -261,6 +285,10 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D description: description || undefined, isPublic: false, elements: elementsData, + settings: { + resolution, + backgroundColor: canvasBackgroundColor, + }, }; savedDashboard = await dashboardApi.createDashboard(dashboardData); diff --git a/frontend/lib/api/dashboard.ts b/frontend/lib/api/dashboard.ts index f6365854..7767d89b 100644 --- a/frontend/lib/api/dashboard.ts +++ b/frontend/lib/api/dashboard.ts @@ -78,6 +78,10 @@ export interface Dashboard { elementsCount?: number; creatorName?: string; elements?: DashboardElement[]; + settings?: { + resolution?: string; + backgroundColor?: string; + }; } export interface CreateDashboardRequest { @@ -87,6 +91,10 @@ export interface CreateDashboardRequest { elements: DashboardElement[]; tags?: string[]; category?: string; + settings?: { + resolution?: string; + backgroundColor?: string; + }; } export interface DashboardListQuery { From a7123216f23e1a3ce55b7a0dd56f84d38fcf7cee Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 16 Oct 2025 11:03:57 +0900 Subject: [PATCH 06/10] =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/types/dashboard.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/backend-node/src/types/dashboard.ts b/backend-node/src/types/dashboard.ts index c37beae8..7d5bfab6 100644 --- a/backend-node/src/types/dashboard.ts +++ b/backend-node/src/types/dashboard.ts @@ -4,8 +4,8 @@ export interface DashboardElement { id: string; - type: 'chart' | 'widget'; - subtype: 'bar' | 'pie' | 'line' | 'exchange' | 'weather'; + type: "chart" | "widget"; + subtype: "bar" | "pie" | "line" | "exchange" | "weather"; position: { x: number; y: number; @@ -17,7 +17,7 @@ export interface DashboardElement { title: string; content?: string; dataSource?: { - type: 'api' | 'database' | 'static'; + type: "api" | "database" | "static"; endpoint?: string; query?: string; refreshInterval?: number; @@ -28,7 +28,7 @@ export interface DashboardElement { xAxis?: string; yAxis?: string; groupBy?: string; - aggregation?: 'sum' | 'avg' | 'count' | 'max' | 'min'; + aggregation?: "sum" | "avg" | "count" | "max" | "min"; colors?: string[]; title?: string; showLegend?: boolean; @@ -58,6 +58,10 @@ export interface CreateDashboardRequest { elements: DashboardElement[]; tags?: string[]; category?: string; + settings?: { + resolution?: string; + backgroundColor?: string; + }; } export interface UpdateDashboardRequest { @@ -67,6 +71,10 @@ export interface UpdateDashboardRequest { elements?: DashboardElement[]; tags?: string[]; category?: string; + settings?: { + resolution?: string; + backgroundColor?: string; + }; } export interface DashboardListQuery { @@ -83,7 +91,7 @@ export interface DashboardShare { dashboardId: string; sharedWithUser?: string; sharedWithRole?: string; - permissionLevel: 'view' | 'edit' | 'admin'; + permissionLevel: "view" | "edit" | "admin"; createdBy: string; createdAt: string; expiresAt?: string; From 3bda194bf204e2f20d1eae9a334fa0204ff453cc Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 16 Oct 2025 11:09:11 +0900 Subject: [PATCH 07/10] =?UTF-8?q?=EC=BA=94=EB=B2=84=EC=8A=A4=20=EB=8F=99?= =?UTF-8?q?=EC=A0=81=20=EB=86=92=EC=9D=B4=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/DashboardCanvas.tsx | 4 +++- .../admin/dashboard/DashboardDesigner.tsx | 22 ++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/frontend/components/admin/dashboard/DashboardCanvas.tsx b/frontend/components/admin/dashboard/DashboardCanvas.tsx index f74af42d..d05f73cf 100644 --- a/frontend/components/admin/dashboard/DashboardCanvas.tsx +++ b/frontend/components/admin/dashboard/DashboardCanvas.tsx @@ -115,9 +115,11 @@ export const DashboardCanvas = forwardRef( return (
{ + if (elements.length === 0) { + return canvasConfig.height; // 기본 높이 + } + + // 모든 요소의 최하단 y 좌표 계산 + const maxBottomY = Math.max(...elements.map((el) => el.position.y + el.size.height)); + + // 최소 높이는 기본 높이, 요소가 아래로 내려가면 자동으로 늘어남 + // 패딩 추가 (100px 여유) + return Math.max(canvasConfig.height, maxBottomY + 100); + }, [elements, canvasConfig.height]); + + const dynamicCanvasHeight = calculateCanvasHeight(); + // 대시보드 ID가 props로 전달되면 로드 React.useEffect(() => { if (initialDashboardId) { @@ -335,12 +351,12 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D /> {/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */} -
+
From 8e0ef82de76a5069eb826147fee8cff22f63efe0 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 16 Oct 2025 11:29:45 +0900 Subject: [PATCH 08/10] =?UTF-8?q?=ED=95=B4=EC=83=81=EB=8F=84=20=EB=B3=B5?= =?UTF-8?q?=EC=9B=90=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/services/DashboardService.ts | 45 ++++++++----------- backend-node/src/types/dashboard.ts | 4 ++ .../admin/dashboard/DashboardDesigner.tsx | 25 +++++++++-- 3 files changed, 44 insertions(+), 30 deletions(-) diff --git a/backend-node/src/services/DashboardService.ts b/backend-node/src/services/DashboardService.ts index 82ef499e..d7245ce0 100644 --- a/backend-node/src/services/DashboardService.ts +++ b/backend-node/src/services/DashboardService.ts @@ -321,34 +321,24 @@ export class DashboardService { ]); // 3. 요소 데이터 변환 - console.log("📊 대시보드 요소 개수:", elementsResult.rows.length); - const elements: DashboardElement[] = elementsResult.rows.map( - (row: any, index: number) => { - const element = { - id: row.id, - type: row.element_type, - subtype: row.element_subtype, - position: { - x: row.position_x, - y: row.position_y, - }, - size: { - width: row.width, - height: row.height, - }, - title: row.title, - content: row.content, - dataSource: JSON.parse(row.data_source_config || "{}"), - chartConfig: JSON.parse(row.chart_config || "{}"), - }; - - console.log( - `📊 위젯 #${index + 1}: type="${element.type}", subtype="${element.subtype}", title="${element.title}"` - ); - - return element; - } + (row: any) => ({ + id: row.id, + type: row.element_type, + subtype: row.element_subtype, + position: { + x: row.position_x, + y: row.position_y, + }, + size: { + width: row.width, + height: row.height, + }, + title: row.title, + content: row.content, + dataSource: JSON.parse(row.data_source_config || "{}"), + chartConfig: JSON.parse(row.chart_config || "{}"), + }) ); return { @@ -363,6 +353,7 @@ export class DashboardService { tags: JSON.parse(dashboard.tags || "[]"), category: dashboard.category, viewCount: parseInt(dashboard.view_count || "0"), + settings: dashboard.settings || undefined, elements, }; } catch (error) { diff --git a/backend-node/src/types/dashboard.ts b/backend-node/src/types/dashboard.ts index 7d5bfab6..f40ee768 100644 --- a/backend-node/src/types/dashboard.ts +++ b/backend-node/src/types/dashboard.ts @@ -48,6 +48,10 @@ export interface Dashboard { tags?: string[]; category?: string; viewCount: number; + settings?: { + resolution?: string; + backgroundColor?: string; + }; elements: DashboardElement[]; } diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 86d5fa40..60983eff 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -37,6 +37,15 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D const [screenResolution] = useState(() => detectScreenResolution()); const [resolution, setResolution] = useState(screenResolution); + // resolution 변경 감지 + const handleResolutionChange = useCallback((newResolution: Resolution) => { + console.log("🎯 해상도 변경 요청:", newResolution); + setResolution((prev) => { + console.log("🎯 이전 해상도:", prev); + return newResolution; + }); + }, []); + // 현재 해상도 설정 (안전하게 기본값 제공) const canvasConfig = RESOLUTIONS[resolution] || RESOLUTIONS.fhd; @@ -81,6 +90,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D // 저장된 설정 복원 console.log("🔍 로드된 대시보드:", dashboard); console.log("📦 저장된 settings:", (dashboard as any).settings); + console.log("🎯 settings 타입:", typeof (dashboard as any).settings); + console.log("🔍 resolution 값:", (dashboard as any).settings?.resolution); if ((dashboard as any).settings?.resolution) { const savedResolution = (dashboard as any).settings.resolution as Resolution; @@ -91,6 +102,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D } if ((dashboard as any).settings?.backgroundColor) { + console.log("✅ 저장된 배경색 복원:", (dashboard as any).settings.backgroundColor); setCanvasBackgroundColor((dashboard as any).settings.backgroundColor); } @@ -273,6 +285,9 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D if (dashboardId) { // 기존 대시보드 업데이트 + console.log("💾 저장 시작 - 현재 resolution 상태:", resolution); + console.log("💾 저장 시작 - 현재 배경색 상태:", canvasBackgroundColor); + const updateData = { elements: elementsData, settings: { @@ -280,11 +295,15 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D backgroundColor: canvasBackgroundColor, }, }; + console.log("💾 저장할 데이터:", updateData); - console.log("💾 저장할 해상도:", resolution); + console.log("💾 저장할 settings:", updateData.settings); savedDashboard = await dashboardApi.updateDashboard(dashboardId, updateData); + console.log("✅ 저장된 대시보드:", savedDashboard); + console.log("✅ 저장된 settings:", (savedDashboard as any).settings); + alert(`대시보드 "${savedDashboard.title}"이 업데이트되었습니다!`); // Next.js 라우터로 뷰어 페이지 이동 @@ -319,7 +338,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류"; alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}\n\n관리자에게 문의하세요.`); } - }, [elements, dashboardId, router]); + }, [elements, dashboardId, router, resolution, canvasBackgroundColor]); // 로딩 중이면 로딩 화면 표시 if (isLoading) { @@ -344,7 +363,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D dashboardTitle={dashboardTitle} onAddElement={addElementFromMenu} resolution={resolution} - onResolutionChange={setResolution} + onResolutionChange={handleResolutionChange} currentScreenResolution={screenResolution} backgroundColor={canvasBackgroundColor} onBackgroundColorChange={setCanvasBackgroundColor} From a8c1b4b5e5110b86e078be29f6676e2e7aa65eec Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 16 Oct 2025 11:49:06 +0900 Subject: [PATCH 09/10] =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EB=B7=B0/?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EB=AA=A8=EB=93=9C=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=8F=99=EC=9D=BC=ED=95=9C=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=A0=9C=EA=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/dashboard/DashboardViewer.tsx | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index c6e941e3..f4f8caea 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -25,7 +25,18 @@ const DocumentWidget = dynamic(() => import("./widgets/DocumentWidget"), { ssr: const BookingAlertWidget = dynamic(() => import("./widgets/BookingAlertWidget"), { ssr: false }); const MaintenanceWidget = dynamic(() => import("./widgets/MaintenanceWidget"), { ssr: false }); const CalculatorWidget = dynamic(() => import("./widgets/CalculatorWidget"), { ssr: false }); -const CalendarWidget = dynamic(() => import("@/components/admin/dashboard/widgets/CalendarWidget").then(mod => ({ default: mod.CalendarWidget })), { ssr: false }); +const CalendarWidget = dynamic( + () => import("@/components/admin/dashboard/widgets/CalendarWidget").then((mod) => ({ default: mod.CalendarWidget })), + { ssr: false }, +); +const ClockWidget = dynamic( + () => import("@/components/admin/dashboard/widgets/ClockWidget").then((mod) => ({ default: mod.ClockWidget })), + { ssr: false }, +); +const ListWidget = dynamic( + () => import("@/components/admin/dashboard/widgets/ListWidget").then((mod) => ({ default: mod.ListWidget })), + { ssr: false }, +); /** * 위젯 렌더링 함수 - DashboardSidebar의 모든 subtype 처리 @@ -34,7 +45,7 @@ const CalendarWidget = dynamic(() => import("@/components/admin/dashboard/widget function renderWidget(element: DashboardElement) { switch (element.subtype) { // 차트는 ChartRenderer에서 처리됨 (이 함수 호출 안됨) - + // === 위젯 종류 === case "exchange": return ; @@ -43,14 +54,7 @@ function renderWidget(element: DashboardElement) { case "calculator": return ; case "clock": - return ( -
-
-
-
시계 위젯 (개발 예정)
-
-
- ); + return ; case "map-summary": return ; case "list-summary": @@ -61,7 +65,7 @@ function renderWidget(element: DashboardElement) { return ; case "status-summary": return ; - + // === 운영/작업 지원 === case "todo": return ; @@ -72,8 +76,8 @@ function renderWidget(element: DashboardElement) { case "document": return ; case "list": - return ; - + return ; + // === 차량 관련 (추가 위젯) === case "vehicle-status": return ; @@ -81,7 +85,7 @@ function renderWidget(element: DashboardElement) { return ; case "vehicle-map": return ; - + // === 배송 관련 (추가 위젯) === case "delivery-status": return ; @@ -93,7 +97,7 @@ function renderWidget(element: DashboardElement) { return ; case "customer-issues": return ; - + // === 기본 fallback === default: return ( @@ -301,7 +305,9 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
{element.type === "chart" ? ( - ) : renderWidget(element)} + ) : ( + renderWidget(element) + )}
{/* 로딩 오버레이 */} From c5499d2e18a59ebce88edcdf4a7ea47dbb8555fc Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 16 Oct 2025 11:55:14 +0900 Subject: [PATCH 10/10] =?UTF-8?q?=ED=95=B4=EC=83=81=EB=8F=84=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EC=8B=9C=20=EC=9A=94=EC=86=8C=EB=93=A4=20=EA=B0=84?= =?UTF-8?q?=EA=B2=A9=20=EC=A1=B0=EC=A0=88=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/DashboardDesigner.tsx | 62 ++++++++++++++++--- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 60983eff..048e5172 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -7,7 +7,7 @@ import { DashboardTopMenu } from "./DashboardTopMenu"; import { ElementConfigModal } from "./ElementConfigModal"; import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal"; import { DashboardElement, ElementType, ElementSubtype } from "./types"; -import { GRID_CONFIG } from "./gridUtils"; +import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize } from "./gridUtils"; import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector"; interface DashboardDesignerProps { @@ -37,14 +37,58 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D const [screenResolution] = useState(() => detectScreenResolution()); const [resolution, setResolution] = useState(screenResolution); - // resolution 변경 감지 - const handleResolutionChange = useCallback((newResolution: Resolution) => { - console.log("🎯 해상도 변경 요청:", newResolution); - setResolution((prev) => { - console.log("🎯 이전 해상도:", prev); - return newResolution; - }); - }, []); + // resolution 변경 감지 및 요소 자동 조정 + const handleResolutionChange = useCallback( + (newResolution: Resolution) => { + console.log("🎯 해상도 변경 요청:", newResolution); + setResolution((prev) => { + console.log("🎯 이전 해상도:", prev); + + // 이전 해상도와 새 해상도의 캔버스 너비 비율 계산 + const oldConfig = RESOLUTIONS[prev]; + const newConfig = RESOLUTIONS[newResolution]; + const widthRatio = newConfig.width / oldConfig.width; + + console.log("📐 너비 비율:", widthRatio, `(${oldConfig.width}px → ${newConfig.width}px)`); + + // 요소들의 위치와 크기를 비율에 맞춰 조정 + if (widthRatio !== 1 && elements.length > 0) { + // 새 해상도의 셀 크기 계산 + const newCellSize = calculateCellSize(newConfig.width); + + const adjustedElements = elements.map((el) => { + // 비율에 맞춰 조정 (X와 너비만) + const scaledX = el.position.x * widthRatio; + const scaledWidth = el.size.width * widthRatio; + + // 그리드에 스냅 (X, Y, 너비, 높이 모두) + const snappedX = snapToGrid(scaledX, newCellSize); + const snappedY = snapToGrid(el.position.y, newCellSize); + const snappedWidth = snapSizeToGrid(scaledWidth, 2, newCellSize); + const snappedHeight = snapSizeToGrid(el.size.height, 2, newCellSize); + + return { + ...el, + position: { + x: snappedX, + y: snappedY, + }, + size: { + width: snappedWidth, + height: snappedHeight, + }, + }; + }); + + console.log("✨ 요소 위치/크기 자동 조정 (그리드 스냅 적용):", adjustedElements.length, "개"); + setElements(adjustedElements); + } + + return newResolution; + }); + }, + [elements], + ); // 현재 해상도 설정 (안전하게 기본값 제공) const canvasConfig = RESOLUTIONS[resolution] || RESOLUTIONS.fhd;