diff --git a/frontend/components/admin/dashboard/widgets/ListWidget.tsx b/frontend/components/admin/dashboard/widgets/ListWidget.tsx
index 378d8825..252831c5 100644
--- a/frontend/components/admin/dashboard/widgets/ListWidget.tsx
+++ b/frontend/components/admin/dashboard/widgets/ListWidget.tsx
@@ -217,11 +217,6 @@ export function ListWidget({ element }: ListWidgetProps) {
return (
- {/* 제목 - 항상 표시 */}
-
-
{element.customTitle || element.title}
-
-
{/* 테이블 뷰 */}
{config.viewMode === "table" && (
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx
index 29c15ca9..bcbded34 100644
--- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx
+++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx
@@ -29,6 +29,19 @@ interface Yard3DCanvasProps {
selectedPlacementId: number | null;
onPlacementClick: (placement: YardPlacement | null) => void;
onPlacementDrag?: (id: number, position: { x: number; y: number; z: number }) => void;
+ gridSize?: number; // 그리드 크기 (기본값: 5)
+ onCollisionDetected?: () => void; // 충돌 감지 시 콜백
+}
+
+// 좌표를 그리드 칸의 중심에 스냅 (마인크래프트 스타일)
+// Three.js Box의 position은 중심점이므로, 그리드 칸의 중심에 배치해야 칸에 딱 맞음
+function snapToGrid(value: number, gridSize: number): number {
+ // 가장 가까운 그리드 칸 찾기
+ const gridIndex = Math.round(value / gridSize);
+ // 그리드 칸의 중심점 반환
+ // gridSize=5일 때: ..., -7.5, -2.5, 2.5, 7.5, 12.5, 17.5...
+ // 이렇게 하면 Box가 칸 안에 정확히 들어감
+ return gridIndex * gridSize + gridSize / 2;
}
// 자재 박스 컴포넌트 (드래그 가능)
@@ -39,6 +52,9 @@ function MaterialBox({
onDrag,
onDragStart,
onDragEnd,
+ gridSize = 5,
+ allPlacements = [],
+ onCollisionDetected,
}: {
placement: YardPlacement;
isSelected: boolean;
@@ -46,17 +62,81 @@ function MaterialBox({
onDrag?: (position: { x: number; y: number; z: number }) => void;
onDragStart?: () => void;
onDragEnd?: () => void;
+ gridSize?: number;
+ allPlacements?: YardPlacement[];
+ onCollisionDetected?: () => void;
}) {
const meshRef = useRef
(null);
const [isDragging, setIsDragging] = useState(false);
+ const [isValidPosition, setIsValidPosition] = useState(true); // 배치 가능 여부 (시각 피드백용)
const dragStartPos = useRef<{ x: number; y: number; z: number }>({ x: 0, y: 0, z: 0 });
const mouseStartPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const { camera, gl } = useThree();
- // 드래그 중이 아닐 때 위치 업데이트
+ // 특정 좌표에 요소를 배치할 수 있는지 확인하고, 필요하면 Y 위치를 조정
+ const checkCollisionAndAdjustY = (x: number, y: number, z: number): { hasCollision: boolean; adjustedY: number } => {
+ const mySize = placement.size_x || gridSize; // 내 크기 (5)
+ const myHalfSize = mySize / 2; // 2.5
+ const mySizeY = placement.size_y || gridSize; // 내 높이 (5)
+
+ let maxYBelow = gridSize / 2; // 기본 바닥 높이 (2.5)
+
+ for (const p of allPlacements) {
+ // 자기 자신은 제외
+ if (Number(p.id) === Number(placement.id)) {
+ continue;
+ }
+
+ const pSize = p.size_x || gridSize; // 상대방 크기 (5)
+ const pHalfSize = pSize / 2; // 2.5
+ const pSizeY = p.size_y || gridSize; // 상대방 높이 (5)
+
+ // XZ 평면에서 겹치는지 확인
+ const isXZOverlapping =
+ Math.abs(x - p.position_x) < myHalfSize + pHalfSize && // X축 겹침
+ Math.abs(z - p.position_z) < myHalfSize + pHalfSize; // Z축 겹침
+
+ if (isXZOverlapping) {
+ // 같은 XZ 위치에 요소가 있음
+ // 그 요소의 윗면 높이 계산 (중심 + 높이/2)
+ const topOfOtherElement = p.position_y + pSizeY / 2;
+ // 내가 올라갈 Y 위치는 윗면 + 내 높이/2
+ const myYOnTop = topOfOtherElement + mySizeY / 2;
+
+ // 가장 높은 위치 기록
+ if (myYOnTop > maxYBelow) {
+ maxYBelow = myYOnTop;
+ }
+ }
+ }
+
+ // 요청한 Y와 조정된 Y가 다르면 충돌로 간주 (위로 올려야 함)
+ const needsAdjustment = Math.abs(y - maxYBelow) > 0.1;
+
+ return {
+ hasCollision: needsAdjustment,
+ adjustedY: maxYBelow,
+ };
+ };
+
+ // 드래그 중이 아닐 때만 위치 동기화
useEffect(() => {
if (!isDragging && meshRef.current) {
- meshRef.current.position.set(placement.position_x, placement.position_y, placement.position_z);
+ const currentPos = meshRef.current.position;
+ const targetX = placement.position_x;
+ const targetY = placement.position_y;
+ const targetZ = placement.position_z;
+
+ // 현재 위치와 목표 위치가 다를 때만 업데이트 (0.01 이상 차이)
+ const threshold = 0.01;
+ const needsUpdate =
+ Math.abs(currentPos.x - targetX) > threshold ||
+ Math.abs(currentPos.y - targetY) > threshold ||
+ Math.abs(currentPos.z - targetZ) > threshold;
+
+ if (needsUpdate) {
+ meshRef.current.position.set(targetX, targetY, targetZ);
+ }
}
}, [placement.position_x, placement.position_y, placement.position_z, isDragging]);
@@ -98,20 +178,59 @@ function MaterialBox({
return;
}
- // 즉시 mesh 위치 업데이트 (부드러운 드래그)
- meshRef.current.position.set(finalX, dragStartPos.current.y, finalZ);
+ // 그리드에 스냅
+ const snappedX = snapToGrid(finalX, gridSize);
+ const snappedZ = snapToGrid(finalZ, gridSize);
- // 상태 업데이트 (저장용)
- onDrag({
- x: finalX,
- y: dragStartPos.current.y,
- z: finalZ,
- });
+ // 충돌 체크 및 Y 위치 조정 (시각 피드백용)
+ const { adjustedY } = checkCollisionAndAdjustY(snappedX, dragStartPos.current.y, snappedZ);
+
+ // 시각 피드백: 항상 유효한 위치 (위로 올라가기 때문)
+ setIsValidPosition(true);
+
+ // 즉시 mesh 위치 업데이트 (조정된 Y 위치로)
+ meshRef.current.position.set(finalX, adjustedY, finalZ);
+
+ // ⚠️ 드래그 중에는 상태 업데이트 안 함 (미리보기만)
+ // 실제 저장은 handleGlobalMouseUp에서만 수행
}
};
const handleGlobalMouseUp = () => {
- if (isDragging) {
+ if (isDragging && meshRef.current) {
+ const currentPos = meshRef.current.position;
+
+ // 실제로 이동했는지 확인 (최소 이동 거리: 0.1)
+ const minMovement = 0.1;
+ const deltaX = Math.abs(currentPos.x - dragStartPos.current.x);
+ const deltaZ = Math.abs(currentPos.z - dragStartPos.current.z);
+ const hasMoved = deltaX > minMovement || deltaZ > minMovement;
+
+ if (hasMoved) {
+ // 실제로 드래그한 경우: 그리드에 스냅
+ const snappedX = snapToGrid(currentPos.x, gridSize);
+ const snappedZ = snapToGrid(currentPos.z, gridSize);
+
+ // Y 위치 조정 (마인크래프트처럼 쌓기)
+ const { adjustedY } = checkCollisionAndAdjustY(snappedX, currentPos.y, snappedZ);
+
+ // ✅ 항상 배치 가능 (위로 올라가므로)
+ console.log("✅ 배치 완료! 저장:", { x: snappedX, y: adjustedY, z: snappedZ });
+ meshRef.current.position.set(snappedX, adjustedY, snappedZ);
+
+ // 최종 위치 저장 (조정된 Y 위치로)
+ if (onDrag) {
+ onDrag({
+ x: snappedX,
+ y: adjustedY,
+ z: snappedZ,
+ });
+ }
+ } else {
+ // 클릭만 한 경우: 원래 위치 유지 (아무것도 안 함)
+ meshRef.current.position.set(dragStartPos.current.x, dragStartPos.current.y, dragStartPos.current.z);
+ }
+
setIsDragging(false);
gl.domElement.style.cursor = isSelected ? "grab" : "pointer";
if (onDragEnd) {
@@ -141,11 +260,12 @@ function MaterialBox({
// 편집 모드에서 선택되었고 드래그 가능한 경우
if (isSelected && meshRef.current) {
- // 드래그 시작 시점의 자재 위치 저장 (숫자로 변환)
+ // 드래그 시작 시점의 mesh 실제 위치 저장 (현재 렌더링된 위치)
+ const currentPos = meshRef.current.position;
dragStartPos.current = {
- x: Number(placement.position_x),
- y: Number(placement.position_y),
- z: Number(placement.position_z),
+ x: currentPos.x,
+ y: currentPos.y,
+ z: currentPos.z,
};
// 마우스 시작 위치 저장
@@ -192,11 +312,11 @@ function MaterialBox({
}}
>
@@ -204,7 +324,14 @@ function MaterialBox({
}
// 3D 씬 컴포넌트
-function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementDrag }: Yard3DCanvasProps) {
+function Scene({
+ placements,
+ selectedPlacementId,
+ onPlacementClick,
+ onPlacementDrag,
+ gridSize = 5,
+ onCollisionDetected,
+}: Yard3DCanvasProps) {
const [isDraggingAny, setIsDraggingAny] = useState(false);
const orbitControlsRef = useRef(null);
@@ -215,15 +342,15 @@ function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementD
- {/* 바닥 그리드 */}
+ {/* 바닥 그리드 (타일을 4등분) */}
))}
@@ -259,10 +389,14 @@ function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementD
enablePan={true}
enableZoom={true}
enableRotate={true}
- minDistance={10}
+ minDistance={8}
maxDistance={200}
maxPolarAngle={Math.PI / 2}
enabled={!isDraggingAny}
+ reverseOrbit={true} // 드래그 방향 반전 (자연스러운 이동)
+ screenSpacePanning={true} // 화면 공간 패닝
+ panSpeed={0.8} // 패닝 속도 (기본값 1.0, 낮을수록 느림)
+ rotateSpeed={0.5} // 회전 속도
/>
>
);
@@ -273,6 +407,8 @@ export default function Yard3DCanvas({
selectedPlacementId,
onPlacementClick,
onPlacementDrag,
+ gridSize = 5,
+ onCollisionDetected,
}: Yard3DCanvasProps) {
const handleCanvasClick = (e: any) => {
// Canvas의 빈 공간을 클릭했을 때만 선택 해제
@@ -297,6 +433,8 @@ export default function Yard3DCanvas({
selectedPlacementId={selectedPlacementId}
onPlacementClick={onPlacementClick}
onPlacementDrag={onPlacementDrag}
+ gridSize={gridSize}
+ onCollisionDetected={onCollisionDetected}
/>
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx
index 41c68af5..87319916 100644
--- a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx
+++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx
@@ -7,10 +7,11 @@ import { yardLayoutApi } from "@/lib/api/yardLayoutApi";
import dynamic from "next/dynamic";
import { YardLayout, YardPlacement } from "./types";
import { Alert, AlertDescription } from "@/components/ui/alert";
-import { AlertCircle, CheckCircle } from "lucide-react";
+import { AlertCircle, CheckCircle, XCircle } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
+import { useToast } from "@/hooks/use-toast";
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
ssr: false,
@@ -33,6 +34,7 @@ interface YardEditorProps {
}
export default function YardEditor({ layout, onBack }: YardEditorProps) {
+ const { toast } = useToast();
const [placements, setPlacements] = useState([]);
const [originalPlacements, setOriginalPlacements] = useState([]); // 원본 데이터 보관
const [selectedPlacement, setSelectedPlacement] = useState(null);
@@ -78,8 +80,89 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
loadPlacements();
}, [layout.id]);
+ // 빈 공간 찾기 (그리드 기반)
+ const findEmptyGridPosition = (gridSize = 5) => {
+ // 이미 사용 중인 좌표 Set
+ const occupiedPositions = new Set(
+ placements.map((p) => {
+ const x = Math.round(p.position_x / gridSize) * gridSize;
+ const z = Math.round(p.position_z / gridSize) * gridSize;
+ return `${x},${z}`;
+ }),
+ );
+
+ // 나선형으로 빈 공간 찾기
+ let x = 0;
+ let z = 0;
+ let direction = 0; // 0: 우, 1: 하, 2: 좌, 3: 상
+ let steps = 1;
+ let stepsTaken = 0;
+ let stepsInDirection = 0;
+
+ for (let i = 0; i < 1000; i++) {
+ const key = `${x},${z}`;
+ if (!occupiedPositions.has(key)) {
+ return { x, z };
+ }
+
+ // 다음 위치로 이동
+ stepsInDirection++;
+ if (direction === 0)
+ x += gridSize; // 우
+ else if (direction === 1)
+ z += gridSize; // 하
+ else if (direction === 2)
+ x -= gridSize; // 좌
+ else z -= gridSize; // 상
+
+ if (stepsInDirection >= steps) {
+ stepsInDirection = 0;
+ direction = (direction + 1) % 4;
+ stepsTaken++;
+ if (stepsTaken === 2) {
+ stepsTaken = 0;
+ steps++;
+ }
+ }
+ }
+
+ return { x: 0, z: 0 };
+ };
+
+ // 특정 XZ 위치에 배치할 때 적절한 Y 위치 계산 (마인크래프트 쌓기)
+ const calculateYPosition = (x: number, z: number, existingPlacements: YardPlacement[]) => {
+ const gridSize = 5;
+ const halfSize = gridSize / 2;
+ let maxY = halfSize; // 기본 바닥 높이 (2.5)
+
+ for (const p of existingPlacements) {
+ // XZ가 겹치는지 확인
+ const isXZOverlapping = Math.abs(x - p.position_x) < gridSize && Math.abs(z - p.position_z) < gridSize;
+
+ if (isXZOverlapping) {
+ // 이 요소의 윗면 높이
+ const topY = p.position_y + (p.size_y || gridSize) / 2;
+ // 새 요소의 Y 위치 (윗면 + 새 요소 높이/2)
+ const newY = topY + gridSize / 2;
+ if (newY > maxY) {
+ maxY = newY;
+ }
+ }
+ }
+
+ return maxY;
+ };
+
// 빈 요소 추가 (로컬 상태에만 추가, 저장 시 서버에 반영)
const handleAddElement = () => {
+ const gridSize = 5;
+ const emptyPos = findEmptyGridPosition(gridSize);
+ const centerX = emptyPos.x + gridSize / 2;
+ const centerZ = emptyPos.z + gridSize / 2;
+
+ // 해당 위치에 적절한 Y 계산 (쌓기)
+ const appropriateY = calculateYPosition(centerX, centerZ, placements);
+
const newPlacement: YardPlacement = {
id: nextPlacementId, // 임시 음수 ID
yard_layout_id: layout.id,
@@ -87,12 +170,13 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
material_name: null,
quantity: null,
unit: null,
- position_x: 0,
- position_y: 2.5,
- position_z: 0,
- size_x: 5,
- size_y: 5,
- size_z: 5,
+ // 그리드 칸의 중심에 배치 (Three.js Box position은 중심점)
+ position_x: centerX, // 칸 중심: 0→2.5, 5→7.5, 10→12.5...
+ position_y: appropriateY, // 쌓기 고려한 Y 위치
+ position_z: centerZ, // 칸 중심: 0→2.5, 5→7.5, 10→12.5...
+ size_x: gridSize,
+ size_y: gridSize,
+ size_z: gridSize,
color: "#9ca3af",
data_source_type: null,
data_source_config: null,
@@ -125,12 +209,62 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
setDeleteConfirmDialog({ open: true, placementId });
};
+ // 중력 적용: 삭제된 요소 위에 있던 요소들을 아래로 내림
+ const applyGravity = (deletedPlacement: YardPlacement, remainingPlacements: YardPlacement[]) => {
+ const gridSize = 5;
+ const halfSize = gridSize / 2;
+
+ return remainingPlacements.map((p) => {
+ // 삭제된 요소와 XZ가 겹치는지 확인
+ const isXZOverlapping =
+ Math.abs(p.position_x - deletedPlacement.position_x) < gridSize &&
+ Math.abs(p.position_z - deletedPlacement.position_z) < gridSize;
+
+ // 삭제된 요소보다 위에 있는지 확인
+ const isAbove = p.position_y > deletedPlacement.position_y;
+
+ if (isXZOverlapping && isAbove) {
+ // 아래로 내림: 삭제된 요소의 크기만큼
+ const fallDistance = deletedPlacement.size_y || gridSize;
+ const newY = Math.max(halfSize, p.position_y - fallDistance); // 바닥(2.5) 아래로는 안 내려감
+
+ return {
+ ...p,
+ position_y: newY,
+ };
+ }
+
+ return p;
+ });
+ };
+
// 요소 삭제 확정 (로컬 상태에서만 삭제, 저장 시 서버에 반영)
const confirmDeletePlacement = () => {
const { placementId } = deleteConfirmDialog;
if (placementId === null) return;
- setPlacements((prev) => prev.filter((p) => p.id !== placementId));
+ setPlacements((prev) => {
+ const deletedPlacement = prev.find((p) => p.id === placementId);
+ if (!deletedPlacement) return prev;
+
+ // 삭제 후 남은 요소들
+ const remaining = prev.filter((p) => p.id !== placementId);
+
+ // 중력 적용 (재귀적으로 계속 적용)
+ let result = remaining;
+ let hasChanges = true;
+
+ // 모든 요소가 안정될 때까지 반복
+ while (hasChanges) {
+ const before = JSON.stringify(result.map((p) => p.position_y));
+ result = applyGravity(deletedPlacement, result);
+ const after = JSON.stringify(result.map((p) => p.position_y));
+ hasChanges = before !== after;
+ }
+
+ return result;
+ });
+
if (selectedPlacement?.id === placementId) {
setSelectedPlacement(null);
setShowConfigPanel(false);
@@ -358,6 +492,13 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
selectedPlacementId={selectedPlacement?.id || null}
onPlacementClick={(placement) => handleSelectPlacement(placement as YardPlacement)}
onPlacementDrag={handlePlacementDrag}
+ onCollisionDetected={() => {
+ toast({
+ title: "배치 불가",
+ description: "해당 위치에 이미 다른 요소가 있습니다.",
+ variant: "destructive",
+ });
+ }}
/>
)}
diff --git a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx
index d97ec05f..893ab6b0 100644
--- a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx
+++ b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx
@@ -56,6 +56,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
// 자동 새로고침 (30초마다)
const interval = setInterval(loadData, 30000);
return () => clearInterval(interval);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [element]);
const loadData = async () => {
@@ -101,7 +102,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
body: JSON.stringify({
query: groupByDS.query,
connectionType: groupByDS.connectionType || "current",
- connectionId: groupByDS.connectionId,
+ connectionId: (groupByDS as any).connectionId,
}),
});
@@ -116,7 +117,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
const labelColumn = columns[0];
const valueColumn = columns[1];
- const cards = rows.map((row) => ({
+ const cards = rows.map((row: any) => ({
label: String(row[labelColumn] || ""),
value: parseFloat(row[valueColumn]) || 0,
}));
@@ -137,12 +138,12 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
- method: groupByDS.method || "GET",
+ method: (groupByDS as any).method || "GET",
url: groupByDS.endpoint,
- headers: groupByDS.headers || {},
- body: groupByDS.body,
- authType: groupByDS.authType,
- authConfig: groupByDS.authConfig,
+ headers: (groupByDS as any).headers || {},
+ body: (groupByDS as any).body,
+ authType: (groupByDS as any).authType,
+ authConfig: (groupByDS as any).authConfig,
}),
});
@@ -169,7 +170,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
const labelColumn = columns[0];
const valueColumn = columns[1];
- const cards = rows.map((row) => ({
+ const cards = rows.map((row: any) => ({
label: String(row[labelColumn] || ""),
value: parseFloat(row[valueColumn]) || 0,
}));
@@ -201,7 +202,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
body: JSON.stringify({
query: element.dataSource.query,
connectionType: element.dataSource.connectionType || "current",
- connectionId: element.dataSource.connectionId,
+ connectionId: (element.dataSource as any).connectionId,
}),
});
@@ -212,13 +213,14 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
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,
- };
- });
+ const calculatedMetrics =
+ element.customMetricConfig?.metrics.map((metric) => {
+ const value = calculateMetric(rows, metric.field, metric.aggregation);
+ return {
+ ...metric,
+ calculatedValue: value,
+ };
+ }) || [];
setMetrics(calculatedMetrics);
} else {
@@ -240,12 +242,12 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
- method: element.dataSource.method || "GET",
+ method: (element.dataSource as any).method || "GET",
url: element.dataSource.endpoint,
- headers: element.dataSource.headers || {},
- body: element.dataSource.body,
- authType: element.dataSource.authType,
- authConfig: element.dataSource.authConfig,
+ headers: (element.dataSource as any).headers || {},
+ body: (element.dataSource as any).body,
+ authType: (element.dataSource as any).authType,
+ authConfig: (element.dataSource as any).authConfig,
}),
});
@@ -278,13 +280,14 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
rows = [result.data];
}
- const calculatedMetrics = element.customMetricConfig.metrics.map((metric) => {
- const value = calculateMetric(rows, metric.field, metric.aggregation);
- return {
- ...metric,
- calculatedValue: value,
- };
- });
+ const calculatedMetrics =
+ element.customMetricConfig?.metrics.map((metric) => {
+ const value = calculateMetric(rows, metric.field, metric.aggregation);
+ return {
+ ...metric,
+ calculatedValue: value,
+ };
+ }) || [];
setMetrics(calculatedMetrics);
} else {
@@ -351,7 +354,9 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
• 선택한 컬럼의 데이터로 지표를 계산합니다
• COUNT, SUM, AVG, MIN, MAX 등 집계 함수 지원
• 사용자 정의 단위 설정 가능
-
• 그룹별 카드 생성 모드로 간편하게 사용 가능
+
+ • 그룹별 카드 생성 모드로 간편하게 사용 가능
+