diff --git a/backend-node/src/services/DashboardService.ts b/backend-node/src/services/DashboardService.ts
index 68cc582f..92b5ed39 100644
--- a/backend-node/src/services/DashboardService.ts
+++ b/backend-node/src/services/DashboardService.ts
@@ -63,9 +63,9 @@ export class DashboardService {
id, dashboard_id, element_type, element_subtype,
position_x, position_y, width, height,
title, custom_title, show_header, content, data_source_config, chart_config,
- list_config, yard_config,
+ list_config, yard_config, custom_metric_config,
display_order, created_at, updated_at
- ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
`,
[
elementId,
@@ -84,6 +84,7 @@ export class DashboardService {
JSON.stringify(element.chartConfig || {}),
JSON.stringify(element.listConfig || null),
JSON.stringify(element.yardConfig || null),
+ JSON.stringify(element.customMetricConfig || null),
i,
now,
now,
@@ -391,6 +392,11 @@ export class DashboardService {
? JSON.parse(row.yard_config)
: row.yard_config
: undefined,
+ customMetricConfig: row.custom_metric_config
+ ? typeof row.custom_metric_config === "string"
+ ? JSON.parse(row.custom_metric_config)
+ : row.custom_metric_config
+ : undefined,
})
);
@@ -514,9 +520,9 @@ export class DashboardService {
id, dashboard_id, element_type, element_subtype,
position_x, position_y, width, height,
title, custom_title, show_header, content, data_source_config, chart_config,
- list_config, yard_config,
+ list_config, yard_config, custom_metric_config,
display_order, created_at, updated_at
- ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
`,
[
elementId,
@@ -535,6 +541,7 @@ export class DashboardService {
JSON.stringify(element.chartConfig || {}),
JSON.stringify(element.listConfig || null),
JSON.stringify(element.yardConfig || null),
+ JSON.stringify(element.customMetricConfig || null),
i,
now,
now,
diff --git a/backend-node/src/types/dashboard.ts b/backend-node/src/types/dashboard.ts
index b03acbff..7d6267a7 100644
--- a/backend-node/src/types/dashboard.ts
+++ b/backend-node/src/types/dashboard.ts
@@ -45,6 +45,17 @@ export interface DashboardElement {
layoutId: number;
layoutName?: string;
};
+ customMetricConfig?: {
+ metrics: Array<{
+ id: string;
+ field: string;
+ label: string;
+ aggregation: "count" | "sum" | "avg" | "min" | "max";
+ unit: string;
+ color: "indigo" | "green" | "blue" | "purple" | "orange" | "gray";
+ decimals: number;
+ }>;
+ };
}
export interface Dashboard {
diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx
index 9350642e..27a20dcb 100644
--- a/frontend/components/admin/dashboard/CanvasElement.tsx
+++ b/frontend/components/admin/dashboard/CanvasElement.tsx
@@ -4,7 +4,7 @@ import React, { useState, useCallback, useRef, useEffect } from "react";
import dynamic from "next/dynamic";
import { DashboardElement, QueryResult, Position } from "./types";
import { ChartRenderer } from "./charts/ChartRenderer";
-import { GRID_CONFIG } from "./gridUtils";
+import { GRID_CONFIG, magneticSnap, snapSizeToGrid } from "./gridUtils";
// 위젯 동적 임포트
const WeatherWidget = dynamic(() => import("@/components/dashboard/widgets/WeatherWidget"), {
@@ -126,6 +126,12 @@ const CustomStatsWidget = dynamic(() => import("@/components/dashboard/widgets/C
loading: () =>
로딩 중...
,
});
+// 사용자 커스텀 카드 위젯
+const CustomMetricWidget = dynamic(() => import("@/components/dashboard/widgets/CustomMetricWidget"), {
+ ssr: false,
+ loading: () => 로딩 중...
,
+});
+
interface CanvasElementProps {
element: DashboardElement;
isSelected: boolean;
@@ -135,6 +141,8 @@ interface CanvasElementProps {
cellSize: number;
subGridSize: number;
canvasWidth?: number;
+ verticalGuidelines: number[];
+ horizontalGuidelines: number[];
onUpdate: (id: string, updates: Partial) => void;
onUpdateMultiple?: (updates: { id: string; updates: Partial }[]) => void; // 🔥 다중 업데이트
onMultiDragStart?: (draggedId: string, otherOffsets: Record) => void;
@@ -159,6 +167,8 @@ export function CanvasElement({
cellSize,
subGridSize,
canvasWidth = 1560,
+ verticalGuidelines,
+ horizontalGuidelines,
onUpdate,
onUpdateMultiple,
onMultiDragStart,
@@ -307,7 +317,7 @@ export function CanvasElement({
const deltaX = e.clientX - dragStartRef.current.x;
const deltaY = e.clientY - dragStartRef.current.y + scrollDelta; // 🔥 스크롤 변화량 반영
- // 임시 위치 계산
+ // 임시 위치 계산 (드래그 중에는 부드럽게 이동)
let rawX = Math.max(0, dragStart.elementX + deltaX);
const rawY = Math.max(0, dragStart.elementY + deltaY);
@@ -315,15 +325,12 @@ export function CanvasElement({
const maxX = canvasWidth - element.size.width;
rawX = Math.min(rawX, maxX);
- // 드래그 중 실시간 스냅 (서브그리드만 사용)
- const snappedX = Math.round(rawX / subGridSize) * subGridSize;
- const snappedY = Math.round(rawY / subGridSize) * subGridSize;
-
- setTempPosition({ x: snappedX, y: snappedY });
+ // 드래그 중에는 스냅 없이 부드럽게 이동
+ setTempPosition({ x: rawX, y: rawY });
// 🔥 다중 드래그 중 - 다른 위젯들의 위치 업데이트
if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragMove) {
- onMultiDragMove(element, { x: snappedX, y: snappedY });
+ onMultiDragMove(element, { x: rawX, y: rawY });
}
} else if (isResizing) {
const deltaX = e.clientX - resizeStart.x;
@@ -367,15 +374,13 @@ export function CanvasElement({
const maxWidth = canvasWidth - newX;
newWidth = Math.min(newWidth, maxWidth);
- // 리사이즈 중 실시간 스냅 (서브그리드만 사용)
- const snappedX = Math.round(newX / subGridSize) * subGridSize;
- const snappedY = Math.round(newY / subGridSize) * subGridSize;
- const snappedWidth = Math.round(newWidth / subGridSize) * subGridSize;
- const snappedHeight = Math.round(newHeight / subGridSize) * subGridSize;
+ // 리사이즈 중에는 스냅 없이 부드럽게 조절
+ const boundedX = Math.max(0, Math.min(newX, canvasWidth - newWidth));
+ const boundedY = Math.max(0, newY);
- // 임시 크기/위치 저장 (스냅됨)
- setTempPosition({ x: Math.max(0, snappedX), y: Math.max(0, snappedY) });
- setTempSize({ width: snappedWidth, height: snappedHeight });
+ // 임시 크기/위치 저장 (부드러운 이동)
+ setTempPosition({ x: boundedX, y: boundedY });
+ setTempSize({ width: newWidth, height: newHeight });
}
},
[
@@ -386,7 +391,8 @@ export function CanvasElement({
element,
canvasWidth,
cellSize,
- subGridSize,
+ verticalGuidelines,
+ horizontalGuidelines,
selectedElements,
allElements,
onUpdateMultiple,
@@ -398,10 +404,9 @@ export function CanvasElement({
// 마우스 업 처리 (이미 스냅된 위치 사용)
const handleMouseUp = useCallback(() => {
if (isDragging && tempPosition) {
- // tempPosition은 이미 드래그 중에 마그네틱 스냅 적용됨
- // 다시 스냅하지 않고 그대로 사용!
- let finalX = tempPosition.x;
- const finalY = tempPosition.y;
+ // 마우스를 놓을 때 그리드에 스냅
+ let finalX = magneticSnap(tempPosition.x, verticalGuidelines);
+ const finalY = magneticSnap(tempPosition.y, horizontalGuidelines);
// X 좌표가 캔버스 너비를 벗어나지 않도록 최종 제한
const maxX = canvasWidth - element.size.width;
@@ -459,20 +464,19 @@ export function CanvasElement({
}
if (isResizing && tempPosition && tempSize) {
- // tempPosition과 tempSize는 이미 리사이즈 중에 마그네틱 스냅 적용됨
- // 다시 스냅하지 않고 그대로 사용!
- const finalX = tempPosition.x;
- const finalY = tempPosition.y;
- let finalWidth = tempSize.width;
- const finalHeight = tempSize.height;
+ // 마우스를 놓을 때 그리드에 스냅
+ const finalX = magneticSnap(tempPosition.x, verticalGuidelines);
+ const finalY = magneticSnap(tempPosition.y, horizontalGuidelines);
+ const finalWidth = snapSizeToGrid(tempSize.width, canvasWidth || 1560);
+ const finalHeight = snapSizeToGrid(tempSize.height, canvasWidth || 1560);
// 가로 너비가 캔버스를 벗어나지 않도록 최종 제한
const maxWidth = canvasWidth - finalX;
- finalWidth = Math.min(finalWidth, maxWidth);
+ const boundedWidth = Math.min(finalWidth, maxWidth);
onUpdate(element.id, {
position: { x: finalX, y: finalY },
- size: { width: finalWidth, height: finalHeight },
+ size: { width: boundedWidth, height: finalHeight },
});
setTempPosition(null);
@@ -504,6 +508,8 @@ export function CanvasElement({
allElements,
dragStart.elementX,
dragStart.elementY,
+ verticalGuidelines,
+ horizontalGuidelines,
]);
// 🔥 자동 스크롤 루프 (requestAnimationFrame 사용)
@@ -891,12 +897,7 @@ export function CanvasElement({
) : element.type === "widget" && element.subtype === "list" ? (
// 리스트 위젯 렌더링
- {
- onUpdate(element.id, { listConfig: newConfig as any });
- }}
- />
+
) : element.type === "widget" && element.subtype === "yard-management-3d" ? (
// 야드 관리 3D 위젯 렌더링
@@ -920,6 +921,11 @@ export function CanvasElement({
+ ) : element.type === "widget" && element.subtype === "custom-metric" ? (
+ // 사용자 커스텀 카드 위젯 렌더링
+
+
+
) : element.type === "widget" && element.subtype === "todo" ? (
// To-Do 위젯 렌더링
diff --git a/frontend/components/admin/dashboard/DashboardCanvas.tsx b/frontend/components/admin/dashboard/DashboardCanvas.tsx
index 1c2414c1..c77cf541 100644
--- a/frontend/components/admin/dashboard/DashboardCanvas.tsx
+++ b/frontend/components/admin/dashboard/DashboardCanvas.tsx
@@ -3,7 +3,15 @@
import React, { forwardRef, useState, useCallback, useMemo, useEffect } from "react";
import { DashboardElement, ElementType, ElementSubtype, DragData } from "./types";
import { CanvasElement } from "./CanvasElement";
-import { GRID_CONFIG, snapToGrid, calculateGridConfig } from "./gridUtils";
+import {
+ GRID_CONFIG,
+ snapToGrid,
+ calculateGridConfig,
+ calculateVerticalGuidelines,
+ calculateHorizontalGuidelines,
+ calculateBoxSize,
+ magneticSnap,
+} from "./gridUtils";
import { resolveAllCollisions } from "./collisionUtils";
interface DashboardCanvasProps {
@@ -40,14 +48,14 @@ export const DashboardCanvas = forwardRef
(
onSelectElement,
onSelectMultiple,
onConfigureElement,
- backgroundColor = "#f9fafb",
+ backgroundColor = "transparent",
canvasWidth = 1560,
canvasHeight = 768,
},
ref,
) => {
const [isDragOver, setIsDragOver] = useState(false);
-
+
// 🔥 선택 박스 상태
const [selectionBox, setSelectionBox] = useState<{
startX: number;
@@ -58,10 +66,10 @@ export const DashboardCanvas = forwardRef(
const [isSelecting, setIsSelecting] = useState(false);
const [justSelected, setJustSelected] = useState(false); // 🔥 방금 선택했는지 플래그
const [isDraggingAny, setIsDraggingAny] = useState(false); // 🔥 현재 드래그 중인지 플래그
-
+
// 🔥 다중 선택된 위젯들의 임시 위치 (드래그 중 시각적 피드백)
const [multiDragOffsets, setMultiDragOffsets] = useState>({});
-
+
// 🔥 선택 박스 드래그 중 자동 스크롤
const lastMouseYForSelectionRef = React.useRef(window.innerHeight / 2);
const selectionAutoScrollFrameRef = React.useRef(null);
@@ -70,6 +78,14 @@ export const DashboardCanvas = forwardRef(
const gridConfig = useMemo(() => calculateGridConfig(canvasWidth), [canvasWidth]);
const cellSize = gridConfig.CELL_SIZE;
+ // 🔥 그리드 박스 시스템 - 12개 박스가 캔버스 너비에 꽉 차게
+ const verticalGuidelines = useMemo(() => calculateVerticalGuidelines(canvasWidth), [canvasWidth]);
+ const horizontalGuidelines = useMemo(
+ () => calculateHorizontalGuidelines(canvasHeight, canvasWidth),
+ [canvasHeight, canvasWidth],
+ );
+ const boxSize = useMemo(() => calculateBoxSize(canvasWidth), [canvasWidth]);
+
// 충돌 방지 기능이 포함된 업데이트 핸들러
const handleUpdateWithCollisionDetection = useCallback(
(id: string, updates: Partial) => {
@@ -177,23 +193,13 @@ export const DashboardCanvas = forwardRef(
const rawX = e.clientX - rect.left + (ref.current?.scrollLeft || 0);
const rawY = e.clientY - rect.top + (ref.current?.scrollTop || 0);
- // 마그네틱 스냅 (큰 그리드 우선, 없으면 서브그리드)
- const gridSize = cellSize + GRID_CONFIG.GAP; // GAP 포함한 실제 그리드 크기
- const magneticThreshold = 15;
+ // 자석 스냅 적용
+ let snappedX = magneticSnap(rawX, verticalGuidelines);
+ let snappedY = magneticSnap(rawY, horizontalGuidelines);
- // X 좌표 스냅
- const nearestGridX = Math.round(rawX / gridSize) * gridSize;
- const distToGridX = Math.abs(rawX - nearestGridX);
- let snappedX = distToGridX <= magneticThreshold ? nearestGridX : Math.round(rawX / subGridSize) * subGridSize;
-
- // Y 좌표 스냅
- const nearestGridY = Math.round(rawY / gridSize) * gridSize;
- const distToGridY = Math.abs(rawY - nearestGridY);
- const snappedY =
- distToGridY <= magneticThreshold ? nearestGridY : Math.round(rawY / subGridSize) * subGridSize;
-
- // X 좌표가 캔버스 너비를 벗어나지 않도록 제한
- const maxX = canvasWidth - cellSize * 2; // 최소 2칸 너비 보장
+ // X 좌표가 캔버스 너비를 벗어나지 않도록 제한 (최소 2칸 너비 보장)
+ const minElementWidth = cellSize * 2 + GRID_CONFIG.GAP;
+ const maxX = canvasWidth - minElementWidth;
snappedX = Math.max(0, Math.min(snappedX, maxX));
onCreateElement(dragData.type, dragData.subtype, snappedX, snappedY);
@@ -201,7 +207,7 @@ export const DashboardCanvas = forwardRef(
// 드롭 데이터 파싱 오류 무시
}
},
- [ref, onCreateElement, canvasWidth, cellSize],
+ [ref, onCreateElement, canvasWidth, cellSize, verticalGuidelines, horizontalGuidelines],
);
// 🔥 선택 박스 드래그 시작
@@ -210,14 +216,14 @@ export const DashboardCanvas = forwardRef(
// 🔥 위젯 내부 클릭이 아닌 경우만 (data-element-id가 없는 경우)
const target = e.target as HTMLElement;
const isWidget = target.closest("[data-element-id]");
-
+
if (isWidget) {
// console.log("🚫 위젯 내부 클릭 - 선택 박스 시작 안함");
return;
}
-
+
// console.log("✅ 빈 공간 클릭 - 선택 박스 시작");
-
+
if (!ref || typeof ref === "function") return;
const rect = ref.current?.getBoundingClientRect();
if (!rect) return;
@@ -274,20 +280,20 @@ export const DashboardCanvas = forwardRef(
// 겹치는 영역의 넓이
const overlapArea = (overlapRight - overlapLeft) * (overlapBottom - overlapTop);
-
+
// 요소의 전체 넓이
const elementArea = el.size.width * el.size.height;
// 70% 이상 겹치면 선택
const overlapPercentage = overlapArea / elementArea;
-
+
// console.log(`📦 요소 ${el.id}:`, {
// position: el.position,
// size: el.size,
// overlapPercentage: (overlapPercentage * 100).toFixed(1) + "%",
// selected: overlapPercentage >= 0.7,
// });
-
+
return overlapPercentage >= 0.7;
})
.map((el) => el.id);
@@ -327,9 +333,9 @@ export const DashboardCanvas = forwardRef(
if (!isSelecting) {
const deltaX = Math.abs(x - selectionBox.startX);
const deltaY = Math.abs(y - selectionBox.startY);
-
+
// console.log("📏 이동 거리:", { deltaX, deltaY });
-
+
// 🔥 5px 이상 움직이면 선택 박스 활성화 (위젯 드래그와 구분)
if (deltaX > 5 || deltaY > 5) {
// console.log("🎯 선택 박스 활성화 (5px 이상 이동)");
@@ -374,10 +380,10 @@ export const DashboardCanvas = forwardRef(
const autoScrollLoop = (currentTime: number) => {
const viewportHeight = window.innerHeight;
const lastMouseY = lastMouseYForSelectionRef.current;
-
+
let shouldScroll = false;
let scrollDirection = 0;
-
+
if (lastMouseY < scrollThreshold) {
shouldScroll = true;
scrollDirection = -scrollSpeed;
@@ -387,9 +393,9 @@ export const DashboardCanvas = forwardRef(
scrollDirection = scrollSpeed;
// console.log("⬇️ 아래로 스크롤 (선택 박스):", { lastMouseY, boundary: viewportHeight - scrollThreshold });
}
-
+
const deltaTime = currentTime - lastTime;
-
+
if (shouldScroll && deltaTime >= 10) {
window.scrollBy(0, scrollDirection);
// console.log("✅ 스크롤 실행 (선택 박스):", { scrollDirection, deltaTime });
@@ -418,7 +424,7 @@ export const DashboardCanvas = forwardRef(
// console.log("🚫 방금 선택했거나 드래그 중이므로 클릭 이벤트 무시");
return;
}
-
+
if (e.target === e.currentTarget) {
// console.log("✅ 빈 공간 클릭 - 선택 해제");
onSelectElement(null);
@@ -433,7 +439,7 @@ export const DashboardCanvas = forwardRef(
// 동적 그리드 크기 계산
const cellWithGap = cellSize + GRID_CONFIG.GAP;
const gridSize = `${cellWithGap}px ${cellWithGap}px`;
-
+
// 서브그리드 크기 계산 (gridConfig에서 정확하게 계산된 값 사용)
const subGridSize = gridConfig.SUB_GRID_SIZE;
@@ -443,7 +449,7 @@ export const DashboardCanvas = forwardRef(
// 🔥 선택 박스 스타일 계산
const selectionBoxStyle = useMemo(() => {
if (!selectionBox) return null;
-
+
const minX = Math.min(selectionBox.startX, selectionBox.endX);
const maxX = Math.max(selectionBox.startX, selectionBox.endX);
const minY = Math.min(selectionBox.startY, selectionBox.endY);
@@ -460,19 +466,11 @@ export const DashboardCanvas = forwardRef(
return (
(
}}
/>
))} */}
+ {/* 그리드 박스들 (12px 간격, 캔버스 너비에 꽉 차게, 마지막 행 제외) */}
+ {verticalGuidelines.map((x, xIdx) =>
+ horizontalGuidelines.slice(0, -1).map((y, yIdx) => (
+
+ )),
+ )}
{/* 배치된 요소들 렌더링 */}
{elements.length === 0 && (
@@ -513,6 +529,8 @@ export const DashboardCanvas = forwardRef
(
cellSize={cellSize}
subGridSize={subGridSize}
canvasWidth={canvasWidth}
+ verticalGuidelines={verticalGuidelines}
+ horizontalGuidelines={horizontalGuidelines}
onUpdate={handleUpdateWithCollisionDetection}
onUpdateMultiple={(updates) => {
// 🔥 여러 요소 동시 업데이트 (충돌 감지 건너뛰기)
@@ -552,10 +570,9 @@ export const DashboardCanvas = forwardRef(
}}
onRemove={onRemoveElement}
onSelect={onSelectElement}
- onConfigure={onConfigureElement}
/>
))}
-
+
{/* 🔥 선택 박스 렌더링 */}
{selectionBox && selectionBoxStyle && (
(initialDashboardId || null);
const [dashboardTitle, setDashboardTitle] = useState
("");
const [isLoading, setIsLoading] = useState(false);
- const [canvasBackgroundColor, setCanvasBackgroundColor] = useState("#f9fafb");
+ const [canvasBackgroundColor, setCanvasBackgroundColor] = useState("transparent");
const canvasRef = useRef(null);
// 저장 모달 상태
@@ -65,7 +72,11 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
// 화면 해상도 자동 감지
const [screenResolution] = useState(() => detectScreenResolution());
- const [resolution, setResolution] = useState(screenResolution);
+ const [resolution, setResolution] = useState(() => {
+ // 새 대시보드인 경우 (dashboardId 없음) 화면 해상도 감지값 사용
+ // 기존 대시보드 편집인 경우 FHD로 시작 (로드 시 덮어씀)
+ return initialDashboardId ? "fhd" : detectScreenResolution();
+ });
// resolution 변경 감지 및 요소 자동 조정
const handleResolutionChange = useCallback(
@@ -89,8 +100,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
// 그리드에 스냅 (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);
+ const snappedWidth = snapSizeToGrid(scaledWidth, newConfig.width);
+ const snappedHeight = snapSizeToGrid(el.size.height, newConfig.width);
return {
...el,
@@ -136,8 +147,12 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
// 대시보드 ID가 props로 전달되면 로드
React.useEffect(() => {
if (initialDashboardId) {
+ console.log("📝 기존 대시보드 편집 모드");
loadDashboard(initialDashboardId);
+ } else {
+ console.log("✨ 새 대시보드 생성 모드 - 감지된 해상도:", resolution);
}
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialDashboardId]);
// 대시보드 데이터 로드
@@ -164,23 +179,19 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
settings,
resolution: settings?.resolution,
backgroundColor: settings?.backgroundColor,
- currentResolution: resolution,
});
- if (settings?.resolution) {
- setResolution(settings.resolution);
- console.log("✅ Resolution 설정됨:", settings.resolution);
- } else {
- console.log("⚠️ Resolution 없음, 기본값 유지:", resolution);
- }
-
+ // 배경색 설정
if (settings?.backgroundColor) {
setCanvasBackgroundColor(settings.backgroundColor);
console.log("✅ BackgroundColor 설정됨:", settings.backgroundColor);
- } else {
- console.log("⚠️ BackgroundColor 없음, 기본값 유지:", canvasBackgroundColor);
}
+ // 해상도와 요소를 함께 설정 (해상도가 먼저 반영되어야 함)
+ const loadedResolution = settings?.resolution || "fhd";
+ setResolution(loadedResolution);
+ console.log("✅ Resolution 설정됨:", loadedResolution);
+
// 요소들 설정
if (dashboard.elements && dashboard.elements.length > 0) {
setElements(dashboard.elements);
@@ -215,22 +226,25 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
return;
}
- // 기본 크기 설정 (서브그리드 기준)
- const gridConfig = calculateGridConfig(canvasConfig.width);
- const subGridSize = gridConfig.SUB_GRID_SIZE;
-
- // 서브그리드 기준 기본 크기 (픽셀)
- let defaultWidth = subGridSize * 10; // 기본 위젯: 서브그리드 10칸
- let defaultHeight = subGridSize * 10; // 기본 위젯: 서브그리드 10칸
+ // 기본 크기 설정 (그리드 박스 단위)
+ const boxSize = calculateBoxSize(canvasConfig.width);
+
+ // 그리드 박스 단위 기본 크기
+ let boxesWidth = 3; // 기본 위젯: 박스 3개
+ let boxesHeight = 3; // 기본 위젯: 박스 3개
if (type === "chart") {
- defaultWidth = subGridSize * 20; // 차트: 서브그리드 20칸
- defaultHeight = subGridSize * 15; // 차트: 서브그리드 15칸
+ boxesWidth = 4; // 차트: 박스 4개
+ boxesHeight = 3; // 차트: 박스 3개
} else if (type === "widget" && subtype === "calendar") {
- defaultWidth = subGridSize * 10; // 달력: 서브그리드 10칸
- defaultHeight = subGridSize * 15; // 달력: 서브그리드 15칸
+ boxesWidth = 3; // 달력: 박스 3개
+ boxesHeight = 4; // 달력: 박스 4개
}
+ // 박스 개수를 픽셀로 변환 (마지막 간격 제거)
+ const defaultWidth = boxesWidth * boxSize + (boxesWidth - 1) * GRID_CONFIG.GRID_BOX_GAP;
+ const defaultHeight = boxesHeight * boxSize + (boxesHeight - 1) * GRID_CONFIG.GRID_BOX_GAP;
+
// 크기 유효성 검사
if (isNaN(defaultWidth) || isNaN(defaultHeight) || defaultWidth <= 0 || defaultHeight <= 0) {
// console.error("Invalid size calculated:", {
@@ -378,12 +392,21 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
// 사이드바 적용
const handleApplySidebar = useCallback(
(updatedElement: DashboardElement) => {
- updateElement(updatedElement.id, updatedElement);
- // 사이드바는 열린 채로 유지하여 연속 수정 가능
- // 단, sidebarElement도 업데이트해서 최신 상태 반영
- setSidebarElement(updatedElement);
+ // 현재 요소의 최신 상태를 가져와서 position과 size는 유지
+ const currentElement = elements.find((el) => el.id === updatedElement.id);
+ if (currentElement) {
+ // position과 size는 현재 상태 유지, 나머지만 업데이트
+ const finalElement = {
+ ...updatedElement,
+ position: currentElement.position,
+ size: currentElement.size,
+ };
+ updateElement(finalElement.id, finalElement);
+ // 사이드바도 최신 상태로 업데이트
+ setSidebarElement(finalElement);
+ }
},
- [updateElement],
+ [elements, updateElement],
);
// 레이아웃 저장
@@ -422,8 +445,15 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
id: el.id,
type: el.type,
subtype: el.subtype,
- position: el.position,
- size: el.size,
+ // 위치와 크기는 정수로 반올림 (DB integer 타입)
+ position: {
+ x: Math.round(el.position.x),
+ y: Math.round(el.position.y),
+ },
+ size: {
+ width: Math.round(el.size.width),
+ height: Math.round(el.size.height),
+ },
title: el.title,
customTitle: el.customTitle,
showHeader: el.showHeader,
@@ -432,6 +462,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
chartConfig: el.chartConfig,
listConfig: el.listConfig,
yardConfig: el.yardConfig,
+ customMetricConfig: el.customMetricConfig,
};
});
@@ -449,6 +480,12 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
},
};
+ console.log("💾 대시보드 업데이트 요청:", {
+ dashboardId,
+ updateData,
+ elementsCount: elementsData.length,
+ });
+
savedDashboard = await dashboardApi.updateDashboard(dashboardId, updateData);
} else {
// 새 대시보드 생성
@@ -509,7 +546,18 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
// 성공 모달 표시
setSuccessModalOpen(true);
} catch (error) {
+ console.error("❌ 대시보드 저장 실패:", error);
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류";
+
+ // 상세한 에러 정보 로깅
+ if (error instanceof Error) {
+ console.error("Error details:", {
+ message: error.message,
+ stack: error.stack,
+ name: error.name,
+ });
+ }
+
alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}`);
throw error;
}
@@ -550,7 +598,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
{/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */}
{
- setExpandedSections((prev) => ({ ...prev, [section]: !prev[section] }));
- };
-
- // 드래그 시작 처리
- const handleDragStart = (e: React.DragEvent, type: ElementType, subtype: ElementSubtype) => {
- const dragData: DragData = { type, subtype };
- e.dataTransfer.setData("application/json", JSON.stringify(dragData));
- e.dataTransfer.effectAllowed = "copy";
- };
-
- return (
-
- {/* 차트 섹션 */}
-
-
-
- {expandedSections.charts && (
-
-
-
-
-
-
-
-
-
-
- )}
-
-
- {/* 위젯 섹션 */}
-
-
-
- {expandedSections.widgets && (
-
-
-
-
-
-
- {/* */}
-
-
-
-
-
- )}
-
-
- {/* 운영/작업 지원 섹션 */}
-
-
-
- {expandedSections.operations && (
-
-
- {/* 예약알림 위젯 - 필요시 주석 해제 */}
- {/* */}
- {/* 정비 일정 관리 위젯 제거 - 커스텀 목록 카드로 대체 가능 */}
-
-
-
-
-
- )}
-
-
- );
-}
-
-interface DraggableItemProps {
- icon?: string;
- title: string;
- type: ElementType;
- subtype: ElementSubtype;
- className?: string;
- onDragStart: (e: React.DragEvent, type: ElementType, subtype: ElementSubtype) => void;
-}
-
-/**
- * 드래그 가능한 아이템 컴포넌트
- */
-function DraggableItem({ title, type, subtype, className = "", onDragStart }: DraggableItemProps) {
- return (
-
onDragStart(e, type, subtype)}
- >
- {title}
-
- );
-}
diff --git a/frontend/components/admin/dashboard/DashboardTopMenu.tsx b/frontend/components/admin/dashboard/DashboardTopMenu.tsx
index f2a11b29..b5357dd2 100644
--- a/frontend/components/admin/dashboard/DashboardTopMenu.tsx
+++ b/frontend/components/admin/dashboard/DashboardTopMenu.tsx
@@ -181,12 +181,11 @@ export function DashboardTopMenu({
데이터 위젯
리스트 위젯
+ 사용자 커스텀 카드
야드 관리 3D
- 커스텀 통계 카드
- {/* 지도 */}
+ {/* 커스텀 통계 카드 */}
커스텀 지도 카드
- {/* 커스텀 목록 카드 */}
- 커스텀 상태 카드
+ {/* 커스텀 상태 카드 */}
일반 위젯
@@ -198,7 +197,7 @@ export function DashboardTopMenu({
할 일
{/* 예약 알림 */}
정비 일정
- 문서
+ {/* 문서 */}
리스크 알림
{/* 범용 위젯으로 대체 가능하여 주석처리 */}
diff --git a/frontend/components/admin/dashboard/ElementConfigSidebar.tsx b/frontend/components/admin/dashboard/ElementConfigSidebar.tsx
index 97332944..e661eead 100644
--- a/frontend/components/admin/dashboard/ElementConfigSidebar.tsx
+++ b/frontend/components/admin/dashboard/ElementConfigSidebar.tsx
@@ -12,6 +12,7 @@ import { YardWidgetConfigSidebar } from "./widgets/YardWidgetConfigSidebar";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import CustomMetricConfigSidebar from "./widgets/custom-metric/CustomMetricConfigSidebar";
interface ElementConfigSidebarProps {
element: DashboardElement | null;
@@ -145,6 +146,20 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
);
}
+ // 사용자 커스텀 카드 위젯은 사이드바로 처리
+ if (element.subtype === "custom-metric") {
+ return (
+
{
+ onApply({ ...element, ...updates });
+ }}
+ />
+ );
+ }
+
// 차트 설정이 필요 없는 위젯 (쿼리/API만 필요)
const isSimpleWidget =
element.subtype === "todo" ||
diff --git a/frontend/components/admin/dashboard/ResolutionSelector.tsx b/frontend/components/admin/dashboard/ResolutionSelector.tsx
index 5f5bda53..33b444f0 100644
--- a/frontend/components/admin/dashboard/ResolutionSelector.tsx
+++ b/frontend/components/admin/dashboard/ResolutionSelector.tsx
@@ -54,14 +54,55 @@ interface ResolutionSelectorProps {
export function detectScreenResolution(): Resolution {
if (typeof window === "undefined") return "fhd";
- const width = window.screen.width;
- const height = window.screen.height;
+ // 1. 브라우저 뷰포트 크기 (실제 사용 가능한 공간)
+ const viewportWidth = window.innerWidth;
+ const viewportHeight = window.innerHeight;
- // 화면 해상도에 따라 적절한 캔버스 해상도 반환
- if (width >= 3840 || height >= 2160) return "uhd";
- if (width >= 2560 || height >= 1440) return "qhd";
- if (width >= 1920 || height >= 1080) return "fhd";
- return "hd";
+ // 2. 화면 해상도 + devicePixelRatio (Retina 디스플레이 대응)
+ const pixelRatio = window.devicePixelRatio || 1;
+ const physicalWidth = window.screen.width;
+ const physicalHeight = window.screen.height;
+ const logicalWidth = physicalWidth / pixelRatio;
+ const logicalHeight = physicalHeight / pixelRatio;
+
+ let detectedResolution: Resolution;
+
+ // 뷰포트와 논리적 해상도 중 더 큰 값을 기준으로 결정
+ // (크램쉘 모드나 특수한 경우에도 대응)
+ const effectiveWidth = Math.max(viewportWidth, logicalWidth);
+ const effectiveHeight = Math.max(viewportHeight, logicalHeight);
+
+ // 캔버스가 여유있게 들어갈 수 있는 크기로 결정
+ // 여유 공간: 좌우 패딩, 사이드바 등을 고려하여 약 400-500px 여유
+ if (effectiveWidth >= 3400) {
+ // UHD 캔버스 2940px + 여유 460px
+ detectedResolution = "uhd";
+ } else if (effectiveWidth >= 2400) {
+ // QHD 캔버스 1960px + 여유 440px
+ detectedResolution = "qhd";
+ } else if (effectiveWidth >= 1900) {
+ // FHD 캔버스 1560px + 여유 340px
+ detectedResolution = "fhd";
+ } else {
+ // HD 캔버스 1160px 이하
+ detectedResolution = "hd";
+ }
+
+ console.log("🖥️ 화면 해상도 자동 감지:", {
+ viewportWidth,
+ viewportHeight,
+ physicalWidth,
+ physicalHeight,
+ pixelRatio,
+ logicalWidth: Math.round(logicalWidth),
+ logicalHeight: Math.round(logicalHeight),
+ effectiveWidth: Math.round(effectiveWidth),
+ effectiveHeight: Math.round(effectiveHeight),
+ detectedResolution,
+ canvasSize: RESOLUTIONS[detectedResolution],
+ });
+
+ return detectedResolution;
}
/**
diff --git a/frontend/components/admin/dashboard/gridUtils.ts b/frontend/components/admin/dashboard/gridUtils.ts
index 3864e861..6094aa0e 100644
--- a/frontend/components/admin/dashboard/gridUtils.ts
+++ b/frontend/components/admin/dashboard/gridUtils.ts
@@ -12,6 +12,13 @@ export const GRID_CONFIG = {
SNAP_THRESHOLD: 10, // 스냅 임계값 (px)
ELEMENT_PADDING: 4, // 요소 주위 여백 (px)
SUB_GRID_DIVISIONS: 5, // 각 그리드 칸을 5x5로 세분화 (세밀한 조정용)
+ // 가이드라인 시스템
+ GUIDELINE_SPACING: 12, // 가이드라인 간격 (px)
+ SNAP_DISTANCE: 10, // 자석 스냅 거리 (px)
+ GUIDELINE_COLOR: "rgba(59, 130, 246, 0.3)", // 가이드라인 색상
+ ROW_HEIGHT: 96, // 각 행의 높이 (12px * 8 = 96px)
+ GRID_BOX_SIZE: 40, // 그리드 박스 크기 (px) - [ ] 한 칸의 크기
+ GRID_BOX_GAP: 12, // 그리드 박스 간 간격 (px)
// CELL_SIZE와 CANVAS_WIDTH는 해상도에 따라 동적 계산
} as const;
@@ -47,9 +54,11 @@ export function calculateGridConfig(canvasWidth: number) {
/**
* 실제 그리드 셀 크기 계산 (gap 포함)
+ * @param canvasWidth - 캔버스 너비
*/
-export const getCellWithGap = () => {
- return GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP;
+export const getCellWithGap = (canvasWidth: number = 1560) => {
+ const boxSize = calculateBoxSize(canvasWidth);
+ return boxSize + GRID_CONFIG.GRID_BOX_GAP;
};
/**
@@ -63,14 +72,14 @@ export const getCanvasWidth = () => {
/**
* 좌표를 서브 그리드에 스냅 (세밀한 조정 가능)
* @param value - 스냅할 좌표값
- * @param subGridSize - 서브 그리드 크기 (선택사항, 기본값: cellSize/3 ≈ 43px)
+ * @param subGridSize - 서브 그리드 크기 (선택사항)
* @returns 스냅된 좌표값
*/
export const snapToGrid = (value: number, subGridSize?: number): number => {
- // 서브 그리드 크기가 지정되지 않으면 기본 그리드 크기의 1/3 사용 (3x3 서브그리드)
- const snapSize = subGridSize ?? Math.floor(GRID_CONFIG.CELL_SIZE / 3);
-
- // 서브 그리드 단위로 스냅
+ // 서브 그리드 크기가 지정되지 않으면 기본 박스 크기 사용
+ const snapSize = subGridSize ?? calculateBoxSize(1560);
+
+ // 그리드 단위로 스냅
const gridIndex = Math.round(value / snapSize);
return gridIndex * snapSize;
};
@@ -81,8 +90,9 @@ export const snapToGrid = (value: number, subGridSize?: number): number => {
* @param cellSize - 셀 크기 (선택사항)
* @returns 스냅된 좌표값 (임계값 내에 있으면 스냅, 아니면 원래 값)
*/
-export const snapToGridWithThreshold = (value: number, cellSize: number = GRID_CONFIG.CELL_SIZE): number => {
- const snapped = snapToGrid(value, cellSize);
+export const snapToGridWithThreshold = (value: number, cellSize?: number): number => {
+ const snapSize = cellSize ?? calculateBoxSize(1560);
+ const snapped = snapToGrid(value, snapSize);
const distance = Math.abs(value - snapped);
return distance <= GRID_CONFIG.SNAP_THRESHOLD ? snapped : value;
@@ -95,15 +105,7 @@ export const snapToGridWithThreshold = (value: number, cellSize: number = GRID_C
* @param cellSize - 셀 크기 (선택사항)
* @returns 스냅된 크기
*/
-export const snapSizeToGrid = (
- size: number,
- minCells: number = 2,
- cellSize: number = GRID_CONFIG.CELL_SIZE,
-): number => {
- const cellWithGap = cellSize + GRID_CONFIG.GAP;
- const cells = Math.max(minCells, Math.round(size / cellWithGap));
- return cells * cellWithGap - GRID_CONFIG.GAP;
-};
+// 기존 snapSizeToGrid 제거 - 새 버전은 269번 줄에 있음
/**
* 위치와 크기를 모두 그리드에 스냅
@@ -135,9 +137,10 @@ export const snapBoundsToGrid = (bounds: GridBounds, canvasWidth?: number, canva
let snappedX = snapToGrid(bounds.position.x);
let snappedY = snapToGrid(bounds.position.y);
- // 크기 스냅
- const snappedWidth = snapSizeToGrid(bounds.size.width);
- const snappedHeight = snapSizeToGrid(bounds.size.height);
+ // 크기 스냅 (canvasWidth 기본값 1560)
+ const width = canvasWidth || 1560;
+ const snappedWidth = snapSizeToGrid(bounds.size.width, width);
+ const snappedHeight = snapSizeToGrid(bounds.size.height, width);
// 캔버스 경계 체크
if (canvasWidth) {
@@ -198,3 +201,75 @@ export const getNearbyGridLines = (value: number): number[] => {
export const isWithinSnapThreshold = (value: number, snapValue: number): boolean => {
return Math.abs(value - snapValue) <= GRID_CONFIG.SNAP_THRESHOLD;
};
+
+// 박스 크기 계산 (캔버스 너비에 맞게)
+export function calculateBoxSize(canvasWidth: number): number {
+ const totalGaps = 11 * GRID_CONFIG.GRID_BOX_GAP; // 12개 박스 사이 간격 11개
+ const availableWidth = canvasWidth - totalGaps;
+ return availableWidth / 12;
+}
+
+// 수직 그리드 박스 좌표 계산 (12개, 너비에 꽉 차게)
+export function calculateVerticalGuidelines(canvasWidth: number): number[] {
+ const lines: number[] = [];
+ const boxSize = calculateBoxSize(canvasWidth);
+
+ for (let i = 0; i < 12; i++) {
+ const x = i * (boxSize + GRID_CONFIG.GRID_BOX_GAP);
+ lines.push(x);
+ }
+ return lines;
+}
+
+// 수평 그리드 박스 좌표 계산 (캔버스 너비 기준으로 정사각형 유지)
+export function calculateHorizontalGuidelines(canvasHeight: number, canvasWidth: number): number[] {
+ const lines: number[] = [];
+ const boxSize = calculateBoxSize(canvasWidth); // 수직과 동일한 박스 크기 사용
+ const cellSize = boxSize + GRID_CONFIG.GRID_BOX_GAP;
+
+ for (let y = 0; y <= canvasHeight; y += cellSize) {
+ lines.push(y);
+ }
+ return lines;
+}
+
+// 가장 가까운 가이드라인 찾기
+export function findNearestGuideline(
+ value: number,
+ guidelines: number[],
+): {
+ nearest: number;
+ distance: number;
+} {
+ let nearest = guidelines[0];
+ let minDistance = Math.abs(value - guidelines[0]);
+
+ for (const guideline of guidelines) {
+ const distance = Math.abs(value - guideline);
+ if (distance < minDistance) {
+ minDistance = distance;
+ nearest = guideline;
+ }
+ }
+
+ return { nearest, distance: minDistance };
+}
+
+// 강제 스냅 (항상 가장 가까운 가이드라인에 스냅)
+export function magneticSnap(value: number, guidelines: number[]): number {
+ const { nearest } = findNearestGuideline(value, guidelines);
+ return nearest; // 거리 체크 없이 무조건 스냅
+}
+
+// 크기를 그리드 박스 단위로 스냅 (박스 크기의 배수로만 가능)
+export function snapSizeToGrid(size: number, canvasWidth: number): number {
+ const boxSize = calculateBoxSize(canvasWidth);
+ const cellSize = boxSize + GRID_CONFIG.GRID_BOX_GAP; // 박스 + 간격
+
+ // 최소 1개 박스 크기
+ const minBoxes = 1;
+ const boxes = Math.max(minBoxes, Math.round(size / cellSize));
+
+ // 박스 개수에서 마지막 간격 제거
+ return boxes * boxSize + (boxes - 1) * GRID_CONFIG.GRID_BOX_GAP;
+}
diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts
index 7ae9b4d8..5c82f805 100644
--- a/frontend/components/admin/dashboard/types.ts
+++ b/frontend/components/admin/dashboard/types.ts
@@ -38,7 +38,8 @@ export type ElementSubtype =
| "list"
| "yard-management-3d" // 야드 관리 3D 위젯
| "work-history" // 작업 이력 위젯
- | "transport-stats"; // 커스텀 통계 카드 위젯
+ | "transport-stats" // 커스텀 통계 카드 위젯
+ | "custom-metric"; // 사용자 커스텀 카드 위젯
export interface Position {
x: number;
@@ -68,6 +69,7 @@ export interface DashboardElement {
driverManagementConfig?: DriverManagementConfig; // 기사 관리 설정
listConfig?: ListWidgetConfig; // 리스트 위젯 설정
yardConfig?: YardManagementConfig; // 야드 관리 3D 설정
+ customMetricConfig?: CustomMetricConfig; // 사용자 커스텀 카드 설정
}
export interface DragData {
@@ -282,3 +284,16 @@ export interface YardManagementConfig {
layoutId: number; // 선택된 야드 레이아웃 ID
layoutName?: string; // 레이아웃 이름 (표시용)
}
+
+// 사용자 커스텀 카드 설정
+export interface CustomMetricConfig {
+ metrics: Array<{
+ id: string; // 고유 ID
+ field: string; // 집계할 컬럼명
+ label: string; // 표시할 라벨
+ aggregation: "count" | "sum" | "avg" | "min" | "max"; // 집계 함수
+ unit: string; // 단위 (%, 건, 일, km, 톤 등)
+ color: "indigo" | "green" | "blue" | "purple" | "orange" | "gray";
+ decimals: number; // 소수점 자릿수
+ }>;
+}
diff --git a/frontend/components/admin/dashboard/widgets/ListWidget.tsx b/frontend/components/admin/dashboard/widgets/ListWidget.tsx
index e3a2a7e7..378d8825 100644
--- a/frontend/components/admin/dashboard/widgets/ListWidget.tsx
+++ b/frontend/components/admin/dashboard/widgets/ListWidget.tsx
@@ -255,7 +255,7 @@ export function ListWidget({ element }: ListWidgetProps) {
) : (
paginatedRows.map((row, idx) => (
-
+
{displayColumns
.filter((col) => col.visible)
.map((col) => (
diff --git a/frontend/components/admin/dashboard/widgets/custom-metric/CustomMetricConfigSidebar.tsx b/frontend/components/admin/dashboard/widgets/custom-metric/CustomMetricConfigSidebar.tsx
new file mode 100644
index 00000000..0a1dd39b
--- /dev/null
+++ b/frontend/components/admin/dashboard/widgets/custom-metric/CustomMetricConfigSidebar.tsx
@@ -0,0 +1,429 @@
+"use client";
+
+import React, { useState } from "react";
+import { DashboardElement, CustomMetricConfig } from "@/components/admin/dashboard/types";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { GripVertical, Plus, Trash2, ChevronDown, ChevronUp, X } from "lucide-react";
+import { DatabaseConfig } from "../../data-sources/DatabaseConfig";
+import { ChartDataSource } from "../../types";
+import { ApiConfig } from "../../data-sources/ApiConfig";
+import { QueryEditor } from "../../QueryEditor";
+import { v4 as uuidv4 } from "uuid";
+import { cn } from "@/lib/utils";
+
+interface CustomMetricConfigSidebarProps {
+ element: DashboardElement;
+ isOpen: boolean;
+ onClose: () => void;
+ onApply: (updates: Partial) => void;
+}
+
+export default function CustomMetricConfigSidebar({
+ element,
+ isOpen,
+ onClose,
+ onApply,
+}: CustomMetricConfigSidebarProps) {
+ const [metrics, setMetrics] = useState(element.customMetricConfig?.metrics || []);
+ const [expandedMetric, setExpandedMetric] = useState(null);
+ const [queryColumns, setQueryColumns] = useState([]);
+ const [dataSourceType, setDataSourceType] = useState<"database" | "api">(element.dataSource?.type || "database");
+ const [dataSource, setDataSource] = useState(
+ element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
+ );
+ const [draggedIndex, setDraggedIndex] = useState(null);
+ const [dragOverIndex, setDragOverIndex] = useState(null);
+ const [customTitle, setCustomTitle] = useState(element.customTitle || element.title || "");
+ const [showHeader, setShowHeader] = useState(element.showHeader !== false);
+
+ // 쿼리 실행 결과 처리
+ const handleQueryTest = (result: any) => {
+ // QueryEditor에서 오는 경우: { success: true, data: { columns: [...], rows: [...] } }
+ if (result.success && result.data?.columns) {
+ setQueryColumns(result.data.columns);
+ }
+ // ApiConfig에서 오는 경우: { columns: [...], data: [...] } 또는 { success: true, columns: [...] }
+ else if (result.columns && Array.isArray(result.columns)) {
+ setQueryColumns(result.columns);
+ }
+ // 오류 처리
+ else {
+ setQueryColumns([]);
+ }
+ };
+
+ // 메트릭 추가
+ const addMetric = () => {
+ const newMetric = {
+ id: uuidv4(),
+ field: "",
+ label: "새 지표",
+ aggregation: "count" as const,
+ unit: "",
+ color: "gray" as const,
+ decimals: 1,
+ };
+ setMetrics([...metrics, newMetric]);
+ setExpandedMetric(newMetric.id);
+ };
+
+ // 메트릭 삭제
+ const deleteMetric = (id: string) => {
+ setMetrics(metrics.filter((m) => m.id !== id));
+ if (expandedMetric === id) {
+ setExpandedMetric(null);
+ }
+ };
+
+ // 메트릭 업데이트
+ const updateMetric = (id: string, field: string, value: any) => {
+ setMetrics(metrics.map((m) => (m.id === id ? { ...m, [field]: value } : m)));
+ };
+
+ // 메트릭 순서 변경
+ // 드래그 앤 드롭 핸들러
+ const handleDragStart = (index: number) => {
+ setDraggedIndex(index);
+ };
+
+ const handleDragOver = (e: React.DragEvent, index: number) => {
+ e.preventDefault();
+ setDragOverIndex(index);
+ };
+
+ const handleDrop = (e: React.DragEvent, dropIndex: number) => {
+ e.preventDefault();
+ if (draggedIndex === null || draggedIndex === dropIndex) {
+ setDraggedIndex(null);
+ setDragOverIndex(null);
+ return;
+ }
+
+ const newMetrics = [...metrics];
+ const [draggedItem] = newMetrics.splice(draggedIndex, 1);
+ newMetrics.splice(dropIndex, 0, draggedItem);
+
+ setMetrics(newMetrics);
+ setDraggedIndex(null);
+ setDragOverIndex(null);
+ };
+
+ const handleDragEnd = () => {
+ setDraggedIndex(null);
+ setDragOverIndex(null);
+ };
+
+ // 데이터 소스 업데이트
+ const handleDataSourceUpdate = (updates: Partial) => {
+ const newDataSource = { ...dataSource, ...updates };
+ setDataSource(newDataSource);
+ onApply({ dataSource: newDataSource });
+ };
+
+ // 데이터 소스 타입 변경
+ const handleDataSourceTypeChange = (type: "database" | "api") => {
+ setDataSourceType(type);
+ const newDataSource: ChartDataSource =
+ type === "database"
+ ? { type: "database", connectionType: "current", refreshInterval: 0 }
+ : { type: "api", method: "GET", refreshInterval: 0 };
+
+ setDataSource(newDataSource);
+ onApply({ dataSource: newDataSource });
+ setQueryColumns([]);
+ };
+
+ // 저장
+ const handleSave = () => {
+ onApply({
+ customTitle: customTitle,
+ showHeader: showHeader,
+ customMetricConfig: {
+ metrics,
+ },
+ });
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+ {/* 헤더 */}
+
+
+ {/* 본문: 스크롤 가능 영역 */}
+
+
+ {/* 헤더 설정 */}
+
+
헤더 설정
+
+ {/* 제목 입력 */}
+
+
+ setCustomTitle(e.target.value)}
+ placeholder="위젯 제목을 입력하세요"
+ className="h-8 text-xs"
+ style={{ fontSize: "12px" }}
+ />
+
+
+ {/* 헤더 표시 여부 */}
+
+
+
+
+
+
+
+ {/* 데이터 소스 타입 선택 */}
+
+
데이터 소스 타입
+
+
+
+
+
+
+ {/* 데이터 소스 설정 */}
+ {dataSourceType === "database" ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+ {/* 지표 설정 섹션 - 쿼리 실행 후에만 표시 */}
+ {queryColumns.length > 0 && (
+
+
+
+
+ {metrics.length === 0 ? (
+
추가된 지표가 없습니다
+ ) : (
+ metrics.map((metric, index) => (
+
handleDragOver(e, index)}
+ onDrop={(e) => handleDrop(e, index)}
+ className={cn(
+ "rounded-md border bg-white p-2 transition-all",
+ draggedIndex === index && "opacity-50",
+ dragOverIndex === index && draggedIndex !== index && "border-primary border-2",
+ )}
+ >
+ {/* 헤더 */}
+
+
handleDragStart(index)}
+ onDragEnd={handleDragEnd}
+ className="cursor-grab active:cursor-grabbing"
+ >
+
+
+
+
+ {metric.label || "새 지표"}
+
+ {metric.aggregation.toUpperCase()}
+
+
+
+
+ {/* 설정 영역 */}
+ {expandedMetric === metric.id && (
+
+ {/* 2열 그리드 레이아웃 */}
+
+ {/* 컬럼 */}
+
+
+
+
+
+ {/* 집계 함수 */}
+
+
+
+
+
+ {/* 단위 */}
+
+
+ updateMetric(metric.id, "unit", e.target.value)}
+ className="h-6 w-full text-[10px]"
+ placeholder="건, %, km"
+ />
+
+
+ {/* 소수점 */}
+
+
+
+
+
+
+ {/* 표시 이름 (전체 너비) */}
+
+
+ updateMetric(metric.id, "label", e.target.value)}
+ className="h-6 w-full text-[10px]"
+ placeholder="라벨"
+ />
+
+
+ {/* 삭제 버튼 */}
+
+
+
+
+ )}
+
+ ))
+ )}
+
+
+ )}
+
+
+
+ {/* 푸터 */}
+
+
+
+
+
+ );
+}
diff --git a/frontend/components/admin/dashboard/widgets/list-widget/UnifiedColumnEditor.tsx b/frontend/components/admin/dashboard/widgets/list-widget/UnifiedColumnEditor.tsx
index 53eb30b9..2ddaafdc 100644
--- a/frontend/components/admin/dashboard/widgets/list-widget/UnifiedColumnEditor.tsx
+++ b/frontend/components/admin/dashboard/widgets/list-widget/UnifiedColumnEditor.tsx
@@ -119,22 +119,13 @@ export function UnifiedColumnEditor({ queryResult, config, onConfigChange }: Uni
return (
{
- handleDragStart(index);
- e.currentTarget.style.cursor = "grabbing";
- }}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={handleDrop}
- onDragEnd={(e) => {
- handleDragEnd();
- e.currentTarget.style.cursor = "grab";
- }}
className={`group relative rounded-md border transition-all ${
col.visible
? "border-primary/40 bg-primary/5 shadow-sm"
: "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm"
- } cursor-grab active:cursor-grabbing ${draggedIndex === index ? "scale-95 opacity-50" : ""}`}
+ } ${draggedIndex === index ? "scale-95 opacity-50" : ""}`}
>
{/* 헤더 */}
@@ -143,7 +134,20 @@ export function UnifiedColumnEditor({ queryResult, config, onConfigChange }: Uni
onCheckedChange={() => handleToggle(col.id)}
className="data-[state=checked]:bg-primary data-[state=checked]:border-primary h-4 w-4 shrink-0 rounded-full"
/>
-
+
{
+ handleDragStart(index);
+ e.currentTarget.style.cursor = "grabbing";
+ }}
+ onDragEnd={(e) => {
+ handleDragEnd();
+ e.currentTarget.style.cursor = "grab";
+ }}
+ className="cursor-grab active:cursor-grabbing"
+ >
+
+
diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx
index 5438b0c0..3b8b790d 100644
--- a/frontend/components/dashboard/DashboardViewer.tsx
+++ b/frontend/components/dashboard/DashboardViewer.tsx
@@ -51,6 +51,10 @@ const CustomStatsWidget = dynamic(() => import("./widgets/CustomStatsWidget"), {
ssr: false,
});
+const CustomMetricWidget = dynamic(() => import("./widgets/CustomMetricWidget"), {
+ ssr: false,
+});
+
/**
* 위젯 렌더링 함수 - DashboardSidebar의 모든 subtype 처리
* ViewerElement에서 사용하기 위해 컴포넌트 외부에 정의
@@ -76,6 +80,8 @@ function renderWidget(element: DashboardElement) {
return ;
case "status-summary":
return ;
+ case "custom-metric":
+ return ;
// === 운영/작업 지원 ===
case "todo":
@@ -328,26 +334,28 @@ export function DashboardViewer({
) : (
// 데스크톱: 기존 고정 캔버스 레이아웃
-
-
- {sortedElements.map((element) => (
-
loadElementData(element)}
- isMobile={false}
- />
- ))}
+
+
+
+ {sortedElements.map((element) => (
+ loadElementData(element)}
+ isMobile={false}
+ />
+ ))}
+
)}
diff --git a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx
new file mode 100644
index 00000000..4dfc289e
--- /dev/null
+++ b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx
@@ -0,0 +1,271 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { DashboardElement } from "@/components/admin/dashboard/types";
+
+interface CustomMetricWidgetProps {
+ element?: DashboardElement;
+}
+
+// 집계 함수 실행
+const calculateMetric = (rows: any[], field: string, aggregation: string): number => {
+ if (rows.length === 0) return 0;
+
+ switch (aggregation) {
+ case "count":
+ return rows.length;
+ case "sum": {
+ return rows.reduce((sum, row) => sum + (parseFloat(row[field]) || 0), 0);
+ }
+ case "avg": {
+ const sum = rows.reduce((s, row) => s + (parseFloat(row[field]) || 0), 0);
+ return rows.length > 0 ? sum / rows.length : 0;
+ }
+ case "min": {
+ return Math.min(...rows.map((row) => parseFloat(row[field]) || 0));
+ }
+ case "max": {
+ return Math.max(...rows.map((row) => parseFloat(row[field]) || 0));
+ }
+ default:
+ return 0;
+ }
+};
+
+// 색상 스타일 매핑
+const colorMap = {
+ indigo: { bg: "bg-indigo-50", text: "text-indigo-600", border: "border-indigo-200" },
+ green: { bg: "bg-green-50", text: "text-green-600", border: "border-green-200" },
+ blue: { bg: "bg-blue-50", text: "text-blue-600", border: "border-blue-200" },
+ purple: { bg: "bg-purple-50", text: "text-purple-600", border: "border-purple-200" },
+ orange: { bg: "bg-orange-50", text: "text-orange-600", border: "border-orange-200" },
+ gray: { bg: "bg-gray-50", text: "text-gray-600", border: "border-gray-200" },
+};
+
+export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) {
+ const [metrics, setMetrics] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ loadData();
+
+ // 자동 새로고침 (30초마다)
+ const interval = setInterval(loadData, 30000);
+ return () => clearInterval(interval);
+ }, [element]);
+
+ const loadData = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ // 데이터 소스 타입 확인
+ const dataSourceType = element?.dataSource?.type;
+
+ // 설정이 없으면 초기 상태로 반환
+ if (!element?.customMetricConfig?.metrics) {
+ setMetrics([]);
+ setLoading(false);
+ return;
+ }
+
+ // Database 타입
+ if (dataSourceType === "database") {
+ if (!element?.dataSource?.query) {
+ setMetrics([]);
+ setLoading(false);
+ return;
+ }
+
+ const token = localStorage.getItem("authToken");
+ const response = await fetch("/api/dashboards/execute-query", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({
+ query: element.dataSource.query,
+ connectionType: element.dataSource.connectionType || "current",
+ connectionId: element.dataSource.connectionId,
+ }),
+ });
+
+ if (!response.ok) throw new Error("데이터 로딩 실패");
+
+ const result = await response.json();
+
+ if (result.success && result.data?.rows) {
+ const rows = result.data.rows;
+
+ const calculatedMetrics = element.customMetricConfig.metrics.map((metric) => {
+ const value = calculateMetric(rows, metric.field, metric.aggregation);
+ return {
+ ...metric,
+ calculatedValue: value,
+ };
+ });
+
+ setMetrics(calculatedMetrics);
+ } else {
+ throw new Error(result.message || "데이터 로드 실패");
+ }
+ }
+ // API 타입
+ else if (dataSourceType === "api") {
+ if (!element?.dataSource?.endpoint) {
+ setMetrics([]);
+ setLoading(false);
+ return;
+ }
+
+ const token = localStorage.getItem("authToken");
+ const response = await fetch("/api/dashboards/fetch-external-api", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({
+ method: element.dataSource.method || "GET",
+ url: element.dataSource.endpoint,
+ headers: element.dataSource.headers || {},
+ body: element.dataSource.body,
+ authType: element.dataSource.authType,
+ authConfig: element.dataSource.authConfig,
+ }),
+ });
+
+ if (!response.ok) throw new Error("API 호출 실패");
+
+ const result = await response.json();
+
+ if (result.success && result.data) {
+ // API 응답 데이터 구조 확인 및 처리
+ let rows: any[] = [];
+
+ // result.data가 배열인 경우
+ if (Array.isArray(result.data)) {
+ rows = result.data;
+ }
+ // result.data.results가 배열인 경우 (일반적인 API 응답 구조)
+ else if (result.data.results && Array.isArray(result.data.results)) {
+ rows = result.data.results;
+ }
+ // result.data.items가 배열인 경우
+ else if (result.data.items && Array.isArray(result.data.items)) {
+ rows = result.data.items;
+ }
+ // result.data.data가 배열인 경우
+ else if (result.data.data && Array.isArray(result.data.data)) {
+ rows = result.data.data;
+ }
+ // 그 외의 경우 단일 객체를 배열로 래핑
+ else {
+ rows = [result.data];
+ }
+
+ const calculatedMetrics = element.customMetricConfig.metrics.map((metric) => {
+ const value = calculateMetric(rows, metric.field, metric.aggregation);
+ return {
+ ...metric,
+ calculatedValue: value,
+ };
+ });
+
+ setMetrics(calculatedMetrics);
+ } else {
+ throw new Error("API 응답 형식 오류");
+ }
+ } else {
+ setMetrics([]);
+ setLoading(false);
+ }
+ } catch (err) {
+ console.error("메트릭 로드 실패:", err);
+ setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
⚠️ {error}
+
+
+
+ );
+ }
+
+ // 데이터 소스가 없거나 설정이 없는 경우
+ const hasDataSource =
+ (element?.dataSource?.type === "database" && element?.dataSource?.query) ||
+ (element?.dataSource?.type === "api" && element?.dataSource?.endpoint);
+
+ if (!hasDataSource || !element?.customMetricConfig?.metrics || metrics.length === 0) {
+ return (
+
+
+
사용자 커스텀 카드
+
+
📊 맞춤형 지표 위젯
+
+ - • SQL 쿼리로 데이터를 불러옵니다
+ - • 선택한 컬럼의 데이터로 지표를 계산합니다
+ - • COUNT, SUM, AVG, MIN, MAX 등 집계 함수 지원
+ - • 사용자 정의 단위 설정 가능
+
+
+
+
⚙️ 설정 방법
+
SQL 쿼리를 입력하고 지표를 추가하세요
+
+
+
+ );
+ }
+
+ return (
+
+ {/* 스크롤 가능한 콘텐츠 영역 */}
+
+
+ {metrics.map((metric) => {
+ const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray;
+ const formattedValue = metric.calculatedValue.toFixed(metric.decimals);
+
+ return (
+
+
{metric.label}
+
+ {formattedValue}
+ {metric.unit}
+
+
+ );
+ })}
+
+
+
+ );
+}