Merge pull request '기타 수정사항' (#150) from feat/rest-api into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/150
This commit is contained in:
commit
4f2cf6c0ff
|
|
@ -217,11 +217,6 @@ export function ListWidget({ element }: ListWidgetProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col p-4">
|
<div className="flex h-full w-full flex-col p-4">
|
||||||
{/* 제목 - 항상 표시 */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<h3 className="text-sm font-semibold text-gray-700">{element.customTitle || element.title}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 테이블 뷰 */}
|
{/* 테이블 뷰 */}
|
||||||
{config.viewMode === "table" && (
|
{config.viewMode === "table" && (
|
||||||
<div className={`flex-1 overflow-auto rounded-lg border ${config.compactMode ? "text-xs" : "text-sm"}`}>
|
<div className={`flex-1 overflow-auto rounded-lg border ${config.compactMode ? "text-xs" : "text-sm"}`}>
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,19 @@ interface Yard3DCanvasProps {
|
||||||
selectedPlacementId: number | null;
|
selectedPlacementId: number | null;
|
||||||
onPlacementClick: (placement: YardPlacement | null) => void;
|
onPlacementClick: (placement: YardPlacement | null) => void;
|
||||||
onPlacementDrag?: (id: number, position: { x: number; y: number; z: number }) => 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,
|
onDrag,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
|
gridSize = 5,
|
||||||
|
allPlacements = [],
|
||||||
|
onCollisionDetected,
|
||||||
}: {
|
}: {
|
||||||
placement: YardPlacement;
|
placement: YardPlacement;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
|
@ -46,17 +62,81 @@ function MaterialBox({
|
||||||
onDrag?: (position: { x: number; y: number; z: number }) => void;
|
onDrag?: (position: { x: number; y: number; z: number }) => void;
|
||||||
onDragStart?: () => void;
|
onDragStart?: () => void;
|
||||||
onDragEnd?: () => void;
|
onDragEnd?: () => void;
|
||||||
|
gridSize?: number;
|
||||||
|
allPlacements?: YardPlacement[];
|
||||||
|
onCollisionDetected?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const meshRef = useRef<THREE.Mesh>(null);
|
const meshRef = useRef<THREE.Mesh>(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
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 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 mouseStartPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||||
const { camera, gl } = useThree();
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isDragging && meshRef.current) {
|
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]);
|
}, [placement.position_x, placement.position_y, placement.position_z, isDragging]);
|
||||||
|
|
||||||
|
|
@ -98,20 +178,59 @@ function MaterialBox({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 즉시 mesh 위치 업데이트 (부드러운 드래그)
|
// 그리드에 스냅
|
||||||
meshRef.current.position.set(finalX, dragStartPos.current.y, finalZ);
|
const snappedX = snapToGrid(finalX, gridSize);
|
||||||
|
const snappedZ = snapToGrid(finalZ, gridSize);
|
||||||
|
|
||||||
// 상태 업데이트 (저장용)
|
// 충돌 체크 및 Y 위치 조정 (시각 피드백용)
|
||||||
onDrag({
|
const { adjustedY } = checkCollisionAndAdjustY(snappedX, dragStartPos.current.y, snappedZ);
|
||||||
x: finalX,
|
|
||||||
y: dragStartPos.current.y,
|
// 시각 피드백: 항상 유효한 위치 (위로 올라가기 때문)
|
||||||
z: finalZ,
|
setIsValidPosition(true);
|
||||||
});
|
|
||||||
|
// 즉시 mesh 위치 업데이트 (조정된 Y 위치로)
|
||||||
|
meshRef.current.position.set(finalX, adjustedY, finalZ);
|
||||||
|
|
||||||
|
// ⚠️ 드래그 중에는 상태 업데이트 안 함 (미리보기만)
|
||||||
|
// 실제 저장은 handleGlobalMouseUp에서만 수행
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const 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);
|
setIsDragging(false);
|
||||||
gl.domElement.style.cursor = isSelected ? "grab" : "pointer";
|
gl.domElement.style.cursor = isSelected ? "grab" : "pointer";
|
||||||
if (onDragEnd) {
|
if (onDragEnd) {
|
||||||
|
|
@ -141,11 +260,12 @@ function MaterialBox({
|
||||||
|
|
||||||
// 편집 모드에서 선택되었고 드래그 가능한 경우
|
// 편집 모드에서 선택되었고 드래그 가능한 경우
|
||||||
if (isSelected && meshRef.current) {
|
if (isSelected && meshRef.current) {
|
||||||
// 드래그 시작 시점의 자재 위치 저장 (숫자로 변환)
|
// 드래그 시작 시점의 mesh 실제 위치 저장 (현재 렌더링된 위치)
|
||||||
|
const currentPos = meshRef.current.position;
|
||||||
dragStartPos.current = {
|
dragStartPos.current = {
|
||||||
x: Number(placement.position_x),
|
x: currentPos.x,
|
||||||
y: Number(placement.position_y),
|
y: currentPos.y,
|
||||||
z: Number(placement.position_z),
|
z: currentPos.z,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 마우스 시작 위치 저장
|
// 마우스 시작 위치 저장
|
||||||
|
|
@ -192,11 +312,11 @@ function MaterialBox({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
color={placement.color}
|
color={isDragging ? (isValidPosition ? "#22c55e" : "#ef4444") : placement.color}
|
||||||
opacity={isConfigured ? (isSelected ? 1 : 0.8) : 0.5}
|
opacity={isConfigured ? (isSelected ? 1 : 0.8) : 0.5}
|
||||||
transparent
|
transparent
|
||||||
emissive={isSelected ? "#ffffff" : "#000000"}
|
emissive={isDragging ? (isValidPosition ? "#22c55e" : "#ef4444") : isSelected ? "#ffffff" : "#000000"}
|
||||||
emissiveIntensity={isSelected ? 0.2 : 0}
|
emissiveIntensity={isDragging ? 0.5 : isSelected ? 0.2 : 0}
|
||||||
wireframe={!isConfigured}
|
wireframe={!isConfigured}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -204,7 +324,14 @@ function MaterialBox({
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3D 씬 컴포넌트
|
// 3D 씬 컴포넌트
|
||||||
function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementDrag }: Yard3DCanvasProps) {
|
function Scene({
|
||||||
|
placements,
|
||||||
|
selectedPlacementId,
|
||||||
|
onPlacementClick,
|
||||||
|
onPlacementDrag,
|
||||||
|
gridSize = 5,
|
||||||
|
onCollisionDetected,
|
||||||
|
}: Yard3DCanvasProps) {
|
||||||
const [isDraggingAny, setIsDraggingAny] = useState(false);
|
const [isDraggingAny, setIsDraggingAny] = useState(false);
|
||||||
const orbitControlsRef = useRef<any>(null);
|
const orbitControlsRef = useRef<any>(null);
|
||||||
|
|
||||||
|
|
@ -215,15 +342,15 @@ function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementD
|
||||||
<directionalLight position={[10, 10, 5]} intensity={1} />
|
<directionalLight position={[10, 10, 5]} intensity={1} />
|
||||||
<directionalLight position={[-10, -10, -5]} intensity={0.3} />
|
<directionalLight position={[-10, -10, -5]} intensity={0.3} />
|
||||||
|
|
||||||
{/* 바닥 그리드 */}
|
{/* 바닥 그리드 (타일을 4등분) */}
|
||||||
<Grid
|
<Grid
|
||||||
args={[100, 100]}
|
args={[100, 100]}
|
||||||
cellSize={5}
|
cellSize={gridSize / 2} // 타일을 2x2로 나눔 (2.5칸)
|
||||||
cellThickness={0.5}
|
cellThickness={0.6}
|
||||||
cellColor="#6b7280"
|
cellColor="#1f2937" // 얇은 선 (서브 그리드) - 매우 어두운 회색
|
||||||
sectionSize={10}
|
sectionSize={gridSize} // 타일 경계선 (5칸마다)
|
||||||
sectionThickness={1}
|
sectionThickness={1.5}
|
||||||
sectionColor="#374151"
|
sectionColor="#374151" // 타일 경계는 조금 밝게
|
||||||
fadeDistance={200}
|
fadeDistance={200}
|
||||||
fadeStrength={1}
|
fadeStrength={1}
|
||||||
followCamera={false}
|
followCamera={false}
|
||||||
|
|
@ -250,6 +377,9 @@ function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementD
|
||||||
orbitControlsRef.current.enabled = true;
|
orbitControlsRef.current.enabled = true;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
gridSize={gridSize}
|
||||||
|
allPlacements={placements}
|
||||||
|
onCollisionDetected={onCollisionDetected}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
@ -259,10 +389,14 @@ function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementD
|
||||||
enablePan={true}
|
enablePan={true}
|
||||||
enableZoom={true}
|
enableZoom={true}
|
||||||
enableRotate={true}
|
enableRotate={true}
|
||||||
minDistance={10}
|
minDistance={8}
|
||||||
maxDistance={200}
|
maxDistance={200}
|
||||||
maxPolarAngle={Math.PI / 2}
|
maxPolarAngle={Math.PI / 2}
|
||||||
enabled={!isDraggingAny}
|
enabled={!isDraggingAny}
|
||||||
|
reverseOrbit={true} // 드래그 방향 반전 (자연스러운 이동)
|
||||||
|
screenSpacePanning={true} // 화면 공간 패닝
|
||||||
|
panSpeed={0.8} // 패닝 속도 (기본값 1.0, 낮을수록 느림)
|
||||||
|
rotateSpeed={0.5} // 회전 속도
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -273,6 +407,8 @@ export default function Yard3DCanvas({
|
||||||
selectedPlacementId,
|
selectedPlacementId,
|
||||||
onPlacementClick,
|
onPlacementClick,
|
||||||
onPlacementDrag,
|
onPlacementDrag,
|
||||||
|
gridSize = 5,
|
||||||
|
onCollisionDetected,
|
||||||
}: Yard3DCanvasProps) {
|
}: Yard3DCanvasProps) {
|
||||||
const handleCanvasClick = (e: any) => {
|
const handleCanvasClick = (e: any) => {
|
||||||
// Canvas의 빈 공간을 클릭했을 때만 선택 해제
|
// Canvas의 빈 공간을 클릭했을 때만 선택 해제
|
||||||
|
|
@ -297,6 +433,8 @@ export default function Yard3DCanvas({
|
||||||
selectedPlacementId={selectedPlacementId}
|
selectedPlacementId={selectedPlacementId}
|
||||||
onPlacementClick={onPlacementClick}
|
onPlacementClick={onPlacementClick}
|
||||||
onPlacementDrag={onPlacementDrag}
|
onPlacementDrag={onPlacementDrag}
|
||||||
|
gridSize={gridSize}
|
||||||
|
onCollisionDetected={onCollisionDetected}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,11 @@ import { yardLayoutApi } from "@/lib/api/yardLayoutApi";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { YardLayout, YardPlacement } from "./types";
|
import { YardLayout, YardPlacement } from "./types";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
|
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
|
|
@ -33,6 +34,7 @@ interface YardEditorProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
const [placements, setPlacements] = useState<YardPlacement[]>([]);
|
const [placements, setPlacements] = useState<YardPlacement[]>([]);
|
||||||
const [originalPlacements, setOriginalPlacements] = useState<YardPlacement[]>([]); // 원본 데이터 보관
|
const [originalPlacements, setOriginalPlacements] = useState<YardPlacement[]>([]); // 원본 데이터 보관
|
||||||
const [selectedPlacement, setSelectedPlacement] = useState<YardPlacement | null>(null);
|
const [selectedPlacement, setSelectedPlacement] = useState<YardPlacement | null>(null);
|
||||||
|
|
@ -78,8 +80,89 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
||||||
loadPlacements();
|
loadPlacements();
|
||||||
}, [layout.id]);
|
}, [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 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 = {
|
const newPlacement: YardPlacement = {
|
||||||
id: nextPlacementId, // 임시 음수 ID
|
id: nextPlacementId, // 임시 음수 ID
|
||||||
yard_layout_id: layout.id,
|
yard_layout_id: layout.id,
|
||||||
|
|
@ -87,12 +170,13 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
||||||
material_name: null,
|
material_name: null,
|
||||||
quantity: null,
|
quantity: null,
|
||||||
unit: null,
|
unit: null,
|
||||||
position_x: 0,
|
// 그리드 칸의 중심에 배치 (Three.js Box position은 중심점)
|
||||||
position_y: 2.5,
|
position_x: centerX, // 칸 중심: 0→2.5, 5→7.5, 10→12.5...
|
||||||
position_z: 0,
|
position_y: appropriateY, // 쌓기 고려한 Y 위치
|
||||||
size_x: 5,
|
position_z: centerZ, // 칸 중심: 0→2.5, 5→7.5, 10→12.5...
|
||||||
size_y: 5,
|
size_x: gridSize,
|
||||||
size_z: 5,
|
size_y: gridSize,
|
||||||
|
size_z: gridSize,
|
||||||
color: "#9ca3af",
|
color: "#9ca3af",
|
||||||
data_source_type: null,
|
data_source_type: null,
|
||||||
data_source_config: null,
|
data_source_config: null,
|
||||||
|
|
@ -125,12 +209,62 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
||||||
setDeleteConfirmDialog({ open: true, placementId });
|
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 confirmDeletePlacement = () => {
|
||||||
const { placementId } = deleteConfirmDialog;
|
const { placementId } = deleteConfirmDialog;
|
||||||
if (placementId === null) return;
|
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) {
|
if (selectedPlacement?.id === placementId) {
|
||||||
setSelectedPlacement(null);
|
setSelectedPlacement(null);
|
||||||
setShowConfigPanel(false);
|
setShowConfigPanel(false);
|
||||||
|
|
@ -358,6 +492,13 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
||||||
selectedPlacementId={selectedPlacement?.id || null}
|
selectedPlacementId={selectedPlacement?.id || null}
|
||||||
onPlacementClick={(placement) => handleSelectPlacement(placement as YardPlacement)}
|
onPlacementClick={(placement) => handleSelectPlacement(placement as YardPlacement)}
|
||||||
onPlacementDrag={handlePlacementDrag}
|
onPlacementDrag={handlePlacementDrag}
|
||||||
|
onCollisionDetected={() => {
|
||||||
|
toast({
|
||||||
|
title: "배치 불가",
|
||||||
|
description: "해당 위치에 이미 다른 요소가 있습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
// 자동 새로고침 (30초마다)
|
// 자동 새로고침 (30초마다)
|
||||||
const interval = setInterval(loadData, 30000);
|
const interval = setInterval(loadData, 30000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [element]);
|
}, [element]);
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
|
|
@ -101,7 +102,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
query: groupByDS.query,
|
query: groupByDS.query,
|
||||||
connectionType: groupByDS.connectionType || "current",
|
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 labelColumn = columns[0];
|
||||||
const valueColumn = columns[1];
|
const valueColumn = columns[1];
|
||||||
|
|
||||||
const cards = rows.map((row) => ({
|
const cards = rows.map((row: any) => ({
|
||||||
label: String(row[labelColumn] || ""),
|
label: String(row[labelColumn] || ""),
|
||||||
value: parseFloat(row[valueColumn]) || 0,
|
value: parseFloat(row[valueColumn]) || 0,
|
||||||
}));
|
}));
|
||||||
|
|
@ -137,12 +138,12 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
method: groupByDS.method || "GET",
|
method: (groupByDS as any).method || "GET",
|
||||||
url: groupByDS.endpoint,
|
url: groupByDS.endpoint,
|
||||||
headers: groupByDS.headers || {},
|
headers: (groupByDS as any).headers || {},
|
||||||
body: groupByDS.body,
|
body: (groupByDS as any).body,
|
||||||
authType: groupByDS.authType,
|
authType: (groupByDS as any).authType,
|
||||||
authConfig: groupByDS.authConfig,
|
authConfig: (groupByDS as any).authConfig,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -169,7 +170,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
const labelColumn = columns[0];
|
const labelColumn = columns[0];
|
||||||
const valueColumn = columns[1];
|
const valueColumn = columns[1];
|
||||||
|
|
||||||
const cards = rows.map((row) => ({
|
const cards = rows.map((row: any) => ({
|
||||||
label: String(row[labelColumn] || ""),
|
label: String(row[labelColumn] || ""),
|
||||||
value: parseFloat(row[valueColumn]) || 0,
|
value: parseFloat(row[valueColumn]) || 0,
|
||||||
}));
|
}));
|
||||||
|
|
@ -201,7 +202,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
query: element.dataSource.query,
|
query: element.dataSource.query,
|
||||||
connectionType: element.dataSource.connectionType || "current",
|
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) {
|
if (result.success && result.data?.rows) {
|
||||||
const rows = result.data.rows;
|
const rows = result.data.rows;
|
||||||
|
|
||||||
const calculatedMetrics = element.customMetricConfig.metrics.map((metric) => {
|
const calculatedMetrics =
|
||||||
|
element.customMetricConfig?.metrics.map((metric) => {
|
||||||
const value = calculateMetric(rows, metric.field, metric.aggregation);
|
const value = calculateMetric(rows, metric.field, metric.aggregation);
|
||||||
return {
|
return {
|
||||||
...metric,
|
...metric,
|
||||||
calculatedValue: value,
|
calculatedValue: value,
|
||||||
};
|
};
|
||||||
});
|
}) || [];
|
||||||
|
|
||||||
setMetrics(calculatedMetrics);
|
setMetrics(calculatedMetrics);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -240,12 +242,12 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
method: element.dataSource.method || "GET",
|
method: (element.dataSource as any).method || "GET",
|
||||||
url: element.dataSource.endpoint,
|
url: element.dataSource.endpoint,
|
||||||
headers: element.dataSource.headers || {},
|
headers: (element.dataSource as any).headers || {},
|
||||||
body: element.dataSource.body,
|
body: (element.dataSource as any).body,
|
||||||
authType: element.dataSource.authType,
|
authType: (element.dataSource as any).authType,
|
||||||
authConfig: element.dataSource.authConfig,
|
authConfig: (element.dataSource as any).authConfig,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -278,13 +280,14 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
rows = [result.data];
|
rows = [result.data];
|
||||||
}
|
}
|
||||||
|
|
||||||
const calculatedMetrics = element.customMetricConfig.metrics.map((metric) => {
|
const calculatedMetrics =
|
||||||
|
element.customMetricConfig?.metrics.map((metric) => {
|
||||||
const value = calculateMetric(rows, metric.field, metric.aggregation);
|
const value = calculateMetric(rows, metric.field, metric.aggregation);
|
||||||
return {
|
return {
|
||||||
...metric,
|
...metric,
|
||||||
calculatedValue: value,
|
calculatedValue: value,
|
||||||
};
|
};
|
||||||
});
|
}) || [];
|
||||||
|
|
||||||
setMetrics(calculatedMetrics);
|
setMetrics(calculatedMetrics);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -351,7 +354,9 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
<li>• 선택한 컬럼의 데이터로 지표를 계산합니다</li>
|
<li>• 선택한 컬럼의 데이터로 지표를 계산합니다</li>
|
||||||
<li>• COUNT, SUM, AVG, MIN, MAX 등 집계 함수 지원</li>
|
<li>• COUNT, SUM, AVG, MIN, MAX 등 집계 함수 지원</li>
|
||||||
<li>• 사용자 정의 단위 설정 가능</li>
|
<li>• 사용자 정의 단위 설정 가능</li>
|
||||||
<li>• <strong>그룹별 카드 생성 모드</strong>로 간편하게 사용 가능</li>
|
<li>
|
||||||
|
• <strong>그룹별 카드 생성 모드</strong>로 간편하게 사용 가능
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 rounded-lg bg-blue-50 p-2 text-[10px] text-blue-700">
|
<div className="mt-2 rounded-lg bg-blue-50 p-2 text-[10px] text-blue-700">
|
||||||
|
|
@ -361,11 +366,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
? "SQL 쿼리를 입력하고 실행하세요 (지표 추가 불필요)"
|
? "SQL 쿼리를 입력하고 실행하세요 (지표 추가 불필요)"
|
||||||
: "SQL 쿼리를 입력하고 지표를 추가하세요"}
|
: "SQL 쿼리를 입력하고 지표를 추가하세요"}
|
||||||
</p>
|
</p>
|
||||||
{isGroupByMode && (
|
{isGroupByMode && <p className="text-[9px]">💡 첫 번째 컬럼: 카드 제목, 두 번째 컬럼: 카드 값</p>}
|
||||||
<p className="text-[9px]">
|
|
||||||
💡 첫 번째 컬럼: 카드 제목, 두 번째 컬럼: 카드 값
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -386,7 +387,10 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
const colors = colorMap[colorKey];
|
const colors = colorMap[colorKey];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`group-${index}`} className={`rounded-lg border ${colors.bg} ${colors.border} p-4 text-center`}>
|
<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="text-sm text-gray-600">{card.label}</div>
|
||||||
<div className={`mt-2 text-3xl font-bold ${colors.text}`}>{card.value.toLocaleString()}</div>
|
<div className={`mt-2 text-3xl font-bold ${colors.text}`}>{card.value.toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -416,7 +416,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
} flex h-[calc(100vh-3.5rem)] w-72 max-w-72 min-w-72 flex-col border-r border-slate-200 bg-white transition-transform duration-300`}
|
} flex h-[calc(100vh-3.5rem)] w-72 max-w-72 min-w-72 flex-col border-r border-slate-200 bg-white transition-transform duration-300`}
|
||||||
>
|
>
|
||||||
{/* 사이드바 상단 - Admin/User 모드 전환 버튼 (관리자만) */}
|
{/* 사이드바 상단 - Admin/User 모드 전환 버튼 (관리자만) */}
|
||||||
{(user as ExtendedUserInfo)?.userType === "admin" && (
|
{(user as ExtendedUserInfo)?.userType?.toLowerCase().includes("admin") && (
|
||||||
<div className="border-b border-slate-200 p-3">
|
<div className="border-b border-slate-200 p-3">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleModeSwitch}
|
onClick={handleModeSwitch}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue