diff --git a/frontend/app/(main)/dashboard/[dashboardId]/page.tsx b/frontend/app/(main)/dashboard/[dashboardId]/page.tsx index 7639abc6..54d61f77 100644 --- a/frontend/app/(main)/dashboard/[dashboardId]/page.tsx +++ b/frontend/app/(main)/dashboard/[dashboardId]/page.tsx @@ -105,7 +105,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) { } return ( -
+
{/* 대시보드 헤더 - 보기 모드에서는 숨김 */} {/*
diff --git a/frontend/components/admin/ExternalDbConnectionModal.tsx b/frontend/components/admin/ExternalDbConnectionModal.tsx index 5ab52491..8bf6c144 100644 --- a/frontend/components/admin/ExternalDbConnectionModal.tsx +++ b/frontend/components/admin/ExternalDbConnectionModal.tsx @@ -73,6 +73,9 @@ export const ExternalDbConnectionModal: React.FC // 연결 정보가 변경될 때 폼 데이터 업데이트 useEffect(() => { + // 테스트 관련 상태 초기화 + setTestResult(null); + if (connection) { setFormData({ ...connection, @@ -304,7 +307,9 @@ export const ExternalDbConnectionModal: React.FC - {isEditMode ? "연결 정보 수정" : "새 외부 DB 연결 추가"} + + {isEditMode ? "연결 정보 수정" : "새 외부 DB 연결 추가"} +
@@ -437,7 +442,7 @@ export const ExternalDbConnectionModal: React.FC type="button" variant="ghost" size="sm" - className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent" + className="absolute top-0 right-0 h-full px-3 py-2 hover:bg-transparent" onClick={() => setShowPassword(!showPassword)} > {showPassword ? : } @@ -464,7 +469,7 @@ export const ExternalDbConnectionModal: React.FC > {testingConnection ? "테스트 중..." : "연결 테스트"} - {testingConnection &&
연결을 확인하고 있습니다...
} + {testingConnection &&
연결을 확인하고 있습니다...
}
{/* 테스트 결과 표시 */} @@ -492,7 +497,9 @@ export const ExternalDbConnectionModal: React.FC {!testResult.success && testResult.error && (
오류 코드: {testResult.error.code}
- {testResult.error.details &&
{testResult.error.details}
} + {testResult.error.details && ( +
{testResult.error.details}
+ )}
)}
@@ -602,7 +609,11 @@ export const ExternalDbConnectionModal: React.FC > 취소 - diff --git a/frontend/components/admin/RestApiConnectionList.tsx b/frontend/components/admin/RestApiConnectionList.tsx index 3f512af2..a66d79c4 100644 --- a/frontend/components/admin/RestApiConnectionList.tsx +++ b/frontend/components/admin/RestApiConnectionList.tsx @@ -206,7 +206,7 @@ export function RestApiConnectionList() {
{/* 검색 */}
- + - - 새 연결 추가 + 새 연결 추가
{/* 연결 목록 */} {loading ? ( -
-
로딩 중...
+
+
로딩 중...
) : connections.length === 0 ? ( -
+
-

등록된 REST API 연결이 없습니다

+

등록된 REST API 연결이 없습니다

) : ( -
+
- - - 연결명 - 기본 URL - 인증 타입 - 헤더 수 - 상태 - 마지막 테스트 - 연결 테스트 - 작업 - - - - {connections.map((connection) => ( - - -
{connection.connection_name}
+ + + 연결명 + 기본 URL + 인증 타입 + 헤더 수 + 상태 + 마지막 테스트 + 연결 테스트 + 작업 + + + + {connections.map((connection) => ( + + +
+
+ {connection.connection_name} +
{connection.description && ( -
{connection.description}
- )} - - {connection.base_url} - - - {AUTH_TYPE_LABELS[connection.auth_type] || connection.auth_type} - - - - {Object.keys(connection.default_headers || {}).length} - - - - {connection.is_active === "Y" ? "활성" : "비활성"} - - - - {connection.last_test_date ? ( -
-
{new Date(connection.last_test_date).toLocaleDateString()}
- - {connection.last_test_result === "Y" ? "성공" : "실패"} - +
+ {connection.description}
- ) : ( - - )} - - -
-
+
+ +
+ {connection.base_url} +
+
+ + {AUTH_TYPE_LABELS[connection.auth_type] || connection.auth_type} + + + {Object.keys(connection.default_headers || {}).length} + + + + {connection.is_active === "Y" ? "활성" : "비활성"} + + + + {connection.last_test_date ? ( +
+
{new Date(connection.last_test_date).toLocaleDateString()}
+ - {testingConnections.has(connection.id!) ? "테스트 중..." : "테스트"} - - {testResults.has(connection.id!) && ( - - {testResults.get(connection.id!) ? "성공" : "실패"} - - )} + {connection.last_test_result === "Y" ? "성공" : "실패"} +
-
- -
- - -
-
- - ))} - -
-
+ ) : ( + - + )} + + +
+ + {testResults.has(connection.id!) && ( + + {testResults.get(connection.id!) ? "성공" : "실패"} + + )} +
+
+ +
+ + +
+
+ + ))} + + +
)} {/* 연결 설정 모달 */} @@ -377,8 +384,7 @@ export function RestApiConnectionList() { 연결 삭제 확인 "{connectionToDelete?.connection_name}" 연결을 삭제하시겠습니까? -
- 이 작업은 되돌릴 수 없습니다. +
이 작업은 되돌릴 수 없습니다.
@@ -390,7 +396,7 @@ export function RestApiConnectionList() { 삭제 diff --git a/frontend/components/admin/RestApiConnectionModal.tsx b/frontend/components/admin/RestApiConnectionModal.tsx index 27b421cb..2b5d2097 100644 --- a/frontend/components/admin/RestApiConnectionModal.tsx +++ b/frontend/components/admin/RestApiConnectionModal.tsx @@ -46,6 +46,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: const [testEndpoint, setTestEndpoint] = useState(""); const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState(null); + const [testRequestUrl, setTestRequestUrl] = useState(""); const [saving, setSaving] = useState(false); // 기존 연결 데이터 로드 @@ -77,6 +78,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: setTestResult(null); setTestEndpoint(""); + setTestRequestUrl(""); }, [connection, isOpen]); // 연결 테스트 @@ -94,6 +96,10 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: setTesting(true); setTestResult(null); + // 사용자가 테스트하려는 실제 외부 API URL 설정 + const fullUrl = testEndpoint ? `${baseUrl}${testEndpoint}` : baseUrl; + setTestRequestUrl(fullUrl); + try { const result = await ExternalRestApiConnectionAPI.testConnection({ base_url: baseUrl, @@ -220,7 +226,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
setShowAdvanced(!showAdvanced)} - className="flex items-center space-x-2 text-sm font-semibold hover:text-blue-600" + className="hover:text-primary flex items-center space-x-2 text-sm font-semibold transition-colors" > 고급 설정 {showAdvanced ? : } {showAdvanced && ( -
+
@@ -342,7 +348,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: id="test-endpoint" value={testEndpoint} onChange={(e) => setTestEndpoint(e.target.value)} - placeholder="/api/v1/test 또는 빈칸 (기본 URL만 테스트)" + placeholder="엔드포인트 또는 빈칸(기본 URL만 테스트)" />
@@ -351,6 +357,41 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: {testing ? "테스트 중..." : "연결 테스트"} + {/* 테스트 요청 정보 표시 */} + {testRequestUrl && ( +
+
+
테스트 요청 URL
+ GET {testRequestUrl} +
+ + {Object.keys(defaultHeaders).length > 0 && ( +
+
요청 헤더
+
+ {Object.entries(defaultHeaders).map(([key, value]) => ( + + {key}: {value} + + ))} +
+
+ )} + + {authType !== "none" && ( +
+
인증 방식
+ + {authType === "api-key" && "API Key"} + {authType === "bearer" && "Bearer Token"} + {authType === "basic" && "Basic Auth"} + {authType === "oauth2" && "OAuth 2.0"} + +
+ )} +
+ )} + {testResult && (
(null); + const [containerWidth, setContainerWidth] = useState(width || 250); const [chartData, setChartData] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + // 컨테이너 너비 측정 (width가 undefined일 때) + useEffect(() => { + if (width !== undefined) { + setContainerWidth(width); + return; + } + + const updateWidth = () => { + if (containerRef.current) { + const measuredWidth = containerRef.current.offsetWidth; + console.log("📏 컨테이너 너비 측정:", measuredWidth); + setContainerWidth(measuredWidth || 500); // 기본값 500 + } + }; + + // 약간의 지연을 두고 측정 (DOM 렌더링 완료 후) + const timer = setTimeout(updateWidth, 100); + updateWidth(); + + window.addEventListener("resize", updateWidth); + return () => { + clearTimeout(timer); + window.removeEventListener("resize", updateWidth); + }; + }, [width]); + // 데이터 페칭 useEffect(() => { const fetchData = async () => { @@ -212,15 +240,39 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char } // D3 차트 렌더링 + const actualWidth = width !== undefined ? width : containerWidth; + + // 원형 차트는 더 큰 크기가 필요 (최소 400px) + const isCircularChart = element.subtype === "pie" || element.subtype === "donut"; + const minWidth = isCircularChart ? 400 : 200; + const finalWidth = Math.max(actualWidth - 20, minWidth); + // 원형 차트는 범례 공간을 위해 더 많은 여백 필요 + const finalHeight = Math.max(height - (isCircularChart ? 60 : 20), 300); + + console.log("🎨 ChartRenderer:", { + elementSubtype: element.subtype, + propWidth: width, + containerWidth, + actualWidth, + finalWidth, + finalHeight, + hasChartData: !!chartData, + chartDataLabels: chartData?.labels, + chartDataDatasets: chartData?.datasets?.length, + isCircularChart, + }); + return ( -
- +
+
+ +
); } diff --git a/frontend/components/admin/dashboard/charts/PieChart.tsx b/frontend/components/admin/dashboard/charts/PieChart.tsx index 8afcb4c0..ab24219f 100644 --- a/frontend/components/admin/dashboard/charts/PieChart.tsx +++ b/frontend/components/admin/dashboard/charts/PieChart.tsx @@ -24,12 +24,17 @@ export function PieChart({ data, config, width = 500, height = 500, isDonut = fa const svg = d3.select(svgRef.current); svg.selectAll("*").remove(); - const margin = { top: 40, right: 150, bottom: 40, left: 120 }; + // 범례를 위한 여백 확보 (아래 80px) + const legendHeight = config.showLegend !== false ? 80 : 0; + const margin = { top: 20, right: 20, bottom: 20 + legendHeight, left: 20 }; const chartWidth = width - margin.left - margin.right; - const chartHeight = height - margin.top - margin.bottom; + const chartHeight = height - margin.top - margin.bottom - legendHeight; const radius = Math.min(chartWidth, chartHeight) / 2; - const g = svg.append("g").attr("transform", `translate(${width / 2},${height / 2})`); + // 차트를 위쪽에 배치 (범례 공간 확보) + const centerX = width / 2; + const centerY = margin.top + radius + 20; + const g = svg.append("g").attr("transform", `translate(${centerX},${centerY})`); // 색상 팔레트 const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B", "#8B5CF6", "#EC4899"]; @@ -136,33 +141,35 @@ export function PieChart({ data, config, width = 500, height = 500, isDonut = fa .text(config.title); } - // 범례 (차트 오른쪽, 세로 배치) + // 범례 (차트 아래, 가로 배치, 중앙 정렬) if (config.showLegend !== false) { - const legendX = width / 2 + radius + 30; // 차트 오른쪽 - const legendY = (height - pieData.length * 25) / 2; // 세로 중앙 정렬 - - const legend = svg - .append("g") - .attr("class", "legend") - .attr("transform", `translate(${legendX}, ${legendY})`); + const itemSpacing = 100; // 각 범례 항목 사이 간격 (줄임) + const totalWidth = pieData.length * itemSpacing; + const legendStartX = (width - totalWidth) / 2; // 시작 위치 + const legendY = centerY + radius + 40; // 차트 아래 40px + + const legend = svg.append("g").attr("class", "legend"); pieData.forEach((d, i) => { const legendItem = legend .append("g") - .attr("transform", `translate(0, ${i * 25})`); + .attr("transform", `translate(${legendStartX + i * itemSpacing + itemSpacing / 2}, ${legendY})`); legendItem .append("rect") - .attr("width", 15) - .attr("height", 15) + .attr("x", -6) // 사각형을 중앙 기준으로 + .attr("y", -6) + .attr("width", 12) + .attr("height", 12) .attr("fill", colors[i % colors.length]) - .attr("rx", 3); + .attr("rx", 2); legendItem .append("text") - .attr("x", 20) - .attr("y", 12) - .style("font-size", "11px") + .attr("x", 0) + .attr("y", 18) + .attr("text-anchor", "middle") // 텍스트 중앙 정렬 + .style("font-size", "10px") .style("fill", "#333") .text(`${d.label} (${d.value})`); }); diff --git a/frontend/components/admin/dashboard/widgets/ClockWidget.tsx b/frontend/components/admin/dashboard/widgets/ClockWidget.tsx index e85623f8..fff65bc4 100644 --- a/frontend/components/admin/dashboard/widgets/ClockWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/ClockWidget.tsx @@ -111,19 +111,21 @@ export function ClockWidget({ element, onConfigUpdate }: ClockWidgetProps) { {/* 시계 콘텐츠 */} {renderClockContent()} - {/* 설정 버튼 - 우측 상단 */} -
- - - - - - setSettingsOpen(false)} /> - - -
+ {/* 설정 버튼 - 우측 상단 (디자이너 모드에서만 표시) */} + {onConfigUpdate && ( +
+ + + + + + setSettingsOpen(false)} /> + + +
+ )}
); } diff --git a/frontend/components/admin/dashboard/widgets/ListWidget.tsx b/frontend/components/admin/dashboard/widgets/ListWidget.tsx index 378d8825..6d3e6929 100644 --- a/frontend/components/admin/dashboard/widgets/ListWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/ListWidget.tsx @@ -216,12 +216,7 @@ export function ListWidget({ element }: ListWidgetProps) { const paginatedRows = config.enablePagination ? data.rows.slice(startIdx, endIdx) : data.rows; return ( -
- {/* 제목 - 항상 표시 */} -
-

{element.customTitle || element.title}

-
- +
{/* 테이블 뷰 */} {config.viewMode === "table" && (
@@ -311,7 +306,7 @@ export function ListWidget({ element }: ListWidgetProps) { {/* 페이지네이션 */} {config.enablePagination && totalPages > 1 && ( -
+
{startIdx + 1}-{Math.min(endIdx, data.rows.length)} / {data.rows.length}개
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx index 29c15ca9..b5af2630 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx @@ -1,7 +1,7 @@ "use client"; import { Canvas, useThree } from "@react-three/fiber"; -import { OrbitControls, Grid, Box } from "@react-three/drei"; +import { OrbitControls, Grid, Box, Text } from "@react-three/drei"; import { Suspense, useRef, useState, useEffect } from "react"; import * as THREE from "three"; @@ -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,6 +62,9 @@ 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); @@ -53,10 +72,83 @@ function MaterialBox({ 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 palletHeight = 0.3; // 팔레트 높이 + const palletGap = 0.05; // 팔레트와 박스 사이 간격 + + const mySize = placement.size_x || gridSize; // 내 크기 (5) + const myHalfSize = mySize / 2; // 2.5 + const mySizeY = placement.size_y || gridSize; // 박스 높이 (5) + const myTotalHeight = mySizeY + palletHeight + palletGap; // 팔레트 포함한 전체 높이 + + 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) + const pTotalHeight = pSizeY + palletHeight + palletGap; // 상대방 팔레트 포함 전체 높이 + + // 1단계: 넓은 범위로 겹침 감지 (살짝만 가까이 가도 감지) + const detectionMargin = 0.5; // 감지 범위 확장 (0.5 유닛) + const isNearby = + Math.abs(x - p.position_x) < myHalfSize + pHalfSize + detectionMargin && // X축 근접 + Math.abs(z - p.position_z) < myHalfSize + pHalfSize + detectionMargin; // Z축 근접 + + if (isNearby) { + // 2단계: 실제로 겹치는지 정확히 판단 (바닥에 둘지, 위에 둘지 결정) + const isActuallyOverlapping = + Math.abs(x - p.position_x) < myHalfSize + pHalfSize && // X축 실제 겹침 + Math.abs(z - p.position_z) < myHalfSize + pHalfSize; // Z축 실제 겹침 + + if (isActuallyOverlapping) { + // 실제로 겹침: 위에 배치 + // 상대방 전체 높이 (박스 + 팔레트)의 윗면 계산 + const topOfOtherElement = p.position_y + pTotalHeight / 2; + // 내 전체 높이의 절반을 더해서 내가 올라갈 Y 위치 계산 + const myYOnTop = topOfOtherElement + myTotalHeight / 2; + + if (myYOnTop > maxYBelow) { + maxYBelow = myYOnTop; + } + } + // 근처에만 있고 실제로 안 겹침: 바닥에 배치 (maxYBelow 유지) + } + } + + // 요청한 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 +190,56 @@ 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); + + // 즉시 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 +269,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, }; // 마우스 시작 위치 저장 @@ -165,11 +294,19 @@ function MaterialBox({ // 요소가 설정되었는지 확인 const isConfigured = !!(placement.material_name && placement.quantity && placement.unit); + const boxHeight = placement.size_y || gridSize; + const boxWidth = placement.size_x || gridSize; + const boxDepth = placement.size_z || gridSize; + const palletHeight = 0.3; // 팔레트 높이 + const palletGap = 0.05; // 팔레트와 박스 사이 간격 (매우 작게) + + // 팔레트 위치 계산: 박스 하단부터 시작 + const palletYOffset = -(boxHeight / 2) - palletHeight / 2 - palletGap; + return ( - { e.stopPropagation(); e.nativeEvent?.stopPropagation(); @@ -178,7 +315,6 @@ function MaterialBox({ }} onPointerDown={handlePointerDown} onPointerOver={() => { - // 뷰어 모드(onDrag 없음)에서는 기본 커서, 편집 모드에서는 grab 커서 if (onDrag) { gl.domElement.style.cursor = isSelected ? "grab" : "pointer"; } else { @@ -191,20 +327,154 @@ function MaterialBox({ } }} > - - + {/* 팔레트 그룹 - 박스 하단에 붙어있도록 */} + + {/* 상단 가로 판자들 (5개) */} + {[-boxDepth * 0.4, -boxDepth * 0.2, 0, boxDepth * 0.2, boxDepth * 0.4].map((zOffset, idx) => ( + + + + + + + + ))} + + {/* 중간 세로 받침대 (3개) */} + {[-boxWidth * 0.35, 0, boxWidth * 0.35].map((xOffset, idx) => ( + + + + + + + + ))} + + {/* 하단 가로 판자들 (3개) */} + {[-boxDepth * 0.3, 0, boxDepth * 0.3].map((zOffset, idx) => ( + + + + + + + + ))} + + + {/* 메인 박스 */} + + {/* 메인 재질 - 골판지 느낌 */} + + + {/* 외곽선 - 더 진하게 */} + + + + + + + {/* 포장 테이프 (가로) - 윗면 */} + {isConfigured && ( + <> + {/* 테이프 세로 */} + + + + + )} + + {/* 자재명 라벨 스티커 (앞면) - 흰색 배경 */} + {isConfigured && placement.material_name && ( + + {/* 라벨 배경 (흰색 스티커) */} + + + + + + + + {/* 라벨 텍스트 */} + + {placement.material_name} + + + )} + + {/* 수량 라벨 (윗면) - 큰 글씨 */} + {isConfigured && placement.quantity && ( + + {placement.quantity} {placement.unit || ""} + + )} + + {/* 디테일 표시 */} + {isConfigured && ( + <> + {/* 화살표 표시 (이 쪽이 위) */} + + + ▲ + + + UP + + + + )} + ); } // 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 +485,15 @@ function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementD - {/* 바닥 그리드 */} + {/* 바닥 그리드 (타일을 4등분) */} ))} @@ -259,10 +532,13 @@ function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementD enablePan={true} enableZoom={true} enableRotate={true} - minDistance={10} + minDistance={8} maxDistance={200} maxPolarAngle={Math.PI / 2} enabled={!isDraggingAny} + screenSpacePanning={true} // 화면 공간 패닝 + panSpeed={0.8} // 패닝 속도 (기본값 1.0, 낮을수록 느림) + rotateSpeed={0.5} // 회전 속도 /> ); @@ -273,6 +549,8 @@ export default function Yard3DCanvas({ selectedPlacementId, onPlacementClick, onPlacementDrag, + gridSize = 5, + onCollisionDetected, }: Yard3DCanvasProps) { const handleCanvasClick = (e: any) => { // Canvas의 빈 공간을 클릭했을 때만 선택 해제 @@ -297,6 +575,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..a9fea2f3 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); @@ -63,7 +65,17 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { setIsLoading(true); const response = await yardLayoutApi.getPlacementsByLayoutId(layout.id); if (response.success) { - const loadedData = response.data as YardPlacement[]; + const loadedData = (response.data as YardPlacement[]).map((p) => ({ + ...p, + // 문자열로 저장된 숫자 필드를 숫자로 변환 + position_x: Number(p.position_x), + position_y: Number(p.position_y), + position_z: Number(p.position_z), + size_x: Number(p.size_x), + size_y: Number(p.size_y), + size_z: Number(p.size_z), + quantity: p.quantity !== null && p.quantity !== undefined ? Number(p.quantity) : null, + })); setPlacements(loadedData); setOriginalPlacements(JSON.parse(JSON.stringify(loadedData))); // 깊은 복사 } @@ -78,8 +90,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 +180,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 +219,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 +502,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/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index 4bbca728..4ad23fa8 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -174,18 +174,6 @@ export function DashboardViewer({ }: DashboardViewerProps) { const [elementData, setElementData] = useState>({}); const [loadingElements, setLoadingElements] = useState>(new Set()); - const [isMobile, setIsMobile] = useState(false); - - // 화면 크기 감지 - useEffect(() => { - const checkMobile = () => { - setIsMobile(window.innerWidth < 1024); // 1024px (lg) 미만은 모바일/태블릿 - }; - - checkMobile(); - window.addEventListener("resize", checkMobile); - return () => window.removeEventListener("resize", checkMobile); - }, []); // 캔버스 설정 계산 const canvasConfig = useMemo(() => { @@ -287,10 +275,8 @@ export function DashboardViewer({ return () => clearInterval(interval); }, [refreshInterval, loadAllData]); - // 모바일에서 요소를 자연스러운 읽기 순서로 정렬 (왼쪽→오른쪽, 위→아래) + // 요소를 자연스러운 읽기 순서로 정렬 (왼쪽→오른쪽, 위→아래) - 태블릿 이하에서 세로 정렬 시 사용 const sortedElements = useMemo(() => { - if (!isMobile) return elements; - return [...elements].sort((a, b) => { // Y 좌표 차이가 50px 이상이면 Y 우선 (같은 행으로 간주 안함) const yDiff = a.position.y - b.position.y; @@ -300,7 +286,7 @@ export function DashboardViewer({ // 같은 행이면 X 좌표로 정렬 return a.position.x - b.position.x; }); - }, [elements, isMobile]); + }, [elements]); // 요소가 없는 경우 if (elements.length === 0) { @@ -317,10 +303,18 @@ export function DashboardViewer({ return ( - {isMobile ? ( - // 모바일/태블릿: 세로 스택 레이아웃 -
-
+ {/* 데스크톱: 디자이너에서 설정한 위치 그대로 렌더링 (화면에 맞춰 비율 유지) */} +
+
+
{sortedElements.map((element) => ( loadElementData(element)} - isMobile={true} + isMobile={false} + canvasWidth={canvasConfig.width} /> ))}
- ) : ( - // 데스크톱: 기존 고정 캔버스 레이아웃 -
-
-
- {sortedElements.map((element) => ( - loadElementData(element)} - isMobile={false} - /> - ))} -
-
+
+ + {/* 태블릿 이하: 반응형 세로 정렬 */} +
+
+ {sortedElements.map((element) => ( + loadElementData(element)} + isMobile={true} + /> + ))}
- )} +
); } @@ -370,22 +355,28 @@ interface ViewerElementProps { isLoading: boolean; onRefresh: () => void; isMobile: boolean; + canvasWidth?: number; } /** * 개별 뷰어 요소 컴포넌트 + * - 데스크톱(lg 이상): absolute positioning으로 디자이너에서 설정한 위치 그대로 렌더링 (너비는 화면 비율에 따라 조정) + * - 태블릿 이하: 세로 스택 카드 레이아웃 */ -function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: ViewerElementProps) { - const [isHovered, setIsHovered] = useState(false); +function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWidth = 1920 }: ViewerElementProps) { + const [isMounted, setIsMounted] = useState(false); + + // 마운트 확인 (Leaflet 지도 초기화 문제 해결) + useEffect(() => { + setIsMounted(true); + }, []); if (isMobile) { - // 모바일/태블릿: 세로 스택 카드 스타일 + // 태블릿 이하: 세로 스택 카드 스타일 return (
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} > {element.showHeader !== false && (
@@ -393,19 +384,31 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: Viewer
)}
- {element.type === "chart" ? ( + {!isMounted ? ( +
+
+
+ ) : element.type === "chart" ? ( ) : ( renderWidget(element) @@ -423,18 +426,19 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: Viewer ); } - // 데스크톱: 기존 absolute positioning + // 데스크톱: 디자이너에서 설정한 위치 그대로 absolute positioning + // 단, 너비는 화면 크기에 따라 비율로 조정 + const widthPercentage = (element.size.width / canvasWidth) * 100; + return (
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} > {element.showHeader !== false && (
@@ -442,22 +446,37 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: Viewer
)} -
- {element.type === "chart" ? ( - +
+ {!isMounted ? ( +
+
+
+ ) : element.type === "chart" ? ( + ) : ( renderWidget(element) )} diff --git a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx index d97ec05f..52c8411c 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 등 집계 함수 지원
  • • 사용자 정의 단위 설정 가능
  • -
  • 그룹별 카드 생성 모드로 간편하게 사용 가능
  • +
  • + • 그룹별 카드 생성 모드로 간편하게 사용 가능 +
  • @@ -361,11 +366,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) ? "SQL 쿼리를 입력하고 실행하세요 (지표 추가 불필요)" : "SQL 쿼리를 입력하고 지표를 추가하세요"}

    - {isGroupByMode && ( -

    - 💡 첫 번째 컬럼: 카드 제목, 두 번째 컬럼: 카드 값 -

    - )} + {isGroupByMode &&

    💡 첫 번째 컬럼: 카드 제목, 두 번째 컬럼: 카드 값

    }
    @@ -373,42 +374,46 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) } return ( -
    - {/* 스크롤 가능한 콘텐츠 영역 */} -
    -
    - {/* 그룹별 카드 (활성화 시) */} - {isGroupByMode && - groupedCards.map((card, index) => { - // 색상 순환 (6가지 색상) - const colorKeys = Object.keys(colorMap) as Array; - const colorKey = colorKeys[index % colorKeys.length]; - const colors = colorMap[colorKey]; - - return ( -
    -
    {card.label}
    -
    {card.value.toLocaleString()}
    -
    - ); - })} - - {/* 일반 지표 카드 (항상 표시) */} - {metrics.map((metric) => { - const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray; - const formattedValue = metric.calculatedValue.toFixed(metric.decimals); +
    + {/* 콘텐츠 영역 - 스크롤 없이 자동으로 크기 조정 */} +
    + {/* 그룹별 카드 (활성화 시) */} + {isGroupByMode && + groupedCards.map((card, index) => { + // 색상 순환 (6가지 색상) + const colorKeys = Object.keys(colorMap) as Array; + const colorKey = colorKeys[index % colorKeys.length]; + const colors = colorMap[colorKey]; return ( -
    -
    {metric.label}
    -
    - {formattedValue} - {metric.unit} -
    +
    +
    {card.label}
    +
    {card.value.toLocaleString()}
    ); })} -
    + + {/* 일반 지표 카드 (항상 표시) */} + {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} +
    +
    + ); + })}
    );