대시보드 방식 이전
This commit is contained in:
parent
d7613713cf
commit
18e2280623
|
|
@ -156,8 +156,6 @@ export default function DashboardListPage() {
|
|||
<TableRow>
|
||||
<TableHead>제목</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead>요소 수</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead>생성일</TableHead>
|
||||
<TableHead>수정일</TableHead>
|
||||
<TableHead className="w-[80px]">작업</TableHead>
|
||||
|
|
@ -166,29 +164,10 @@ export default function DashboardListPage() {
|
|||
<TableBody>
|
||||
{dashboards.map((dashboard) => (
|
||||
<TableRow key={dashboard.id} className="cursor-pointer hover:bg-gray-50">
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
{dashboard.title}
|
||||
{dashboard.isPublic && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
공개
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{dashboard.title}</TableCell>
|
||||
<TableCell className="max-w-md truncate text-sm text-gray-500">
|
||||
{dashboard.description || "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{dashboard.elementsCount || 0}개</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{dashboard.isPublic ? (
|
||||
<Badge className="bg-green-100 text-green-800">공개</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">비공개</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">{formatDate(dashboard.createdAt)}</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">{formatDate(dashboard.updatedAt)}</TableCell>
|
||||
<TableCell>
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ interface CanvasElementProps {
|
|||
element: DashboardElement;
|
||||
isSelected: boolean;
|
||||
cellSize: number;
|
||||
canvasWidth?: number;
|
||||
onUpdate: (id: string, updates: Partial<DashboardElement>) => 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" ? (
|
||||
// 커스텀 상태 카드 - 범용 위젯
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<StatusSummaryWidget
|
||||
element={element}
|
||||
title="상태 요약"
|
||||
icon="📊"
|
||||
bgGradient="from-slate-50 to-blue-50"
|
||||
/>
|
||||
<StatusSummaryWidget element={element} title="상태 요약" icon="📊" bgGradient="from-slate-50 to-blue-50" />
|
||||
</div>
|
||||
) : /* element.type === "widget" && element.subtype === "list-summary" ? (
|
||||
// 커스텀 목록 카드 - 범용 위젯 (다른 분 작업 중 - 임시 주석)
|
||||
|
|
@ -560,7 +557,7 @@ export function CanvasElement({
|
|||
) : */ element.type === "widget" && element.subtype === "delivery-status" ? (
|
||||
// 배송/화물 현황 위젯 - 범용 위젯 사용 (구버전 호환)
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<StatusSummaryWidget
|
||||
<StatusSummaryWidget
|
||||
element={element}
|
||||
title="배송/화물 현황"
|
||||
icon="📦"
|
||||
|
|
@ -570,23 +567,23 @@ export function CanvasElement({
|
|||
) : element.type === "widget" && element.subtype === "delivery-status-summary" ? (
|
||||
// 배송 상태 요약 - 범용 위젯 사용
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<StatusSummaryWidget
|
||||
<StatusSummaryWidget
|
||||
element={element}
|
||||
title="배송 상태 요약"
|
||||
icon="📊"
|
||||
bgGradient="from-slate-50 to-blue-50"
|
||||
statusConfig={{
|
||||
"배송중": { label: "배송중", color: "blue" },
|
||||
"완료": { label: "완료", color: "green" },
|
||||
"지연": { label: "지연", color: "red" },
|
||||
"픽업 대기": { label: "픽업 대기", color: "yellow" }
|
||||
배송중: { label: "배송중", color: "blue" },
|
||||
완료: { label: "완료", color: "green" },
|
||||
지연: { label: "지연", color: "red" },
|
||||
"픽업 대기": { label: "픽업 대기", color: "yellow" },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "delivery-today-stats" ? (
|
||||
// 오늘 처리 현황 - 범용 위젯 사용
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<StatusSummaryWidget
|
||||
<StatusSummaryWidget
|
||||
element={element}
|
||||
title="오늘 처리 현황"
|
||||
icon="📈"
|
||||
|
|
@ -596,7 +593,7 @@ export function CanvasElement({
|
|||
) : element.type === "widget" && element.subtype === "cargo-list" ? (
|
||||
// 화물 목록 - 범용 위젯 사용
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<StatusSummaryWidget
|
||||
<StatusSummaryWidget
|
||||
element={element}
|
||||
title="화물 목록"
|
||||
icon="📦"
|
||||
|
|
@ -606,7 +603,7 @@ export function CanvasElement({
|
|||
) : element.type === "widget" && element.subtype === "customer-issues" ? (
|
||||
// 고객 클레임/이슈 - 범용 위젯 사용
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<StatusSummaryWidget
|
||||
<StatusSummaryWidget
|
||||
element={element}
|
||||
title="고객 클레임/이슈"
|
||||
icon="⚠️"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import React, { forwardRef, useState, useCallback, useEffect } from "react";
|
||||
import React, { forwardRef, useState, useCallback, useMemo } from "react";
|
||||
import { DashboardElement, ElementType, ElementSubtype, DragData } from "./types";
|
||||
import { CanvasElement } from "./CanvasElement";
|
||||
import { GRID_CONFIG, snapToGrid } from "./gridUtils";
|
||||
import { GRID_CONFIG, snapToGrid, calculateGridConfig } from "./gridUtils";
|
||||
|
||||
interface DashboardCanvasProps {
|
||||
elements: DashboardElement[];
|
||||
|
|
@ -14,6 +14,8 @@ interface DashboardCanvasProps {
|
|||
onSelectElement: (id: string | null) => void;
|
||||
onConfigureElement?: (element: DashboardElement) => void;
|
||||
backgroundColor?: string;
|
||||
canvasWidth?: number;
|
||||
canvasHeight?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -34,11 +36,17 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
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<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, 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<HTMLDivElement, DashboardCanvasProps>(
|
|||
[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 (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`relative rounded-lg shadow-inner ${isDragOver ? "bg-blue-50/50" : ""} `}
|
||||
className={`relative h-full w-full rounded-lg shadow-inner ${isDragOver ? "bg-blue-50/50" : ""} `}
|
||||
style={{
|
||||
backgroundColor,
|
||||
width: `${GRID_CONFIG.CANVAS_WIDTH}px`,
|
||||
minHeight: `${minCanvasHeight}px`,
|
||||
// 12 컬럼 그리드 배경
|
||||
// 세밀한 그리드 배경
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(59, 130, 246, 0.15) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(59, 130, 246, 0.15) 1px, transparent 1px)
|
||||
linear-gradient(rgba(59, 130, 246, 0.08) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(59, 130, 246, 0.08) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: gridSize,
|
||||
backgroundPosition: "0 0",
|
||||
|
|
@ -129,13 +132,33 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
onDrop={handleDrop}
|
||||
onClick={handleCanvasClick}
|
||||
>
|
||||
{/* 12개 컬럼 메인 구분선 */}
|
||||
{columnLines.map((x, i) => (
|
||||
<div
|
||||
key={`col-${i}`}
|
||||
className="pointer-events-none absolute top-0 h-full"
|
||||
style={{
|
||||
left: `${x}px`,
|
||||
width: "2px",
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* 배치된 요소들 렌더링 */}
|
||||
{elements.length === 0 && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-gray-400">
|
||||
<div className="text-center">
|
||||
<div className="text-sm">상단 메뉴에서 차트나 위젯을 선택하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{elements.map((element) => (
|
||||
<CanvasElement
|
||||
key={element.id}
|
||||
element={element}
|
||||
isSelected={selectedElement === element.id}
|
||||
cellSize={GRID_CONFIG.CELL_SIZE}
|
||||
cellSize={cellSize}
|
||||
canvasWidth={canvasWidth}
|
||||
onUpdate={onUpdateElement}
|
||||
onRemove={onRemoveElement}
|
||||
onSelect={onSelectElement}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@
|
|||
import React, { useState, useRef, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DashboardCanvas } from "./DashboardCanvas";
|
||||
import { DashboardSidebar } from "./DashboardSidebar";
|
||||
import { DashboardToolbar } from "./DashboardToolbar";
|
||||
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 { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector";
|
||||
|
||||
interface DashboardDesignerProps {
|
||||
dashboardId?: string;
|
||||
|
|
@ -33,6 +33,13 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
const [canvasBackgroundColor, setCanvasBackgroundColor] = useState<string>("#f9fafb");
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 화면 해상도 자동 감지 및 기본 해상도 설정
|
||||
const [screenResolution] = useState<Resolution>(() => detectScreenResolution());
|
||||
const [resolution, setResolution] = useState<Resolution>(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 (
|
||||
<div className="flex h-full bg-gray-50">
|
||||
{/* 캔버스 영역 */}
|
||||
<div className="relative flex-1 overflow-auto border-r-2 border-gray-300 bg-gray-100">
|
||||
{/* 편집 중인 대시보드 표시 */}
|
||||
{dashboardTitle && (
|
||||
<div className="bg-accent0 absolute top-6 left-6 z-10 rounded-lg px-3 py-1 text-sm font-medium text-white shadow-lg">
|
||||
📝 편집 중: {dashboardTitle}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex h-full flex-col bg-gray-50">
|
||||
{/* 상단 메뉴바 */}
|
||||
<DashboardTopMenu
|
||||
onSaveLayout={saveLayout}
|
||||
onClearCanvas={clearCanvas}
|
||||
onViewDashboard={dashboardId ? () => router.push(`/dashboard/${dashboardId}`) : undefined}
|
||||
dashboardTitle={dashboardTitle}
|
||||
onAddElement={addElementFromMenu}
|
||||
resolution={resolution}
|
||||
onResolutionChange={setResolution}
|
||||
currentScreenResolution={screenResolution}
|
||||
backgroundColor={canvasBackgroundColor}
|
||||
onBackgroundColorChange={setCanvasBackgroundColor}
|
||||
/>
|
||||
|
||||
<DashboardToolbar
|
||||
onClearCanvas={clearCanvas}
|
||||
onSaveLayout={saveLayout}
|
||||
canvasBackgroundColor={canvasBackgroundColor}
|
||||
onCanvasBackgroundColorChange={setCanvasBackgroundColor}
|
||||
/>
|
||||
|
||||
{/* 캔버스 중앙 정렬 컨테이너 */}
|
||||
<div className="flex justify-center p-4">
|
||||
{/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */}
|
||||
<div className="flex flex-1 items-center justify-center overflow-hidden bg-gray-100">
|
||||
<div
|
||||
className="relative shadow-2xl"
|
||||
style={{
|
||||
width: `${canvasConfig.width}px`,
|
||||
height: `${canvasConfig.height}px`,
|
||||
}}
|
||||
>
|
||||
<DashboardCanvas
|
||||
ref={canvasRef}
|
||||
elements={elements}
|
||||
|
|
@ -274,13 +312,12 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
onSelectElement={setSelectedElement}
|
||||
onConfigureElement={openConfigModal}
|
||||
backgroundColor={canvasBackgroundColor}
|
||||
canvasWidth={canvasConfig.width}
|
||||
canvasHeight={canvasConfig.height}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사이드바 */}
|
||||
<DashboardSidebar />
|
||||
|
||||
{/* 요소 설정 모달 */}
|
||||
{configModalElement && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex h-16 items-center justify-between border-b bg-white px-6 shadow-sm">
|
||||
{/* 좌측: 대시보드 제목 */}
|
||||
<div className="flex items-center gap-4">
|
||||
{dashboardTitle && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold text-gray-900">{dashboardTitle}</span>
|
||||
<span className="rounded bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">편집 중</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 중앙: 해상도 선택 & 요소 추가 */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 해상도 선택 */}
|
||||
{onResolutionChange && (
|
||||
<ResolutionSelector
|
||||
value={resolution}
|
||||
onChange={onResolutionChange}
|
||||
currentScreenResolution={currentScreenResolution}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="h-6 w-px bg-gray-300" />
|
||||
|
||||
{/* 배경색 선택 */}
|
||||
{onBackgroundColorChange && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Palette className="h-4 w-4" />
|
||||
<div className="h-4 w-4 rounded border border-gray-300" style={{ backgroundColor }} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="z-[99999] w-64">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium">캔버스 배경색</label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
value={backgroundColor}
|
||||
onChange={(e) => onBackgroundColorChange(e.target.value)}
|
||||
className="h-10 w-20 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={backgroundColor}
|
||||
onChange={(e) => onBackgroundColorChange(e.target.value)}
|
||||
placeholder="#f9fafb"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
{[
|
||||
"#ffffff",
|
||||
"#f9fafb",
|
||||
"#f3f4f6",
|
||||
"#e5e7eb",
|
||||
"#1f2937",
|
||||
"#111827",
|
||||
"#fef3c7",
|
||||
"#fde68a",
|
||||
"#dbeafe",
|
||||
"#bfdbfe",
|
||||
"#fecaca",
|
||||
"#fca5a5",
|
||||
].map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
className="h-8 w-8 rounded border-2 transition-transform hover:scale-110"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
borderColor: backgroundColor === color ? "#3b82f6" : "#d1d5db",
|
||||
}}
|
||||
onClick={() => onBackgroundColorChange(color)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<div className="h-6 w-px bg-gray-300" />
|
||||
{/* 차트 선택 */}
|
||||
<Select onValueChange={handleChartSelect}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="차트 추가" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[99999]">
|
||||
<SelectGroup>
|
||||
<SelectLabel>차트</SelectLabel>
|
||||
<SelectItem value="bar">바 차트</SelectItem>
|
||||
<SelectItem value="horizontal-bar">수평 바 차트</SelectItem>
|
||||
<SelectItem value="stacked-bar">누적 바 차트</SelectItem>
|
||||
<SelectItem value="line">꺾은선 차트</SelectItem>
|
||||
<SelectItem value="area">영역 차트</SelectItem>
|
||||
<SelectItem value="pie">원형 차트</SelectItem>
|
||||
<SelectItem value="donut">도넛 차트</SelectItem>
|
||||
<SelectItem value="combo">콤보 차트</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 위젯 선택 */}
|
||||
<Select onValueChange={handleWidgetSelect}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="위젯 추가" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[99999]">
|
||||
<SelectGroup>
|
||||
<SelectLabel>데이터 위젯</SelectLabel>
|
||||
<SelectItem value="list">리스트 위젯</SelectItem>
|
||||
<SelectItem value="map">지도</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>일반 위젯</SelectLabel>
|
||||
<SelectItem value="weather">날씨</SelectItem>
|
||||
<SelectItem value="exchange">환율</SelectItem>
|
||||
<SelectItem value="calculator">계산기</SelectItem>
|
||||
<SelectItem value="calendar">달력</SelectItem>
|
||||
<SelectItem value="clock">시계</SelectItem>
|
||||
<SelectItem value="todo">할 일</SelectItem>
|
||||
<SelectItem value="booking-alert">예약 알림</SelectItem>
|
||||
<SelectItem value="maintenance">정비 일정</SelectItem>
|
||||
<SelectItem value="document">문서</SelectItem>
|
||||
<SelectItem value="risk-alert">리스크 알림</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>차량 관리</SelectLabel>
|
||||
<SelectItem value="vehicle-status">차량 상태</SelectItem>
|
||||
<SelectItem value="vehicle-list">차량 목록</SelectItem>
|
||||
<SelectItem value="vehicle-map">차량 위치</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 우측: 액션 버튼 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{onViewDashboard && (
|
||||
<Button variant="outline" size="sm" onClick={onViewDashboard} className="gap-2">
|
||||
<Eye className="h-4 w-4" />
|
||||
미리보기
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={onClearCanvas} className="gap-2 text-red-600 hover:text-red-700">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
초기화
|
||||
</Button>
|
||||
<Button size="sm" onClick={onSaveLayout} className="gap-2">
|
||||
<Save className="h-4 w-4" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Resolution, ResolutionConfig> = {
|
||||
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 (
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 text-gray-500" />
|
||||
<Select value={value} onValueChange={(v) => onChange(v as Resolution)}>
|
||||
<SelectTrigger className={`w-[180px] ${isTooLarge ? "border-orange-500" : ""}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[99999]">
|
||||
<SelectGroup>
|
||||
<SelectLabel>캔버스 해상도</SelectLabel>
|
||||
<SelectItem value="hd">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>HD</span>
|
||||
<span className="text-xs text-gray-500">1280x720</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="fhd">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Full HD</span>
|
||||
<span className="text-xs text-gray-500">1920x1080</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="qhd">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>QHD</span>
|
||||
<span className="text-xs text-gray-500">2560x1440</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="uhd">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>4K UHD</span>
|
||||
<span className="text-xs text-gray-500">3840x2160</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isTooLarge && <span className="text-xs text-orange-600">⚠️ 현재 화면보다 큽니다</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 포함)
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in New Issue