Compare commits

...

24 Commits

Author SHA1 Message Date
kjs 8179946cd8 Merge branch 'main' into feature/screen-management 2025-10-23 15:14:59 +09:00
hyeonsu d668814e03 Merge pull request '사용자 커스텀 카드' (#135) from feat/dashboard into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/135
2025-10-23 14:37:06 +09:00
dohyeons 84ce175d95 rest api 작동 구현 2025-10-23 14:36:14 +09:00
dohyeons 64658d5d5d Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feat/dashboard 2025-10-23 14:28:52 +09:00
dohyeons afe4074d37 제목 및 헤더 설정 기능 추가 2025-10-23 14:27:27 +09:00
dohyeons 6422dac2a4 사용자 커스텀 카드 위젯 구현 2025-10-23 14:24:41 +09:00
dohyeons 60ef6a6a95 문서 및 불필요한 컴포넌트 삭제 2025-10-23 13:30:13 +09:00
hyeonsu 16d0c1eda8 Merge pull request '대시보드 수정사항 적용' (#134) from feat/dashboard into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/134
2025-10-23 13:13:03 +09:00
dohyeons c439596cbf Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feat/dashboard 2025-10-23 13:12:53 +09:00
dohyeons ce7f02409c 컬럼 설정 요소 드래거블 속성 범위 변경 2025-10-23 12:59:45 +09:00
dohyeons a4473eee33 적용 버튼 눌렀을 때 초기 위치, 초기 크기로 되돌아가는 에러 해결 2025-10-23 12:54:46 +09:00
dohyeons 1b6d63bf74 리스트 행 줄무늬 재구현 2025-10-23 12:50:38 +09:00
hyeonsu 745e540d40 Merge pull request '대시보드 캔버스 변경' (#133) from feat/dashboard into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/133
2025-10-23 12:30:48 +09:00
dohyeons cb5ed105ab Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feat/dashboard 2025-10-23 12:23:09 +09:00
kjs d60473f96f Merge pull request 'feature/screen-management' (#132) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/132
2025-10-23 11:31:54 +09:00
dohyeons 900ac4b76e Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feat/dashboard 2025-10-23 11:25:42 +09:00
dohyeons d29d4b596d 화면너비 감지 기능 추가 2025-10-23 10:06:00 +09:00
dohyeons 73e3bf4159 대시보드 화면 감지 기능 추가 2025-10-23 09:56:35 +09:00
dohyeons 298fd11169 대시보드 관리 오류 수정 2025-10-23 09:52:14 +09:00
dohyeons 9bd84f898a 그리드 시스템에 드래그 이동 구현 2025-10-23 09:36:30 +09:00
dohyeons 41c763c019 그리드 박스 기반 스냅 시스템 구현 2025-10-22 16:58:07 +09:00
dohyeons 7c3a2dff4c 스냅 기능 변경 2025-10-22 16:49:57 +09:00
dohyeons 0a28445abe 12그리드 컬럼 디자인 및 캔버스 변경 2025-10-22 16:45:15 +09:00
dohyeons 9dca73f4c4 12컬럼 그리드로 변경 2025-10-22 16:37:14 +09:00
16 changed files with 1133 additions and 451 deletions

View File

@ -63,9 +63,9 @@ export class DashboardService {
id, dashboard_id, element_type, element_subtype, id, dashboard_id, element_type, element_subtype,
position_x, position_y, width, height, position_x, position_y, width, height,
title, custom_title, show_header, content, data_source_config, chart_config, 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 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, elementId,
@ -84,6 +84,7 @@ export class DashboardService {
JSON.stringify(element.chartConfig || {}), JSON.stringify(element.chartConfig || {}),
JSON.stringify(element.listConfig || null), JSON.stringify(element.listConfig || null),
JSON.stringify(element.yardConfig || null), JSON.stringify(element.yardConfig || null),
JSON.stringify(element.customMetricConfig || null),
i, i,
now, now,
now, now,
@ -391,6 +392,11 @@ export class DashboardService {
? JSON.parse(row.yard_config) ? JSON.parse(row.yard_config)
: row.yard_config : row.yard_config
: undefined, : 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, id, dashboard_id, element_type, element_subtype,
position_x, position_y, width, height, position_x, position_y, width, height,
title, custom_title, show_header, content, data_source_config, chart_config, 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 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, elementId,
@ -535,6 +541,7 @@ export class DashboardService {
JSON.stringify(element.chartConfig || {}), JSON.stringify(element.chartConfig || {}),
JSON.stringify(element.listConfig || null), JSON.stringify(element.listConfig || null),
JSON.stringify(element.yardConfig || null), JSON.stringify(element.yardConfig || null),
JSON.stringify(element.customMetricConfig || null),
i, i,
now, now,
now, now,

View File

@ -45,6 +45,17 @@ export interface DashboardElement {
layoutId: number; layoutId: number;
layoutName?: string; 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 { export interface Dashboard {

View File

@ -4,7 +4,7 @@ import React, { useState, useCallback, useRef, useEffect } from "react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { DashboardElement, QueryResult, Position } from "./types"; import { DashboardElement, QueryResult, Position } from "./types";
import { ChartRenderer } from "./charts/ChartRenderer"; 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"), { const WeatherWidget = dynamic(() => import("@/components/dashboard/widgets/WeatherWidget"), {
@ -126,6 +126,12 @@ const CustomStatsWidget = dynamic(() => import("@/components/dashboard/widgets/C
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500"> ...</div>, loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500"> ...</div>,
}); });
// 사용자 커스텀 카드 위젯
const CustomMetricWidget = dynamic(() => import("@/components/dashboard/widgets/CustomMetricWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500"> ...</div>,
});
interface CanvasElementProps { interface CanvasElementProps {
element: DashboardElement; element: DashboardElement;
isSelected: boolean; isSelected: boolean;
@ -135,6 +141,8 @@ interface CanvasElementProps {
cellSize: number; cellSize: number;
subGridSize: number; subGridSize: number;
canvasWidth?: number; canvasWidth?: number;
verticalGuidelines: number[];
horizontalGuidelines: number[];
onUpdate: (id: string, updates: Partial<DashboardElement>) => void; onUpdate: (id: string, updates: Partial<DashboardElement>) => void;
onUpdateMultiple?: (updates: { id: string; updates: Partial<DashboardElement> }[]) => void; // 🔥 다중 업데이트 onUpdateMultiple?: (updates: { id: string; updates: Partial<DashboardElement> }[]) => void; // 🔥 다중 업데이트
onMultiDragStart?: (draggedId: string, otherOffsets: Record<string, { x: number; y: number }>) => void; onMultiDragStart?: (draggedId: string, otherOffsets: Record<string, { x: number; y: number }>) => void;
@ -159,6 +167,8 @@ export function CanvasElement({
cellSize, cellSize,
subGridSize, subGridSize,
canvasWidth = 1560, canvasWidth = 1560,
verticalGuidelines,
horizontalGuidelines,
onUpdate, onUpdate,
onUpdateMultiple, onUpdateMultiple,
onMultiDragStart, onMultiDragStart,
@ -307,7 +317,7 @@ export function CanvasElement({
const deltaX = e.clientX - dragStartRef.current.x; const deltaX = e.clientX - dragStartRef.current.x;
const deltaY = e.clientY - dragStartRef.current.y + scrollDelta; // 🔥 스크롤 변화량 반영 const deltaY = e.clientY - dragStartRef.current.y + scrollDelta; // 🔥 스크롤 변화량 반영
// 임시 위치 계산 // 임시 위치 계산 (드래그 중에는 부드럽게 이동)
let rawX = Math.max(0, dragStart.elementX + deltaX); let rawX = Math.max(0, dragStart.elementX + deltaX);
const rawY = Math.max(0, dragStart.elementY + deltaY); const rawY = Math.max(0, dragStart.elementY + deltaY);
@ -315,15 +325,12 @@ export function CanvasElement({
const maxX = canvasWidth - element.size.width; const maxX = canvasWidth - element.size.width;
rawX = Math.min(rawX, maxX); rawX = Math.min(rawX, maxX);
// 드래그 중 실시간 스냅 (서브그리드만 사용) // 드래그 중에는 스냅 없이 부드럽게 이동
const snappedX = Math.round(rawX / subGridSize) * subGridSize; setTempPosition({ x: rawX, y: rawY });
const snappedY = Math.round(rawY / subGridSize) * subGridSize;
setTempPosition({ x: snappedX, y: snappedY });
// 🔥 다중 드래그 중 - 다른 위젯들의 위치 업데이트 // 🔥 다중 드래그 중 - 다른 위젯들의 위치 업데이트
if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragMove) { if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragMove) {
onMultiDragMove(element, { x: snappedX, y: snappedY }); onMultiDragMove(element, { x: rawX, y: rawY });
} }
} else if (isResizing) { } else if (isResizing) {
const deltaX = e.clientX - resizeStart.x; const deltaX = e.clientX - resizeStart.x;
@ -367,15 +374,13 @@ export function CanvasElement({
const maxWidth = canvasWidth - newX; const maxWidth = canvasWidth - newX;
newWidth = Math.min(newWidth, maxWidth); newWidth = Math.min(newWidth, maxWidth);
// 리사이즈 중 실시간 스냅 (서브그리드만 사용) // 리사이즈 중에는 스냅 없이 부드럽게 조절
const snappedX = Math.round(newX / subGridSize) * subGridSize; const boundedX = Math.max(0, Math.min(newX, canvasWidth - newWidth));
const snappedY = Math.round(newY / subGridSize) * subGridSize; const boundedY = Math.max(0, newY);
const snappedWidth = Math.round(newWidth / subGridSize) * subGridSize;
const snappedHeight = Math.round(newHeight / subGridSize) * subGridSize;
// 임시 크기/위치 저장 (스냅됨) // 임시 크기/위치 저장 (부드러운 이동)
setTempPosition({ x: Math.max(0, snappedX), y: Math.max(0, snappedY) }); setTempPosition({ x: boundedX, y: boundedY });
setTempSize({ width: snappedWidth, height: snappedHeight }); setTempSize({ width: newWidth, height: newHeight });
} }
}, },
[ [
@ -386,7 +391,8 @@ export function CanvasElement({
element, element,
canvasWidth, canvasWidth,
cellSize, cellSize,
subGridSize, verticalGuidelines,
horizontalGuidelines,
selectedElements, selectedElements,
allElements, allElements,
onUpdateMultiple, onUpdateMultiple,
@ -398,10 +404,9 @@ export function CanvasElement({
// 마우스 업 처리 (이미 스냅된 위치 사용) // 마우스 업 처리 (이미 스냅된 위치 사용)
const handleMouseUp = useCallback(() => { const handleMouseUp = useCallback(() => {
if (isDragging && tempPosition) { if (isDragging && tempPosition) {
// tempPosition은 이미 드래그 중에 마그네틱 스냅 적용됨 // 마우스를 놓을 때 그리드에 스냅
// 다시 스냅하지 않고 그대로 사용! let finalX = magneticSnap(tempPosition.x, verticalGuidelines);
let finalX = tempPosition.x; const finalY = magneticSnap(tempPosition.y, horizontalGuidelines);
const finalY = tempPosition.y;
// X 좌표가 캔버스 너비를 벗어나지 않도록 최종 제한 // X 좌표가 캔버스 너비를 벗어나지 않도록 최종 제한
const maxX = canvasWidth - element.size.width; const maxX = canvasWidth - element.size.width;
@ -459,20 +464,19 @@ export function CanvasElement({
} }
if (isResizing && tempPosition && tempSize) { if (isResizing && tempPosition && tempSize) {
// tempPosition과 tempSize는 이미 리사이즈 중에 마그네틱 스냅 적용됨 // 마우스를 놓을 때 그리드에 스냅
// 다시 스냅하지 않고 그대로 사용! const finalX = magneticSnap(tempPosition.x, verticalGuidelines);
const finalX = tempPosition.x; const finalY = magneticSnap(tempPosition.y, horizontalGuidelines);
const finalY = tempPosition.y; const finalWidth = snapSizeToGrid(tempSize.width, canvasWidth || 1560);
let finalWidth = tempSize.width; const finalHeight = snapSizeToGrid(tempSize.height, canvasWidth || 1560);
const finalHeight = tempSize.height;
// 가로 너비가 캔버스를 벗어나지 않도록 최종 제한 // 가로 너비가 캔버스를 벗어나지 않도록 최종 제한
const maxWidth = canvasWidth - finalX; const maxWidth = canvasWidth - finalX;
finalWidth = Math.min(finalWidth, maxWidth); const boundedWidth = Math.min(finalWidth, maxWidth);
onUpdate(element.id, { onUpdate(element.id, {
position: { x: finalX, y: finalY }, position: { x: finalX, y: finalY },
size: { width: finalWidth, height: finalHeight }, size: { width: boundedWidth, height: finalHeight },
}); });
setTempPosition(null); setTempPosition(null);
@ -504,6 +508,8 @@ export function CanvasElement({
allElements, allElements,
dragStart.elementX, dragStart.elementX,
dragStart.elementY, dragStart.elementY,
verticalGuidelines,
horizontalGuidelines,
]); ]);
// 🔥 자동 스크롤 루프 (requestAnimationFrame 사용) // 🔥 자동 스크롤 루프 (requestAnimationFrame 사용)
@ -891,12 +897,7 @@ export function CanvasElement({
) : element.type === "widget" && element.subtype === "list" ? ( ) : element.type === "widget" && element.subtype === "list" ? (
// 리스트 위젯 렌더링 // 리스트 위젯 렌더링
<div className="h-full w-full"> <div className="h-full w-full">
<ListWidget <ListWidget element={element} />
element={element}
onConfigUpdate={(newConfig) => {
onUpdate(element.id, { listConfig: newConfig as any });
}}
/>
</div> </div>
) : element.type === "widget" && element.subtype === "yard-management-3d" ? ( ) : element.type === "widget" && element.subtype === "yard-management-3d" ? (
// 야드 관리 3D 위젯 렌더링 // 야드 관리 3D 위젯 렌더링
@ -920,6 +921,11 @@ export function CanvasElement({
<div className="h-full w-full"> <div className="h-full w-full">
<CustomStatsWidget element={element} /> <CustomStatsWidget element={element} />
</div> </div>
) : element.type === "widget" && element.subtype === "custom-metric" ? (
// 사용자 커스텀 카드 위젯 렌더링
<div className="h-full w-full">
<CustomMetricWidget element={element} />
</div>
) : element.type === "widget" && element.subtype === "todo" ? ( ) : element.type === "widget" && element.subtype === "todo" ? (
// To-Do 위젯 렌더링 // To-Do 위젯 렌더링
<div className="widget-interactive-area h-full w-full"> <div className="widget-interactive-area h-full w-full">

View File

@ -3,7 +3,15 @@
import React, { forwardRef, useState, useCallback, useMemo, useEffect } from "react"; import React, { forwardRef, useState, useCallback, useMemo, useEffect } from "react";
import { DashboardElement, ElementType, ElementSubtype, DragData } from "./types"; import { DashboardElement, ElementType, ElementSubtype, DragData } from "./types";
import { CanvasElement } from "./CanvasElement"; 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"; import { resolveAllCollisions } from "./collisionUtils";
interface DashboardCanvasProps { interface DashboardCanvasProps {
@ -40,7 +48,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
onSelectElement, onSelectElement,
onSelectMultiple, onSelectMultiple,
onConfigureElement, onConfigureElement,
backgroundColor = "#f9fafb", backgroundColor = "transparent",
canvasWidth = 1560, canvasWidth = 1560,
canvasHeight = 768, canvasHeight = 768,
}, },
@ -70,6 +78,14 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
const gridConfig = useMemo(() => calculateGridConfig(canvasWidth), [canvasWidth]); const gridConfig = useMemo(() => calculateGridConfig(canvasWidth), [canvasWidth]);
const cellSize = gridConfig.CELL_SIZE; 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( const handleUpdateWithCollisionDetection = useCallback(
(id: string, updates: Partial<DashboardElement>) => { (id: string, updates: Partial<DashboardElement>) => {
@ -177,23 +193,13 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
const rawX = e.clientX - rect.left + (ref.current?.scrollLeft || 0); const rawX = e.clientX - rect.left + (ref.current?.scrollLeft || 0);
const rawY = e.clientY - rect.top + (ref.current?.scrollTop || 0); const rawY = e.clientY - rect.top + (ref.current?.scrollTop || 0);
// 마그네틱 스냅 (큰 그리드 우선, 없으면 서브그리드) // 자석 스냅 적용
const gridSize = cellSize + GRID_CONFIG.GAP; // GAP 포함한 실제 그리드 크기 let snappedX = magneticSnap(rawX, verticalGuidelines);
const magneticThreshold = 15; let snappedY = magneticSnap(rawY, horizontalGuidelines);
// X 좌표 스냅 // X 좌표가 캔버스 너비를 벗어나지 않도록 제한 (최소 2칸 너비 보장)
const nearestGridX = Math.round(rawX / gridSize) * gridSize; const minElementWidth = cellSize * 2 + GRID_CONFIG.GAP;
const distToGridX = Math.abs(rawX - nearestGridX); const maxX = canvasWidth - minElementWidth;
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칸 너비 보장
snappedX = Math.max(0, Math.min(snappedX, maxX)); snappedX = Math.max(0, Math.min(snappedX, maxX));
onCreateElement(dragData.type, dragData.subtype, snappedX, snappedY); onCreateElement(dragData.type, dragData.subtype, snappedX, snappedY);
@ -201,7 +207,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
// 드롭 데이터 파싱 오류 무시 // 드롭 데이터 파싱 오류 무시
} }
}, },
[ref, onCreateElement, canvasWidth, cellSize], [ref, onCreateElement, canvasWidth, cellSize, verticalGuidelines, horizontalGuidelines],
); );
// 🔥 선택 박스 드래그 시작 // 🔥 선택 박스 드래그 시작
@ -460,19 +466,11 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
return ( return (
<div <div
ref={ref} ref={ref}
className={`relative w-full rounded-lg shadow-inner ${isDragOver ? "bg-blue-50/50" : ""} `} className={`relative w-full ${isDragOver ? "bg-blue-50/50" : ""} `}
style={{ style={{
backgroundColor, backgroundColor,
height: `${canvasHeight}px`, height: `${canvasHeight}px`,
minHeight: `${canvasHeight}px`, minHeight: `${canvasHeight}px`,
// 서브그리드 배경 (세밀한 점선)
backgroundImage: `
linear-gradient(rgba(59, 130, 246, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(59, 130, 246, 0.05) 1px, transparent 1px)
`,
backgroundSize: `${subGridSize}px ${subGridSize}px`,
backgroundPosition: "0 0",
backgroundRepeat: "repeat",
cursor: isSelecting ? "crosshair" : "default", cursor: isSelecting ? "crosshair" : "default",
}} }}
onDragOver={handleDragOver} onDragOver={handleDragOver}
@ -494,6 +492,24 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
}} }}
/> />
))} */} ))} */}
{/* 그리드 박스들 (12px 간격, 캔버스 너비에 꽉 차게, 마지막 행 제외) */}
{verticalGuidelines.map((x, xIdx) =>
horizontalGuidelines.slice(0, -1).map((y, yIdx) => (
<div
key={`grid-box-${xIdx}-${yIdx}`}
className="pointer-events-none absolute"
style={{
left: `${x}px`,
top: `${y}px`,
width: `${boxSize}px`,
height: `${boxSize}px`,
backgroundColor: "#ffffff",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
zIndex: 0,
}}
/>
)),
)}
{/* 배치된 요소들 렌더링 */} {/* 배치된 요소들 렌더링 */}
{elements.length === 0 && ( {elements.length === 0 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-gray-400"> <div className="pointer-events-none absolute inset-0 flex items-center justify-center text-gray-400">
@ -513,6 +529,8 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
cellSize={cellSize} cellSize={cellSize}
subGridSize={subGridSize} subGridSize={subGridSize}
canvasWidth={canvasWidth} canvasWidth={canvasWidth}
verticalGuidelines={verticalGuidelines}
horizontalGuidelines={horizontalGuidelines}
onUpdate={handleUpdateWithCollisionDetection} onUpdate={handleUpdateWithCollisionDetection}
onUpdateMultiple={(updates) => { onUpdateMultiple={(updates) => {
// 🔥 여러 요소 동시 업데이트 (충돌 감지 건너뛰기) // 🔥 여러 요소 동시 업데이트 (충돌 감지 건너뛰기)
@ -552,7 +570,6 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
}} }}
onRemove={onRemoveElement} onRemove={onRemoveElement}
onSelect={onSelectElement} onSelect={onSelectElement}
onConfigure={onConfigureElement}
/> />
))} ))}

View File

@ -7,7 +7,14 @@ import { DashboardTopMenu } from "./DashboardTopMenu";
import { ElementConfigSidebar } from "./ElementConfigSidebar"; import { ElementConfigSidebar } from "./ElementConfigSidebar";
import { DashboardSaveModal } from "./DashboardSaveModal"; import { DashboardSaveModal } from "./DashboardSaveModal";
import { DashboardElement, ElementType, ElementSubtype } from "./types"; import { DashboardElement, ElementType, ElementSubtype } from "./types";
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateGridConfig } from "./gridUtils"; import {
GRID_CONFIG,
snapToGrid,
snapSizeToGrid,
calculateCellSize,
calculateGridConfig,
calculateBoxSize,
} from "./gridUtils";
import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector"; import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector";
import { DashboardProvider } from "@/contexts/DashboardContext"; import { DashboardProvider } from "@/contexts/DashboardContext";
import { useMenu } from "@/contexts/MenuContext"; import { useMenu } from "@/contexts/MenuContext";
@ -47,7 +54,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
const [dashboardId, setDashboardId] = useState<string | null>(initialDashboardId || null); const [dashboardId, setDashboardId] = useState<string | null>(initialDashboardId || null);
const [dashboardTitle, setDashboardTitle] = useState<string>(""); const [dashboardTitle, setDashboardTitle] = useState<string>("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [canvasBackgroundColor, setCanvasBackgroundColor] = useState<string>("#f9fafb"); const [canvasBackgroundColor, setCanvasBackgroundColor] = useState<string>("transparent");
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
// 저장 모달 상태 // 저장 모달 상태
@ -65,7 +72,11 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
// 화면 해상도 자동 감지 // 화면 해상도 자동 감지
const [screenResolution] = useState<Resolution>(() => detectScreenResolution()); const [screenResolution] = useState<Resolution>(() => detectScreenResolution());
const [resolution, setResolution] = useState<Resolution>(screenResolution); const [resolution, setResolution] = useState<Resolution>(() => {
// 새 대시보드인 경우 (dashboardId 없음) 화면 해상도 감지값 사용
// 기존 대시보드 편집인 경우 FHD로 시작 (로드 시 덮어씀)
return initialDashboardId ? "fhd" : detectScreenResolution();
});
// resolution 변경 감지 및 요소 자동 조정 // resolution 변경 감지 및 요소 자동 조정
const handleResolutionChange = useCallback( const handleResolutionChange = useCallback(
@ -89,8 +100,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
// 그리드에 스냅 (X, Y, 너비, 높이 모두) // 그리드에 스냅 (X, Y, 너비, 높이 모두)
const snappedX = snapToGrid(scaledX, newCellSize); const snappedX = snapToGrid(scaledX, newCellSize);
const snappedY = snapToGrid(el.position.y, newCellSize); const snappedY = snapToGrid(el.position.y, newCellSize);
const snappedWidth = snapSizeToGrid(scaledWidth, 2, newCellSize); const snappedWidth = snapSizeToGrid(scaledWidth, newConfig.width);
const snappedHeight = snapSizeToGrid(el.size.height, 2, newCellSize); const snappedHeight = snapSizeToGrid(el.size.height, newConfig.width);
return { return {
...el, ...el,
@ -136,8 +147,12 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
// 대시보드 ID가 props로 전달되면 로드 // 대시보드 ID가 props로 전달되면 로드
React.useEffect(() => { React.useEffect(() => {
if (initialDashboardId) { if (initialDashboardId) {
console.log("📝 기존 대시보드 편집 모드");
loadDashboard(initialDashboardId); loadDashboard(initialDashboardId);
} else {
console.log("✨ 새 대시보드 생성 모드 - 감지된 해상도:", resolution);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialDashboardId]); }, [initialDashboardId]);
// 대시보드 데이터 로드 // 대시보드 데이터 로드
@ -164,23 +179,19 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
settings, settings,
resolution: settings?.resolution, resolution: settings?.resolution,
backgroundColor: settings?.backgroundColor, backgroundColor: settings?.backgroundColor,
currentResolution: resolution,
}); });
if (settings?.resolution) { // 배경색 설정
setResolution(settings.resolution);
console.log("✅ Resolution 설정됨:", settings.resolution);
} else {
console.log("⚠️ Resolution 없음, 기본값 유지:", resolution);
}
if (settings?.backgroundColor) { if (settings?.backgroundColor) {
setCanvasBackgroundColor(settings.backgroundColor); setCanvasBackgroundColor(settings.backgroundColor);
console.log("✅ BackgroundColor 설정됨:", 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) { if (dashboard.elements && dashboard.elements.length > 0) {
setElements(dashboard.elements); setElements(dashboard.elements);
@ -215,22 +226,25 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
return; return;
} }
// 기본 크기 설정 (서브그리드 기준) // 기본 크기 설정 (그리드 박스 단위)
const gridConfig = calculateGridConfig(canvasConfig.width); const boxSize = calculateBoxSize(canvasConfig.width);
const subGridSize = gridConfig.SUB_GRID_SIZE;
// 서브그리드 기준 기본 크기 (픽셀) // 그리드 박스 단위 기본 크기
let defaultWidth = subGridSize * 10; // 기본 위젯: 서브그리드 10칸 let boxesWidth = 3; // 기본 위젯: 박스 3개
let defaultHeight = subGridSize * 10; // 기본 위젯: 서브그리드 10칸 let boxesHeight = 3; // 기본 위젯: 박스 3개
if (type === "chart") { if (type === "chart") {
defaultWidth = subGridSize * 20; // 차트: 서브그리드 20칸 boxesWidth = 4; // 차트: 박스 4개
defaultHeight = subGridSize * 15; // 차트: 서브그리드 15칸 boxesHeight = 3; // 차트: 박스 3개
} else if (type === "widget" && subtype === "calendar") { } else if (type === "widget" && subtype === "calendar") {
defaultWidth = subGridSize * 10; // 달력: 서브그리드 10칸 boxesWidth = 3; // 달력: 박스 3개
defaultHeight = subGridSize * 15; // 달력: 서브그리드 15칸 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) { if (isNaN(defaultWidth) || isNaN(defaultHeight) || defaultWidth <= 0 || defaultHeight <= 0) {
// console.error("Invalid size calculated:", { // console.error("Invalid size calculated:", {
@ -378,12 +392,21 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
// 사이드바 적용 // 사이드바 적용
const handleApplySidebar = useCallback( const handleApplySidebar = useCallback(
(updatedElement: DashboardElement) => { (updatedElement: DashboardElement) => {
updateElement(updatedElement.id, updatedElement); // 현재 요소의 최신 상태를 가져와서 position과 size는 유지
// 사이드바는 열린 채로 유지하여 연속 수정 가능 const currentElement = elements.find((el) => el.id === updatedElement.id);
// 단, sidebarElement도 업데이트해서 최신 상태 반영 if (currentElement) {
setSidebarElement(updatedElement); // 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, id: el.id,
type: el.type, type: el.type,
subtype: el.subtype, subtype: el.subtype,
position: el.position, // 위치와 크기는 정수로 반올림 (DB integer 타입)
size: el.size, 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, title: el.title,
customTitle: el.customTitle, customTitle: el.customTitle,
showHeader: el.showHeader, showHeader: el.showHeader,
@ -432,6 +462,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
chartConfig: el.chartConfig, chartConfig: el.chartConfig,
listConfig: el.listConfig, listConfig: el.listConfig,
yardConfig: el.yardConfig, 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); savedDashboard = await dashboardApi.updateDashboard(dashboardId, updateData);
} else { } else {
// 새 대시보드 생성 // 새 대시보드 생성
@ -509,7 +546,18 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
// 성공 모달 표시 // 성공 모달 표시
setSuccessModalOpen(true); setSuccessModalOpen(true);
} catch (error) { } catch (error) {
console.error("❌ 대시보드 저장 실패:", error);
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류"; 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}`); alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}`);
throw error; throw error;
} }
@ -550,7 +598,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
{/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */} {/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */}
<div className="flex flex-1 items-start justify-center bg-gray-100 p-8"> <div className="flex flex-1 items-start justify-center bg-gray-100 p-8">
<div <div
className="relative shadow-2xl" className="relative"
style={{ style={{
width: `${canvasConfig.width}px`, width: `${canvasConfig.width}px`,
minHeight: `${canvasConfig.height}px`, minHeight: `${canvasConfig.height}px`,

View File

@ -1,264 +0,0 @@
"use client";
import React, { useState } from "react";
import { DragData, ElementType, ElementSubtype } from "./types";
import { ChevronDown, ChevronRight } from "lucide-react";
/**
*
* - /
* -
*/
export function DashboardSidebar() {
const [expandedSections, setExpandedSections] = useState({
charts: true,
widgets: true,
operations: true,
});
// 섹션 토글
const toggleSection = (section: keyof typeof expandedSections) => {
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 (
<div className="w-[370px] overflow-y-auto border-l border-border bg-background p-5">
{/* 차트 섹션 */}
<div className="mb-5">
<button
onClick={() => toggleSection("charts")}
className="mb-3 flex w-full items-center justify-between px-1 py-2.5 text-xl font-bold text-foreground transition-colors hover:text-primary"
>
<span> </span>
{expandedSections.charts ? (
<ChevronDown className="h-5 w-5 text-muted-foreground transition-transform" />
) : (
<ChevronRight className="h-5 w-5 text-muted-foreground transition-transform" />
)}
</button>
{expandedSections.charts && (
<div className="space-y-2">
<DraggableItem
title="바 차트"
type="chart"
subtype="bar"
onDragStart={handleDragStart}
/>
<DraggableItem
title="수평 바 차트"
type="chart"
subtype="horizontal-bar"
onDragStart={handleDragStart}
/>
<DraggableItem
title="누적 바 차트"
type="chart"
subtype="stacked-bar"
onDragStart={handleDragStart}
/>
<DraggableItem
title="꺾은선 차트"
type="chart"
subtype="line"
onDragStart={handleDragStart}
/>
<DraggableItem
title="영역 차트"
type="chart"
subtype="area"
onDragStart={handleDragStart}
/>
<DraggableItem
title="원형 차트"
type="chart"
subtype="pie"
onDragStart={handleDragStart}
/>
<DraggableItem
title="도넛 차트"
type="chart"
subtype="donut"
onDragStart={handleDragStart}
/>
<DraggableItem
title="콤보 차트"
type="chart"
subtype="combo"
onDragStart={handleDragStart}
/>
</div>
)}
</div>
{/* 위젯 섹션 */}
<div className="mb-5">
<button
onClick={() => toggleSection("widgets")}
className="mb-3 flex w-full items-center justify-between px-1 py-2.5 text-xl font-bold text-foreground transition-colors hover:text-primary"
>
<span> </span>
{expandedSections.widgets ? (
<ChevronDown className="h-5 w-5 text-muted-foreground transition-transform" />
) : (
<ChevronRight className="h-5 w-5 text-muted-foreground transition-transform" />
)}
</button>
{expandedSections.widgets && (
<div className="space-y-2">
<DraggableItem
title="환율 위젯"
type="widget"
subtype="exchange"
onDragStart={handleDragStart}
/>
<DraggableItem
title="날씨 위젯"
type="widget"
subtype="weather"
onDragStart={handleDragStart}
/>
<DraggableItem
title="계산기 위젯"
type="widget"
subtype="calculator"
onDragStart={handleDragStart}
/>
<DraggableItem
title="시계 위젯"
type="widget"
subtype="clock"
onDragStart={handleDragStart}
/>
<DraggableItem
title="커스텀 지도 카드"
type="widget"
subtype="map-summary"
onDragStart={handleDragStart}
/>
{/* <DraggableItem
title="커스텀 목록 카드"
type="widget"
subtype="list-summary"
onDragStart={handleDragStart}
/> */}
<DraggableItem
title="리스크/알림 위젯"
type="widget"
subtype="risk-alert"
onDragStart={handleDragStart}
/>
<DraggableItem
title="To-Do / 긴급 지시"
type="widget"
subtype="todo"
onDragStart={handleDragStart}
/>
<DraggableItem
title="달력 위젯"
type="widget"
subtype="calendar"
onDragStart={handleDragStart}
/>
<DraggableItem
title="커스텀 상태 카드"
type="widget"
subtype="status-summary"
onDragStart={handleDragStart}
/>
</div>
)}
</div>
{/* 운영/작업 지원 섹션 */}
<div className="mb-5">
<button
onClick={() => toggleSection("operations")}
className="mb-3 flex w-full items-center justify-between px-1 py-2.5 text-xl font-bold text-foreground transition-colors hover:text-primary"
>
<span>/ </span>
{expandedSections.operations ? (
<ChevronDown className="h-5 w-5 text-muted-foreground transition-transform" />
) : (
<ChevronRight className="h-5 w-5 text-muted-foreground transition-transform" />
)}
</button>
{expandedSections.operations && (
<div className="space-y-2">
<DraggableItem
title="To-Do / 긴급 지시"
type="widget"
subtype="todo"
onDragStart={handleDragStart}
/>
{/* 예약알림 위젯 - 필요시 주석 해제 */}
{/* <DraggableItem
title="예약 요청 알림"
type="widget"
subtype="booking-alert"
onDragStart={handleDragStart}
/> */}
{/* 정비 일정 관리 위젯 제거 - 커스텀 목록 카드로 대체 가능 */}
<DraggableItem
title="문서 다운로드"
type="widget"
subtype="document"
onDragStart={handleDragStart}
/>
<DraggableItem
title="리스트 위젯"
type="widget"
subtype="list"
onDragStart={handleDragStart}
/>
<DraggableItem
title="작업 이력"
type="widget"
subtype="work-history"
onDragStart={handleDragStart}
/>
<DraggableItem
title="커스텀 통계 카드"
type="widget"
subtype="transport-stats"
onDragStart={handleDragStart}
/>
</div>
)}
</div>
</div>
);
}
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 (
<div
draggable
className="cursor-move rounded-md border border-border bg-card px-4 py-2.5 text-sm font-medium text-card-foreground transition-all duration-150 hover:border-primary hover:bg-accent"
onDragStart={(e) => onDragStart(e, type, subtype)}
>
{title}
</div>
);
}

View File

@ -181,12 +181,11 @@ export function DashboardTopMenu({
<SelectGroup> <SelectGroup>
<SelectLabel> </SelectLabel> <SelectLabel> </SelectLabel>
<SelectItem value="list"> </SelectItem> <SelectItem value="list"> </SelectItem>
<SelectItem value="custom-metric"> </SelectItem>
<SelectItem value="yard-management-3d"> 3D</SelectItem> <SelectItem value="yard-management-3d"> 3D</SelectItem>
<SelectItem value="transport-stats"> </SelectItem> {/* <SelectItem value="transport-stats">커스텀 통계 카드</SelectItem> */}
{/* <SelectItem value="map">지도</SelectItem> */}
<SelectItem value="map-summary"> </SelectItem> <SelectItem value="map-summary"> </SelectItem>
{/* <SelectItem value="list-summary">커스텀 목록 카드</SelectItem> */} {/* <SelectItem value="status-summary">커스텀 상태 카드</SelectItem> */}
<SelectItem value="status-summary"> </SelectItem>
</SelectGroup> </SelectGroup>
<SelectGroup> <SelectGroup>
<SelectLabel> </SelectLabel> <SelectLabel> </SelectLabel>
@ -198,7 +197,7 @@ export function DashboardTopMenu({
<SelectItem value="todo"> </SelectItem> <SelectItem value="todo"> </SelectItem>
{/* <SelectItem value="booking-alert">예약 알림</SelectItem> */} {/* <SelectItem value="booking-alert">예약 알림</SelectItem> */}
<SelectItem value="maintenance"> </SelectItem> <SelectItem value="maintenance"> </SelectItem>
<SelectItem value="document"></SelectItem> {/* <SelectItem value="document">문서</SelectItem> */}
<SelectItem value="risk-alert"> </SelectItem> <SelectItem value="risk-alert"> </SelectItem>
</SelectGroup> </SelectGroup>
{/* 범용 위젯으로 대체 가능하여 주석처리 */} {/* 범용 위젯으로 대체 가능하여 주석처리 */}

View File

@ -12,6 +12,7 @@ import { YardWidgetConfigSidebar } from "./widgets/YardWidgetConfigSidebar";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import CustomMetricConfigSidebar from "./widgets/custom-metric/CustomMetricConfigSidebar";
interface ElementConfigSidebarProps { interface ElementConfigSidebarProps {
element: DashboardElement | null; element: DashboardElement | null;
@ -145,6 +146,20 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
); );
} }
// 사용자 커스텀 카드 위젯은 사이드바로 처리
if (element.subtype === "custom-metric") {
return (
<CustomMetricConfigSidebar
element={element}
isOpen={isOpen}
onClose={onClose}
onApply={(updates) => {
onApply({ ...element, ...updates });
}}
/>
);
}
// 차트 설정이 필요 없는 위젯 (쿼리/API만 필요) // 차트 설정이 필요 없는 위젯 (쿼리/API만 필요)
const isSimpleWidget = const isSimpleWidget =
element.subtype === "todo" || element.subtype === "todo" ||

View File

@ -54,14 +54,55 @@ interface ResolutionSelectorProps {
export function detectScreenResolution(): Resolution { export function detectScreenResolution(): Resolution {
if (typeof window === "undefined") return "fhd"; if (typeof window === "undefined") return "fhd";
const width = window.screen.width; // 1. 브라우저 뷰포트 크기 (실제 사용 가능한 공간)
const height = window.screen.height; const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// 화면 해상도에 따라 적절한 캔버스 해상도 반환 // 2. 화면 해상도 + devicePixelRatio (Retina 디스플레이 대응)
if (width >= 3840 || height >= 2160) return "uhd"; const pixelRatio = window.devicePixelRatio || 1;
if (width >= 2560 || height >= 1440) return "qhd"; const physicalWidth = window.screen.width;
if (width >= 1920 || height >= 1080) return "fhd"; const physicalHeight = window.screen.height;
return "hd"; 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;
} }
/** /**

View File

@ -12,6 +12,13 @@ export const GRID_CONFIG = {
SNAP_THRESHOLD: 10, // 스냅 임계값 (px) SNAP_THRESHOLD: 10, // 스냅 임계값 (px)
ELEMENT_PADDING: 4, // 요소 주위 여백 (px) ELEMENT_PADDING: 4, // 요소 주위 여백 (px)
SUB_GRID_DIVISIONS: 5, // 각 그리드 칸을 5x5로 세분화 (세밀한 조정용) 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는 해상도에 따라 동적 계산 // CELL_SIZE와 CANVAS_WIDTH는 해상도에 따라 동적 계산
} as const; } as const;
@ -47,9 +54,11 @@ export function calculateGridConfig(canvasWidth: number) {
/** /**
* (gap ) * (gap )
* @param canvasWidth -
*/ */
export const getCellWithGap = () => { export const getCellWithGap = (canvasWidth: number = 1560) => {
return GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP; const boxSize = calculateBoxSize(canvasWidth);
return boxSize + GRID_CONFIG.GRID_BOX_GAP;
}; };
/** /**
@ -63,14 +72,14 @@ export const getCanvasWidth = () => {
/** /**
* ( ) * ( )
* @param value - * @param value -
* @param subGridSize - (, 기본값: cellSize/3 43px) * @param subGridSize - ()
* @returns * @returns
*/ */
export const snapToGrid = (value: number, subGridSize?: number): number => { 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); const gridIndex = Math.round(value / snapSize);
return gridIndex * snapSize; return gridIndex * snapSize;
}; };
@ -81,8 +90,9 @@ export const snapToGrid = (value: number, subGridSize?: number): number => {
* @param cellSize - () * @param cellSize - ()
* @returns ( , ) * @returns ( , )
*/ */
export const snapToGridWithThreshold = (value: number, cellSize: number = GRID_CONFIG.CELL_SIZE): number => { export const snapToGridWithThreshold = (value: number, cellSize?: number): number => {
const snapped = snapToGrid(value, cellSize); const snapSize = cellSize ?? calculateBoxSize(1560);
const snapped = snapToGrid(value, snapSize);
const distance = Math.abs(value - snapped); const distance = Math.abs(value - snapped);
return distance <= GRID_CONFIG.SNAP_THRESHOLD ? snapped : value; return distance <= GRID_CONFIG.SNAP_THRESHOLD ? snapped : value;
@ -95,15 +105,7 @@ export const snapToGridWithThreshold = (value: number, cellSize: number = GRID_C
* @param cellSize - () * @param cellSize - ()
* @returns * @returns
*/ */
export const snapSizeToGrid = ( // 기존 snapSizeToGrid 제거 - 새 버전은 269번 줄에 있음
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;
};
/** /**
* *
@ -135,9 +137,10 @@ export const snapBoundsToGrid = (bounds: GridBounds, canvasWidth?: number, canva
let snappedX = snapToGrid(bounds.position.x); let snappedX = snapToGrid(bounds.position.x);
let snappedY = snapToGrid(bounds.position.y); let snappedY = snapToGrid(bounds.position.y);
// 크기 스냅 // 크기 스냅 (canvasWidth 기본값 1560)
const snappedWidth = snapSizeToGrid(bounds.size.width); const width = canvasWidth || 1560;
const snappedHeight = snapSizeToGrid(bounds.size.height); const snappedWidth = snapSizeToGrid(bounds.size.width, width);
const snappedHeight = snapSizeToGrid(bounds.size.height, width);
// 캔버스 경계 체크 // 캔버스 경계 체크
if (canvasWidth) { if (canvasWidth) {
@ -198,3 +201,75 @@ export const getNearbyGridLines = (value: number): number[] => {
export const isWithinSnapThreshold = (value: number, snapValue: number): boolean => { export const isWithinSnapThreshold = (value: number, snapValue: number): boolean => {
return Math.abs(value - snapValue) <= GRID_CONFIG.SNAP_THRESHOLD; 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;
}

View File

@ -38,7 +38,8 @@ export type ElementSubtype =
| "list" | "list"
| "yard-management-3d" // 야드 관리 3D 위젯 | "yard-management-3d" // 야드 관리 3D 위젯
| "work-history" // 작업 이력 위젯 | "work-history" // 작업 이력 위젯
| "transport-stats"; // 커스텀 통계 카드 위젯 | "transport-stats" // 커스텀 통계 카드 위젯
| "custom-metric"; // 사용자 커스텀 카드 위젯
export interface Position { export interface Position {
x: number; x: number;
@ -68,6 +69,7 @@ export interface DashboardElement {
driverManagementConfig?: DriverManagementConfig; // 기사 관리 설정 driverManagementConfig?: DriverManagementConfig; // 기사 관리 설정
listConfig?: ListWidgetConfig; // 리스트 위젯 설정 listConfig?: ListWidgetConfig; // 리스트 위젯 설정
yardConfig?: YardManagementConfig; // 야드 관리 3D 설정 yardConfig?: YardManagementConfig; // 야드 관리 3D 설정
customMetricConfig?: CustomMetricConfig; // 사용자 커스텀 카드 설정
} }
export interface DragData { export interface DragData {
@ -282,3 +284,16 @@ export interface YardManagementConfig {
layoutId: number; // 선택된 야드 레이아웃 ID layoutId: number; // 선택된 야드 레이아웃 ID
layoutName?: string; // 레이아웃 이름 (표시용) 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; // 소수점 자릿수
}>;
}

View File

@ -255,7 +255,7 @@ export function ListWidget({ element }: ListWidgetProps) {
</TableRow> </TableRow>
) : ( ) : (
paginatedRows.map((row, idx) => ( paginatedRows.map((row, idx) => (
<TableRow key={idx} className={config.stripedRows ? undefined : ""}> <TableRow key={idx} className={config.stripedRows && idx % 2 === 1 ? "bg-muted/50" : ""}>
{displayColumns {displayColumns
.filter((col) => col.visible) .filter((col) => col.visible)
.map((col) => ( .map((col) => (

View File

@ -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<DashboardElement>) => void;
}
export default function CustomMetricConfigSidebar({
element,
isOpen,
onClose,
onApply,
}: CustomMetricConfigSidebarProps) {
const [metrics, setMetrics] = useState<CustomMetricConfig["metrics"]>(element.customMetricConfig?.metrics || []);
const [expandedMetric, setExpandedMetric] = useState<string | null>(null);
const [queryColumns, setQueryColumns] = useState<string[]>([]);
const [dataSourceType, setDataSourceType] = useState<"database" | "api">(element.dataSource?.type || "database");
const [dataSource, setDataSource] = useState<ChartDataSource>(
element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const [customTitle, setCustomTitle] = useState<string>(element.customTitle || element.title || "");
const [showHeader, setShowHeader] = useState<boolean>(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<ChartDataSource>) => {
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 (
<div
className={cn(
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-gray-50 transition-transform duration-300 ease-in-out",
isOpen ? "translate-x-0" : "translate-x-[-100%]",
)}
>
{/* 헤더 */}
<div className="flex items-center justify-between bg-white px-3 py-2 shadow-sm">
<div className="flex items-center gap-2">
<div className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded">
<span className="text-primary text-xs font-bold">📊</span>
</div>
<span className="text-xs font-semibold text-gray-900"> </span>
</div>
<button
onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-gray-100"
>
<X className="h-3.5 w-3.5 text-gray-500" />
</button>
</div>
{/* 본문: 스크롤 가능 영역 */}
<div className="flex-1 overflow-y-auto p-3">
<div className="space-y-3">
{/* 헤더 설정 */}
<div className="rounded-lg bg-white p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase"> </div>
<div className="space-y-2">
{/* 제목 입력 */}
<div>
<label className="mb-0.5 block text-[9px] font-medium text-gray-500"></label>
<Input
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
placeholder="위젯 제목을 입력하세요"
className="h-8 text-xs"
style={{ fontSize: "12px" }}
/>
</div>
{/* 헤더 표시 여부 */}
<div className="flex items-center justify-between">
<label className="text-[9px] font-medium text-gray-500"> </label>
<button
onClick={() => setShowHeader(!showHeader)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
showHeader ? "bg-primary" : "bg-gray-300"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
showHeader ? "translate-x-5" : "translate-x-0.5"
}`}
/>
</button>
</div>
</div>
</div>
{/* 데이터 소스 타입 선택 */}
<div className="rounded-lg bg-white p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase"> </div>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => handleDataSourceTypeChange("database")}
className={`flex h-16 items-center justify-center rounded border transition-all ${
dataSourceType === "database"
? "border-primary bg-primary/5 text-primary"
: "border-gray-200 bg-gray-50 text-gray-600 hover:border-gray-300"
}`}
>
<span className="text-sm font-medium"></span>
</button>
<button
onClick={() => handleDataSourceTypeChange("api")}
className={`flex h-16 items-center justify-center rounded border transition-all ${
dataSourceType === "api"
? "border-primary bg-primary/5 text-primary"
: "border-gray-200 bg-gray-50 text-gray-600 hover:border-gray-300"
}`}
>
<span className="text-sm font-medium">REST API</span>
</button>
</div>
</div>
{/* 데이터 소스 설정 */}
{dataSourceType === "database" ? (
<>
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
<QueryEditor
dataSource={dataSource}
onDataSourceChange={handleDataSourceUpdate}
onQueryTest={handleQueryTest}
/>
</>
) : (
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
)}
{/* 지표 설정 섹션 - 쿼리 실행 후에만 표시 */}
{queryColumns.length > 0 && (
<div className="rounded-lg bg-white p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<div className="text-[10px] font-semibold tracking-wide text-gray-500 uppercase"></div>
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={addMetric}>
<Plus className="h-3 w-3" />
</Button>
</div>
<div className="space-y-2">
{metrics.length === 0 ? (
<p className="text-xs text-gray-500"> </p>
) : (
metrics.map((metric, index) => (
<div
key={metric.id}
onDragOver={(e) => 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",
)}
>
{/* 헤더 */}
<div className="flex w-full items-center gap-2">
<div
draggable
onDragStart={() => handleDragStart(index)}
onDragEnd={handleDragEnd}
className="cursor-grab active:cursor-grabbing"
>
<GripVertical className="h-4 w-4 shrink-0 text-gray-400" />
</div>
<div className="grid min-w-0 flex-1 grid-cols-[1fr,auto,auto] items-center gap-2">
<span className="truncate text-xs font-medium text-gray-900">
{metric.label || "새 지표"}
</span>
<span className="text-[10px] text-gray-500">{metric.aggregation.toUpperCase()}</span>
<button
onClick={() => setExpandedMetric(expandedMetric === metric.id ? null : metric.id)}
className="flex items-center justify-center rounded p-0.5 hover:bg-gray-100"
>
{expandedMetric === metric.id ? (
<ChevronUp className="h-3.5 w-3.5 text-gray-500" />
) : (
<ChevronDown className="h-3.5 w-3.5 text-gray-500" />
)}
</button>
</div>
</div>
{/* 설정 영역 */}
{expandedMetric === metric.id && (
<div className="mt-2 space-y-1.5 border-t border-gray-200 pt-2">
{/* 2열 그리드 레이아웃 */}
<div className="grid grid-cols-2 gap-1.5">
{/* 컬럼 */}
<div>
<label className="mb-0.5 block text-[9px] font-medium text-gray-500"></label>
<Select
value={metric.field}
onValueChange={(value) => updateMetric(metric.id, "field", value)}
>
<SelectTrigger className="h-6 w-full text-[10px]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{queryColumns.map((col) => (
<SelectItem key={col} value={col}>
{col}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 집계 함수 */}
<div>
<label className="mb-0.5 block text-[9px] font-medium text-gray-500"></label>
<Select
value={metric.aggregation}
onValueChange={(value: any) => updateMetric(metric.id, "aggregation", value)}
>
<SelectTrigger className="h-6 w-full text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="count">COUNT</SelectItem>
<SelectItem value="sum">SUM</SelectItem>
<SelectItem value="avg">AVG</SelectItem>
<SelectItem value="min">MIN</SelectItem>
<SelectItem value="max">MAX</SelectItem>
</SelectContent>
</Select>
</div>
{/* 단위 */}
<div>
<label className="mb-0.5 block text-[9px] font-medium text-gray-500"></label>
<Input
value={metric.unit}
onChange={(e) => updateMetric(metric.id, "unit", e.target.value)}
className="h-6 w-full text-[10px]"
placeholder="건, %, km"
/>
</div>
{/* 소수점 */}
<div>
<label className="mb-0.5 block text-[9px] font-medium text-gray-500"></label>
<Select
value={String(metric.decimals)}
onValueChange={(value) => updateMetric(metric.id, "decimals", parseInt(value))}
>
<SelectTrigger className="h-6 w-full text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[0, 1, 2].map((num) => (
<SelectItem key={num} value={String(num)}>
{num}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 표시 이름 (전체 너비) */}
<div>
<label className="mb-0.5 block text-[9px] font-medium text-gray-500"> </label>
<Input
value={metric.label}
onChange={(e) => updateMetric(metric.id, "label", e.target.value)}
className="h-6 w-full text-[10px]"
placeholder="라벨"
/>
</div>
{/* 삭제 버튼 */}
<div className="border-t border-gray-200 pt-1.5">
<Button
size="sm"
variant="ghost"
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-6 w-full gap-1 text-[10px]"
onClick={() => deleteMetric(metric.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
)}
</div>
))
)}
</div>
</div>
)}
</div>
</div>
{/* 푸터 */}
<div className="flex gap-2 border-t bg-white p-3 shadow-sm">
<Button variant="outline" className="h-8 flex-1 text-xs" onClick={onClose}>
</Button>
<Button className="focus:ring-primary/20 h-8 flex-1 text-xs" onClick={handleSave}>
</Button>
</div>
</div>
);
}

View File

@ -119,22 +119,13 @@ export function UnifiedColumnEditor({ queryResult, config, onConfigChange }: Uni
return ( return (
<div <div
key={col.id} key={col.id}
draggable
onDragStart={(e) => {
handleDragStart(index);
e.currentTarget.style.cursor = "grabbing";
}}
onDragOver={(e) => handleDragOver(e, index)} onDragOver={(e) => handleDragOver(e, index)}
onDrop={handleDrop} onDrop={handleDrop}
onDragEnd={(e) => {
handleDragEnd();
e.currentTarget.style.cursor = "grab";
}}
className={`group relative rounded-md border transition-all ${ className={`group relative rounded-md border transition-all ${
col.visible col.visible
? "border-primary/40 bg-primary/5 shadow-sm" ? "border-primary/40 bg-primary/5 shadow-sm"
: "border-gray-200 bg-white hover:border-gray-300 hover: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" : ""}`}
> >
{/* 헤더 */} {/* 헤더 */}
<div className="flex items-center gap-2 px-2.5 py-2"> <div className="flex items-center gap-2 px-2.5 py-2">
@ -143,7 +134,20 @@ export function UnifiedColumnEditor({ queryResult, config, onConfigChange }: Uni
onCheckedChange={() => handleToggle(col.id)} onCheckedChange={() => handleToggle(col.id)}
className="data-[state=checked]:bg-primary data-[state=checked]:border-primary h-4 w-4 shrink-0 rounded-full" className="data-[state=checked]:bg-primary data-[state=checked]:border-primary h-4 w-4 shrink-0 rounded-full"
/> />
<GripVertical className="group-hover:text-primary h-3.5 w-3.5 shrink-0 text-gray-400 transition-colors" /> <div
draggable
onDragStart={(e) => {
handleDragStart(index);
e.currentTarget.style.cursor = "grabbing";
}}
onDragEnd={(e) => {
handleDragEnd();
e.currentTarget.style.cursor = "grab";
}}
className="cursor-grab active:cursor-grabbing"
>
<GripVertical className="group-hover:text-primary h-3.5 w-3.5 shrink-0 text-gray-400 transition-colors" />
</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="truncate text-[11px] font-medium text-gray-900"> <span className="truncate text-[11px] font-medium text-gray-900">

View File

@ -51,6 +51,10 @@ const CustomStatsWidget = dynamic(() => import("./widgets/CustomStatsWidget"), {
ssr: false, ssr: false,
}); });
const CustomMetricWidget = dynamic(() => import("./widgets/CustomMetricWidget"), {
ssr: false,
});
/** /**
* - DashboardSidebar의 subtype * - DashboardSidebar의 subtype
* ViewerElement에서 * ViewerElement에서
@ -76,6 +80,8 @@ function renderWidget(element: DashboardElement) {
return <CalendarWidget element={element} />; return <CalendarWidget element={element} />;
case "status-summary": case "status-summary":
return <StatusSummaryWidget element={element} />; return <StatusSummaryWidget element={element} />;
case "custom-metric":
return <CustomMetricWidget element={element} />;
// === 운영/작업 지원 === // === 운영/작업 지원 ===
case "todo": case "todo":
@ -328,26 +334,28 @@ export function DashboardViewer({
</div> </div>
) : ( ) : (
// 데스크톱: 기존 고정 캔버스 레이아웃 // 데스크톱: 기존 고정 캔버스 레이아웃
<div className="flex min-h-screen items-start justify-center bg-gray-100 p-8"> <div className="min-h-screen bg-gray-100 py-8">
<div <div className="mx-auto" style={{ width: `${canvasConfig.width}px` }}>
className="relative rounded-lg" <div
style={{ className="relative rounded-lg"
width: `${canvasConfig.width}px`, style={{
minHeight: `${canvasConfig.height}px`, width: `${canvasConfig.width}px`,
height: `${canvasHeight}px`, minHeight: `${canvasConfig.height}px`,
backgroundColor: backgroundColor, height: `${canvasHeight}px`,
}} backgroundColor: backgroundColor,
> }}
{sortedElements.map((element) => ( >
<ViewerElement {sortedElements.map((element) => (
key={element.id} <ViewerElement
element={element} key={element.id}
data={elementData[element.id]} element={element}
isLoading={loadingElements.has(element.id)} data={elementData[element.id]}
onRefresh={() => loadElementData(element)} isLoading={loadingElements.has(element.id)}
isMobile={false} onRefresh={() => loadElementData(element)}
/> isMobile={false}
))} />
))}
</div>
</div> </div>
</div> </div>
)} )}

View File

@ -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<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="flex h-full items-center justify-center bg-white">
<div className="text-center">
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
<p className="mt-2 text-sm text-gray-500"> ...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex h-full items-center justify-center bg-white p-4">
<div className="text-center">
<p className="text-sm text-red-600"> {error}</p>
<button
onClick={loadData}
className="mt-2 rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
>
</button>
</div>
</div>
);
}
// 데이터 소스가 없거나 설정이 없는 경우
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 (
<div className="flex h-full items-center justify-center bg-white p-4">
<div className="max-w-xs space-y-2 text-center">
<h3 className="text-sm font-bold text-gray-900"> </h3>
<div className="space-y-1.5 text-xs text-gray-600">
<p className="font-medium">📊 </p>
<ul className="space-y-0.5 text-left">
<li> SQL </li>
<li> </li>
<li> COUNT, SUM, AVG, MIN, MAX </li>
<li> </li>
</ul>
</div>
<div className="mt-2 rounded-lg bg-blue-50 p-2 text-[10px] text-blue-700">
<p className="font-medium"> </p>
<p>SQL </p>
</div>
</div>
</div>
);
}
return (
<div className="flex h-full w-full flex-col overflow-hidden bg-white p-4">
{/* 스크롤 가능한 콘텐츠 영역 */}
<div className="flex-1 overflow-y-auto">
<div className="grid w-full gap-4" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))" }}>
{metrics.map((metric) => {
const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray;
const formattedValue = metric.calculatedValue.toFixed(metric.decimals);
return (
<div key={metric.id} className={`rounded-lg border ${colors.bg} ${colors.border} p-4 text-center`}>
<div className="text-sm text-gray-600">{metric.label}</div>
<div className={`mt-2 text-3xl font-bold ${colors.text}`}>
{formattedValue}
<span className="ml-1 text-lg">{metric.unit}</span>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}