Merge pull request '대시보드 기타 수정사항(3d 야드 위주)' (#151) from feat/rest-api into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/151
This commit is contained in:
commit
d7e8feafc8
|
|
@ -105,7 +105,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-gray-50">
|
||||
<div className="h-screen">
|
||||
{/* 대시보드 헤더 - 보기 모드에서는 숨김 */}
|
||||
{/* <div className="border-b border-gray-200 bg-white px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { DashboardElement, QueryResult, ChartData } from "../types";
|
||||
import { Chart } from "./Chart";
|
||||
import { transformQueryResultToChartData } from "../utils/chartDataTransform";
|
||||
|
|
@ -21,11 +21,39 @@ interface ChartRendererProps {
|
|||
* - QueryResult를 ChartData로 변환
|
||||
* - D3 Chart 컴포넌트에 전달
|
||||
*/
|
||||
export function ChartRenderer({ element, data, width = 250, height = 200 }: ChartRendererProps) {
|
||||
export function ChartRenderer({ element, data, width, height = 200 }: ChartRendererProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(width || 250);
|
||||
const [chartData, setChartData] = useState<ChartData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex h-full w-full items-center justify-center bg-white p-2">
|
||||
<Chart
|
||||
chartType={element.subtype}
|
||||
data={chartData}
|
||||
config={element.chartConfig}
|
||||
width={width - 20}
|
||||
height={height - 20}
|
||||
/>
|
||||
<div ref={containerRef} className="flex h-full w-full items-center justify-center bg-white p-2">
|
||||
<div className="flex items-center justify-center">
|
||||
<Chart
|
||||
chartType={element.subtype}
|
||||
data={chartData}
|
||||
config={element.chartConfig}
|
||||
width={finalWidth}
|
||||
height={finalHeight}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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})`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -111,19 +111,21 @@ export function ClockWidget({ element, onConfigUpdate }: ClockWidgetProps) {
|
|||
{/* 시계 콘텐츠 */}
|
||||
{renderClockContent()}
|
||||
|
||||
{/* 설정 버튼 - 우측 상단 */}
|
||||
<div className="absolute top-2 right-2">
|
||||
<Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 bg-white/80 hover:bg-white">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[500px] p-0" align="end">
|
||||
<ClockSettings config={config} onSave={handleSaveSettings} onClose={() => setSettingsOpen(false)} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
{/* 설정 버튼 - 우측 상단 (디자이너 모드에서만 표시) */}
|
||||
{onConfigUpdate && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 bg-white/80 hover:bg-white">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[500px] p-0" align="end">
|
||||
<ClockSettings config={config} onSave={handleSaveSettings} onClose={() => setSettingsOpen(false)} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ export function ListWidget({ element }: ListWidgetProps) {
|
|||
const paginatedRows = config.enablePagination ? data.rows.slice(startIdx, endIdx) : data.rows;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col p-4">
|
||||
<div className="flex h-full w-full flex-col gap-3 p-4">
|
||||
{/* 테이블 뷰 */}
|
||||
{config.viewMode === "table" && (
|
||||
<div className={`flex-1 overflow-auto rounded-lg border ${config.compactMode ? "text-xs" : "text-sm"}`}>
|
||||
|
|
@ -306,7 +306,7 @@ export function ListWidget({ element }: ListWidgetProps) {
|
|||
|
||||
{/* 페이지네이션 */}
|
||||
{config.enablePagination && totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between text-sm">
|
||||
<div className="flex shrink-0 items-center justify-between border-t pt-3 text-sm">
|
||||
<div className="text-gray-600">
|
||||
{startIdx + 1}-{Math.min(endIdx, data.rows.length)} / {data.rows.length}개
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
@ -68,16 +68,19 @@ function MaterialBox({
|
|||
}) {
|
||||
const meshRef = useRef<THREE.Mesh>(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 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 mySizeY = placement.size_y || gridSize; // 박스 높이 (5)
|
||||
const myTotalHeight = mySizeY + palletHeight + palletGap; // 팔레트 포함한 전체 높이
|
||||
|
||||
let maxYBelow = gridSize / 2; // 기본 바닥 높이 (2.5)
|
||||
|
||||
|
|
@ -89,24 +92,33 @@ function MaterialBox({
|
|||
|
||||
const pSize = p.size_x || gridSize; // 상대방 크기 (5)
|
||||
const pHalfSize = pSize / 2; // 2.5
|
||||
const pSizeY = p.size_y || gridSize; // 상대방 높이 (5)
|
||||
const pSizeY = p.size_y || gridSize; // 상대방 박스 높이 (5)
|
||||
const pTotalHeight = pSizeY + palletHeight + palletGap; // 상대방 팔레트 포함 전체 높이
|
||||
|
||||
// XZ 평면에서 겹치는지 확인
|
||||
const isXZOverlapping =
|
||||
Math.abs(x - p.position_x) < myHalfSize + pHalfSize && // X축 겹침
|
||||
Math.abs(z - p.position_z) < myHalfSize + pHalfSize; // Z축 겹침
|
||||
// 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 (isXZOverlapping) {
|
||||
// 같은 XZ 위치에 요소가 있음
|
||||
// 그 요소의 윗면 높이 계산 (중심 + 높이/2)
|
||||
const topOfOtherElement = p.position_y + pSizeY / 2;
|
||||
// 내가 올라갈 Y 위치는 윗면 + 내 높이/2
|
||||
const myYOnTop = topOfOtherElement + mySizeY / 2;
|
||||
if (isNearby) {
|
||||
// 2단계: 실제로 겹치는지 정확히 판단 (바닥에 둘지, 위에 둘지 결정)
|
||||
const isActuallyOverlapping =
|
||||
Math.abs(x - p.position_x) < myHalfSize + pHalfSize && // X축 실제 겹침
|
||||
Math.abs(z - p.position_z) < myHalfSize + pHalfSize; // Z축 실제 겹침
|
||||
|
||||
// 가장 높은 위치 기록
|
||||
if (myYOnTop > maxYBelow) {
|
||||
maxYBelow = myYOnTop;
|
||||
if (isActuallyOverlapping) {
|
||||
// 실제로 겹침: 위에 배치
|
||||
// 상대방 전체 높이 (박스 + 팔레트)의 윗면 계산
|
||||
const topOfOtherElement = p.position_y + pTotalHeight / 2;
|
||||
// 내 전체 높이의 절반을 더해서 내가 올라갈 Y 위치 계산
|
||||
const myYOnTop = topOfOtherElement + myTotalHeight / 2;
|
||||
|
||||
if (myYOnTop > maxYBelow) {
|
||||
maxYBelow = myYOnTop;
|
||||
}
|
||||
}
|
||||
// 근처에만 있고 실제로 안 겹침: 바닥에 배치 (maxYBelow 유지)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -182,12 +194,9 @@ function MaterialBox({
|
|||
const snappedX = snapToGrid(finalX, gridSize);
|
||||
const snappedZ = snapToGrid(finalZ, gridSize);
|
||||
|
||||
// 충돌 체크 및 Y 위치 조정 (시각 피드백용)
|
||||
// 충돌 체크 및 Y 위치 조정
|
||||
const { adjustedY } = checkCollisionAndAdjustY(snappedX, dragStartPos.current.y, snappedZ);
|
||||
|
||||
// 시각 피드백: 항상 유효한 위치 (위로 올라가기 때문)
|
||||
setIsValidPosition(true);
|
||||
|
||||
// 즉시 mesh 위치 업데이트 (조정된 Y 위치로)
|
||||
meshRef.current.position.set(finalX, adjustedY, finalZ);
|
||||
|
||||
|
|
@ -285,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 (
|
||||
<Box
|
||||
<group
|
||||
ref={meshRef}
|
||||
position={[placement.position_x, placement.position_y, placement.position_z]}
|
||||
args={[placement.size_x, placement.size_y, placement.size_z]}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.nativeEvent?.stopPropagation();
|
||||
|
|
@ -298,7 +315,6 @@ function MaterialBox({
|
|||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerOver={() => {
|
||||
// 뷰어 모드(onDrag 없음)에서는 기본 커서, 편집 모드에서는 grab 커서
|
||||
if (onDrag) {
|
||||
gl.domElement.style.cursor = isSelected ? "grab" : "pointer";
|
||||
} else {
|
||||
|
|
@ -311,15 +327,142 @@ function MaterialBox({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<meshStandardMaterial
|
||||
color={isDragging ? (isValidPosition ? "#22c55e" : "#ef4444") : placement.color}
|
||||
opacity={isConfigured ? (isSelected ? 1 : 0.8) : 0.5}
|
||||
transparent
|
||||
emissive={isDragging ? (isValidPosition ? "#22c55e" : "#ef4444") : isSelected ? "#ffffff" : "#000000"}
|
||||
emissiveIntensity={isDragging ? 0.5 : isSelected ? 0.2 : 0}
|
||||
wireframe={!isConfigured}
|
||||
/>
|
||||
</Box>
|
||||
{/* 팔레트 그룹 - 박스 하단에 붙어있도록 */}
|
||||
<group position={[0, palletYOffset, 0]}>
|
||||
{/* 상단 가로 판자들 (5개) */}
|
||||
{[-boxDepth * 0.4, -boxDepth * 0.2, 0, boxDepth * 0.2, boxDepth * 0.4].map((zOffset, idx) => (
|
||||
<Box
|
||||
key={`top-${idx}`}
|
||||
args={[boxWidth * 0.95, palletHeight * 0.3, boxDepth * 0.15]}
|
||||
position={[0, palletHeight * 0.35, zOffset]}
|
||||
>
|
||||
<meshStandardMaterial color="#8B4513" roughness={0.95} metalness={0.0} />
|
||||
<lineSegments>
|
||||
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth * 0.95, palletHeight * 0.3, boxDepth * 0.15)]} />
|
||||
<lineBasicMaterial color="#000000" opacity={0.3} transparent />
|
||||
</lineSegments>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{/* 중간 세로 받침대 (3개) */}
|
||||
{[-boxWidth * 0.35, 0, boxWidth * 0.35].map((xOffset, idx) => (
|
||||
<Box
|
||||
key={`middle-${idx}`}
|
||||
args={[boxWidth * 0.12, palletHeight * 0.4, boxDepth * 0.2]}
|
||||
position={[xOffset, 0, 0]}
|
||||
>
|
||||
<meshStandardMaterial color="#654321" roughness={0.98} metalness={0.0} />
|
||||
<lineSegments>
|
||||
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth * 0.12, palletHeight * 0.4, boxDepth * 0.2)]} />
|
||||
<lineBasicMaterial color="#000000" opacity={0.4} transparent />
|
||||
</lineSegments>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{/* 하단 가로 판자들 (3개) */}
|
||||
{[-boxDepth * 0.3, 0, boxDepth * 0.3].map((zOffset, idx) => (
|
||||
<Box
|
||||
key={`bottom-${idx}`}
|
||||
args={[boxWidth * 0.95, palletHeight * 0.25, boxDepth * 0.18]}
|
||||
position={[0, -palletHeight * 0.35, zOffset]}
|
||||
>
|
||||
<meshStandardMaterial color="#6B4423" roughness={0.97} metalness={0.0} />
|
||||
<lineSegments>
|
||||
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth * 0.95, palletHeight * 0.25, boxDepth * 0.18)]} />
|
||||
<lineBasicMaterial color="#000000" opacity={0.3} transparent />
|
||||
</lineSegments>
|
||||
</Box>
|
||||
))}
|
||||
</group>
|
||||
|
||||
{/* 메인 박스 */}
|
||||
<Box args={[boxWidth, boxHeight, boxDepth]} position={[0, 0, 0]}>
|
||||
{/* 메인 재질 - 골판지 느낌 */}
|
||||
<meshStandardMaterial
|
||||
color={placement.color}
|
||||
opacity={isConfigured ? (isSelected ? 1 : 0.8) : 0.5}
|
||||
transparent
|
||||
emissive={isSelected ? "#ffffff" : "#000000"}
|
||||
emissiveIntensity={isSelected ? 0.2 : 0}
|
||||
wireframe={!isConfigured}
|
||||
roughness={0.95}
|
||||
metalness={0.05}
|
||||
/>
|
||||
|
||||
{/* 외곽선 - 더 진하게 */}
|
||||
<lineSegments>
|
||||
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth)]} />
|
||||
<lineBasicMaterial color="#1a1a1a" opacity={0.8} transparent />
|
||||
</lineSegments>
|
||||
</Box>
|
||||
|
||||
{/* 포장 테이프 (가로) - 윗면 */}
|
||||
{isConfigured && (
|
||||
<>
|
||||
{/* 테이프 세로 */}
|
||||
<Box args={[boxWidth * 0.12, 0.02, boxDepth * 0.95]} position={[0, boxHeight / 2 + 0.01, 0]}>
|
||||
<meshStandardMaterial color="#d4a574" opacity={0.7} transparent roughness={0.3} metalness={0.3} />
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 자재명 라벨 스티커 (앞면) - 흰색 배경 */}
|
||||
{isConfigured && placement.material_name && (
|
||||
<group position={[0, boxHeight * 0.1, boxDepth / 2 + 0.02]}>
|
||||
{/* 라벨 배경 (흰색 스티커) */}
|
||||
<Box args={[boxWidth * 0.7, boxHeight * 0.25, 0.01]}>
|
||||
<meshStandardMaterial color="#ffffff" roughness={0.4} metalness={0.1} />
|
||||
<lineSegments>
|
||||
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth * 0.7, boxHeight * 0.25, 0.01)]} />
|
||||
<lineBasicMaterial color="#cccccc" opacity={0.8} transparent />
|
||||
</lineSegments>
|
||||
</Box>
|
||||
{/* 라벨 텍스트 */}
|
||||
<Text
|
||||
position={[0, 0, 0.02]}
|
||||
fontSize={0.3}
|
||||
color="#000000"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{placement.material_name}
|
||||
</Text>
|
||||
</group>
|
||||
)}
|
||||
|
||||
{/* 수량 라벨 (윗면) - 큰 글씨 */}
|
||||
{isConfigured && placement.quantity && (
|
||||
<Text
|
||||
position={[0, boxHeight / 2 + 0.03, 0]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
fontSize={0.6}
|
||||
color="#000000"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.1}
|
||||
outlineColor="#ffffff"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{placement.quantity} {placement.unit || ""}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 디테일 표시 */}
|
||||
{isConfigured && (
|
||||
<>
|
||||
{/* 화살표 표시 (이 쪽이 위) */}
|
||||
<group position={[0, boxHeight * 0.35, boxDepth / 2 + 0.01]}>
|
||||
<Text fontSize={0.6} color="#000000" anchorX="center" anchorY="middle">
|
||||
▲
|
||||
</Text>
|
||||
<Text position={[0, -0.4, 0]} fontSize={0.3} color="#666666" anchorX="center" anchorY="middle">
|
||||
UP
|
||||
</Text>
|
||||
</group>
|
||||
</>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -393,7 +536,6 @@ function Scene({
|
|||
maxDistance={200}
|
||||
maxPolarAngle={Math.PI / 2}
|
||||
enabled={!isDraggingAny}
|
||||
reverseOrbit={true} // 드래그 방향 반전 (자연스러운 이동)
|
||||
screenSpacePanning={true} // 화면 공간 패닝
|
||||
panSpeed={0.8} // 패닝 속도 (기본값 1.0, 낮을수록 느림)
|
||||
rotateSpeed={0.5} // 회전 속도
|
||||
|
|
|
|||
|
|
@ -65,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))); // 깊은 복사
|
||||
}
|
||||
|
|
|
|||
|
|
@ -174,18 +174,6 @@ export function DashboardViewer({
|
|||
}: DashboardViewerProps) {
|
||||
const [elementData, setElementData] = useState<Record<string, QueryResult>>({});
|
||||
const [loadingElements, setLoadingElements] = useState<Set<string>>(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 (
|
||||
<DashboardProvider>
|
||||
{isMobile ? (
|
||||
// 모바일/태블릿: 세로 스택 레이아웃
|
||||
<div className="min-h-screen bg-gray-100 p-4" style={{ backgroundColor }}>
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
{/* 데스크톱: 디자이너에서 설정한 위치 그대로 렌더링 (화면에 맞춰 비율 유지) */}
|
||||
<div className="hidden min-h-screen bg-gray-100 py-8 lg:block" style={{ backgroundColor }}>
|
||||
<div className="mx-auto px-4" style={{ maxWidth: `${canvasConfig.width}px` }}>
|
||||
<div
|
||||
className="relative rounded-lg"
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: `${canvasConfig.height}px`,
|
||||
height: `${canvasHeight}px`,
|
||||
backgroundColor: backgroundColor,
|
||||
}}
|
||||
>
|
||||
{sortedElements.map((element) => (
|
||||
<ViewerElement
|
||||
key={element.id}
|
||||
|
|
@ -328,38 +322,29 @@ export function DashboardViewer({
|
|||
data={elementData[element.id]}
|
||||
isLoading={loadingElements.has(element.id)}
|
||||
onRefresh={() => loadElementData(element)}
|
||||
isMobile={true}
|
||||
isMobile={false}
|
||||
canvasWidth={canvasConfig.width}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 데스크톱: 기존 고정 캔버스 레이아웃
|
||||
<div className="min-h-screen bg-gray-100 py-8">
|
||||
<div className="mx-auto" style={{ width: `${canvasConfig.width}px` }}>
|
||||
<div
|
||||
className="relative rounded-lg"
|
||||
style={{
|
||||
width: `${canvasConfig.width}px`,
|
||||
minHeight: `${canvasConfig.height}px`,
|
||||
height: `${canvasHeight}px`,
|
||||
backgroundColor: backgroundColor,
|
||||
}}
|
||||
>
|
||||
{sortedElements.map((element) => (
|
||||
<ViewerElement
|
||||
key={element.id}
|
||||
element={element}
|
||||
data={elementData[element.id]}
|
||||
isLoading={loadingElements.has(element.id)}
|
||||
onRefresh={() => loadElementData(element)}
|
||||
isMobile={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 태블릿 이하: 반응형 세로 정렬 */}
|
||||
<div className="block min-h-screen bg-gray-100 p-4 lg:hidden" style={{ backgroundColor }}>
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
{sortedElements.map((element) => (
|
||||
<ViewerElement
|
||||
key={element.id}
|
||||
element={element}
|
||||
data={elementData[element.id]}
|
||||
isLoading={loadingElements.has(element.id)}
|
||||
onRefresh={() => loadElementData(element)}
|
||||
isMobile={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DashboardProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
className="relative overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm"
|
||||
style={{ minHeight: "300px" }}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{element.showHeader !== false && (
|
||||
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
|
||||
|
|
@ -393,19 +384,31 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: Viewer
|
|||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
className="text-gray-400 hover:text-gray-600 disabled:opacity-50"
|
||||
className="text-gray-400 transition-colors hover:text-gray-600 disabled:opacity-50"
|
||||
title="새로고침"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border border-gray-400 border-t-transparent" />
|
||||
) : (
|
||||
"🔄"
|
||||
)}
|
||||
<svg
|
||||
className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className={element.showHeader !== false ? "p-4" : "p-4"} style={{ minHeight: "250px" }}>
|
||||
{element.type === "chart" ? (
|
||||
{!isMounted ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
</div>
|
||||
) : element.type === "chart" ? (
|
||||
<ChartRenderer element={element} data={data} width={undefined} height={250} />
|
||||
) : (
|
||||
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 (
|
||||
<div
|
||||
className="absolute overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm"
|
||||
style={{
|
||||
left: element.position.x,
|
||||
left: `${(element.position.x / canvasWidth) * 100}%`,
|
||||
top: element.position.y,
|
||||
width: element.size.width,
|
||||
width: `${widthPercentage}%`,
|
||||
height: element.size.height,
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{element.showHeader !== false && (
|
||||
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
|
||||
|
|
@ -442,22 +446,37 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: Viewer
|
|||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
className={`text-gray-400 transition-opacity hover:text-gray-600 disabled:opacity-50 ${
|
||||
isHovered ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
className="text-gray-400 transition-colors hover:text-gray-600 disabled:opacity-50"
|
||||
title="새로고침"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border border-gray-400 border-t-transparent" />
|
||||
) : (
|
||||
"🔄"
|
||||
)}
|
||||
<svg
|
||||
className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className={element.showHeader !== false ? "h-[calc(100%-57px)]" : "h-full"}>
|
||||
{element.type === "chart" ? (
|
||||
<ChartRenderer element={element} data={data} width={element.size.width} height={element.size.height - 57} />
|
||||
<div className={element.showHeader !== false ? "h-[calc(100%-50px)] w-full" : "h-full w-full"}>
|
||||
{!isMounted ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
</div>
|
||||
) : element.type === "chart" ? (
|
||||
<ChartRenderer
|
||||
element={element}
|
||||
data={data}
|
||||
width={undefined}
|
||||
height={element.showHeader !== false ? element.size.height - 50 : element.size.height}
|
||||
/>
|
||||
) : (
|
||||
renderWidget(element)
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -374,45 +374,46 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
|||
}
|
||||
|
||||
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))" }}>
|
||||
{/* 그룹별 카드 (활성화 시) */}
|
||||
{isGroupByMode &&
|
||||
groupedCards.map((card, index) => {
|
||||
// 색상 순환 (6가지 색상)
|
||||
const colorKeys = Object.keys(colorMap) as Array<keyof typeof colorMap>;
|
||||
const colorKey = colorKeys[index % colorKeys.length];
|
||||
const colors = colorMap[colorKey];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`group-${index}`}
|
||||
className={`rounded-lg border ${colors.bg} ${colors.border} p-4 text-center`}
|
||||
>
|
||||
<div className="text-sm text-gray-600">{card.label}</div>
|
||||
<div className={`mt-2 text-3xl font-bold ${colors.text}`}>{card.value.toLocaleString()}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 일반 지표 카드 (항상 표시) */}
|
||||
{metrics.map((metric) => {
|
||||
const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray;
|
||||
const formattedValue = metric.calculatedValue.toFixed(metric.decimals);
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
|
||||
{/* 콘텐츠 영역 - 스크롤 없이 자동으로 크기 조정 */}
|
||||
<div className="grid h-full w-full gap-2" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))" }}>
|
||||
{/* 그룹별 카드 (활성화 시) */}
|
||||
{isGroupByMode &&
|
||||
groupedCards.map((card, index) => {
|
||||
// 색상 순환 (6가지 색상)
|
||||
const colorKeys = Object.keys(colorMap) as Array<keyof typeof colorMap>;
|
||||
const colorKey = colorKeys[index % colorKeys.length];
|
||||
const colors = colorMap[colorKey];
|
||||
|
||||
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
|
||||
key={`group-${index}`}
|
||||
className={`flex flex-col items-center justify-center rounded-lg border ${colors.bg} ${colors.border} p-2`}
|
||||
>
|
||||
<div className="text-[10px] text-gray-600">{card.label}</div>
|
||||
<div className={`mt-0.5 text-xl font-bold ${colors.text}`}>{card.value.toLocaleString()}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 일반 지표 카드 (항상 표시) */}
|
||||
{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={`flex flex-col items-center justify-center rounded-lg border ${colors.bg} ${colors.border} p-2`}
|
||||
>
|
||||
<div className="text-[10px] text-gray-600">{metric.label}</div>
|
||||
<div className={`mt-0.5 text-xl font-bold ${colors.text}`}>
|
||||
{formattedValue}
|
||||
<span className="ml-0.5 text-sm">{metric.unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue