From 18e2280623513900bf7a3cd2dc15d953dd687ba2 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 16 Oct 2025 09:55:14 +0900 Subject: [PATCH] =?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 포함) */