Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map
This commit is contained in:
@@ -648,7 +648,14 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||||||
description: `${loadedObjects.length}개의 객체를 불러왔습니다.`,
|
description: `${loadedObjects.length}개의 객체를 불러왔습니다.`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Location 객체들의 자재 개수 로드
|
// Location 객체들의 자재 개수 로드 (직접 dbConnectionId와 materialTableName 전달)
|
||||||
|
const dbConnectionId = layout.external_db_connection_id;
|
||||||
|
const hierarchyConfigParsed =
|
||||||
|
typeof layout.hierarchy_config === "string"
|
||||||
|
? JSON.parse(layout.hierarchy_config)
|
||||||
|
: layout.hierarchy_config;
|
||||||
|
const materialTableName = hierarchyConfigParsed?.material?.tableName;
|
||||||
|
|
||||||
const locationObjects = loadedObjects.filter(
|
const locationObjects = loadedObjects.filter(
|
||||||
(obj) =>
|
(obj) =>
|
||||||
(obj.type === "location-bed" ||
|
(obj.type === "location-bed" ||
|
||||||
@@ -657,10 +664,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||||||
obj.type === "location-dest") &&
|
obj.type === "location-dest") &&
|
||||||
obj.locaKey,
|
obj.locaKey,
|
||||||
);
|
);
|
||||||
if (locationObjects.length > 0) {
|
if (locationObjects.length > 0 && dbConnectionId && materialTableName) {
|
||||||
const locaKeys = locationObjects.map((obj) => obj.locaKey!);
|
const locaKeys = locationObjects.map((obj) => obj.locaKey!);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
loadMaterialCountsForLocations(locaKeys);
|
loadMaterialCountsForLocations(locaKeys, dbConnectionId, materialTableName);
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1045,11 +1052,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Location별 자재 개수 로드 (locaKeys를 직접 받음)
|
// Location별 자재 개수 로드 (locaKeys를 직접 받음)
|
||||||
const loadMaterialCountsForLocations = async (locaKeys: string[]) => {
|
const loadMaterialCountsForLocations = async (locaKeys: string[], dbConnectionId?: number, materialTableName?: string) => {
|
||||||
if (!selectedDbConnection || locaKeys.length === 0) return;
|
const connectionId = dbConnectionId || selectedDbConnection;
|
||||||
|
const tableName = materialTableName || selectedTables.material;
|
||||||
|
if (!connectionId || locaKeys.length === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await getMaterialCounts(selectedDbConnection, selectedTables.material, locaKeys);
|
const response = await getMaterialCounts(connectionId, tableName, locaKeys);
|
||||||
|
console.log("📊 자재 개수 API 응답:", response);
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
// 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외)
|
// 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외)
|
||||||
setPlacedObjects((prev) =>
|
setPlacedObjects((prev) =>
|
||||||
@@ -1060,13 +1071,23 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||||||
) {
|
) {
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
const materialCount = response.data?.find((mc) => mc.LOCAKEY === obj.locaKey);
|
// 백엔드 응답 필드명: location_key, count (대소문자 모두 체크)
|
||||||
|
const materialCount = response.data?.find(
|
||||||
|
(mc: any) =>
|
||||||
|
mc.LOCAKEY === obj.locaKey ||
|
||||||
|
mc.location_key === obj.locaKey ||
|
||||||
|
mc.locakey === obj.locaKey
|
||||||
|
);
|
||||||
if (materialCount) {
|
if (materialCount) {
|
||||||
|
// count 또는 material_count 필드 사용
|
||||||
|
const count = materialCount.count || materialCount.material_count || 0;
|
||||||
|
const maxLayer = materialCount.max_layer || count;
|
||||||
|
console.log(`📊 ${obj.locaKey}: 자재 ${count}개`);
|
||||||
return {
|
return {
|
||||||
...obj,
|
...obj,
|
||||||
materialCount: materialCount.material_count,
|
materialCount: Number(count),
|
||||||
materialPreview: {
|
materialPreview: {
|
||||||
height: materialCount.max_layer * 1.5, // 층당 1.5 높이 (시각적)
|
height: maxLayer * 1.5, // 층당 1.5 높이 (시각적)
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,15 +54,17 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||||||
|
|
||||||
// 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원)
|
// 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원)
|
||||||
setLayoutName(layout.layout_name || layout.layoutName);
|
setLayoutName(layout.layout_name || layout.layoutName);
|
||||||
setExternalDbConnectionId(layout.external_db_connection_id || layout.externalDbConnectionId);
|
const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId;
|
||||||
|
setExternalDbConnectionId(dbConnectionId);
|
||||||
|
|
||||||
// hierarchy_config 저장
|
// hierarchy_config 저장
|
||||||
|
let hierarchyConfigData: any = null;
|
||||||
if (layout.hierarchy_config) {
|
if (layout.hierarchy_config) {
|
||||||
const config =
|
hierarchyConfigData =
|
||||||
typeof layout.hierarchy_config === "string"
|
typeof layout.hierarchy_config === "string"
|
||||||
? JSON.parse(layout.hierarchy_config)
|
? JSON.parse(layout.hierarchy_config)
|
||||||
: layout.hierarchy_config;
|
: layout.hierarchy_config;
|
||||||
setHierarchyConfig(config);
|
setHierarchyConfig(hierarchyConfigData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 객체 데이터 변환
|
// 객체 데이터 변환
|
||||||
@@ -103,6 +105,47 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||||||
});
|
});
|
||||||
|
|
||||||
setPlacedObjects(loadedObjects);
|
setPlacedObjects(loadedObjects);
|
||||||
|
|
||||||
|
// 외부 DB 연결이 있고 자재 설정이 있으면, 각 Location의 실제 자재 개수 조회
|
||||||
|
if (dbConnectionId && hierarchyConfigData?.material) {
|
||||||
|
const locationObjects = loadedObjects.filter(
|
||||||
|
(obj) =>
|
||||||
|
(obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
|
||||||
|
obj.locaKey
|
||||||
|
);
|
||||||
|
|
||||||
|
// 각 Location에 대해 자재 개수 조회 (병렬 처리)
|
||||||
|
const materialCountPromises = locationObjects.map(async (obj) => {
|
||||||
|
try {
|
||||||
|
const matResponse = await getMaterials(dbConnectionId, {
|
||||||
|
tableName: hierarchyConfigData.material.tableName,
|
||||||
|
keyColumn: hierarchyConfigData.material.keyColumn,
|
||||||
|
locationKeyColumn: hierarchyConfigData.material.locationKeyColumn,
|
||||||
|
layerColumn: hierarchyConfigData.material.layerColumn,
|
||||||
|
locaKey: obj.locaKey!,
|
||||||
|
});
|
||||||
|
if (matResponse.success && matResponse.data) {
|
||||||
|
return { id: obj.id, count: matResponse.data.length };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`자재 개수 조회 실패 (${obj.locaKey}):`, e);
|
||||||
|
}
|
||||||
|
return { id: obj.id, count: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
const materialCounts = await Promise.all(materialCountPromises);
|
||||||
|
|
||||||
|
// materialCount 업데이트
|
||||||
|
setPlacedObjects((prev) =>
|
||||||
|
prev.map((obj) => {
|
||||||
|
const countData = materialCounts.find((m) => m.id === obj.id);
|
||||||
|
if (countData && countData.count > 0) {
|
||||||
|
return { ...obj, materialCount: countData.count };
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(response.error || "레이아웃 조회 실패");
|
throw new Error(response.error || "레이아웃 조회 실패");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Canvas, useThree } from "@react-three/fiber";
|
import { Canvas, useThree } from "@react-three/fiber";
|
||||||
import { OrbitControls, Grid, Box, Text } from "@react-three/drei";
|
import { OrbitControls, Box, Text } from "@react-three/drei";
|
||||||
import { Suspense, useRef, useState, useEffect, useMemo } from "react";
|
import { Suspense, useRef, useState, useEffect, useMemo } from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
|
||||||
@@ -525,68 +525,77 @@ function MaterialBox({
|
|||||||
case "location-bed":
|
case "location-bed":
|
||||||
case "location-temp":
|
case "location-temp":
|
||||||
case "location-dest":
|
case "location-dest":
|
||||||
// 베드 타입 Location: 초록색 상자
|
// 베드 타입 Location: 회색 철판들이 데이터 개수만큼 쌓이는 형태
|
||||||
|
const locPlateCount = placement.material_count || placement.quantity || 5; // 데이터 개수
|
||||||
|
const locVisiblePlateCount = locPlateCount; // 데이터 개수만큼 모두 렌더링
|
||||||
|
const locPlateThickness = 0.15; // 각 철판 두께
|
||||||
|
const locPlateGap = 0.03; // 철판 사이 미세한 간격
|
||||||
|
// 실제 렌더링되는 폴리곤 기준으로 높이 계산
|
||||||
|
const locVisibleStackHeight = locVisiblePlateCount * (locPlateThickness + locPlateGap);
|
||||||
|
// 그룹의 position_y를 상쇄해서 바닥(y=0)부터 시작하도록
|
||||||
|
const locYOffset = -placement.position_y;
|
||||||
|
const locPlateBaseY = locYOffset + locPlateThickness / 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box args={[boxWidth, boxHeight, boxDepth]}>
|
{/* 철판 스택 - 데이터 개수만큼 회색 판 쌓기 (최대 20개) */}
|
||||||
<meshStandardMaterial
|
{Array.from({ length: locVisiblePlateCount }).map((_, idx) => {
|
||||||
color={placement.color}
|
const yPos = locPlateBaseY + idx * (locPlateThickness + locPlateGap);
|
||||||
roughness={0.5}
|
// 약간의 랜덤 오프셋으로 자연스러움 추가
|
||||||
metalness={0.3}
|
const xOffset = (Math.sin(idx * 0.5) * 0.02);
|
||||||
emissive={isSelected ? placement.color : "#000000"}
|
const zOffset = (Math.cos(idx * 0.7) * 0.02);
|
||||||
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
|
|
||||||
/>
|
return (
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 대표 자재 스택 (자재가 있을 때만) */}
|
|
||||||
{placement.material_count !== undefined &&
|
|
||||||
placement.material_count > 0 &&
|
|
||||||
placement.material_preview_height && (
|
|
||||||
<Box
|
<Box
|
||||||
args={[boxWidth * 0.7, placement.material_preview_height, boxDepth * 0.7]}
|
key={`loc-plate-${idx}`}
|
||||||
position={[0, boxHeight / 2 + placement.material_preview_height / 2, 0]}
|
args={[boxWidth, locPlateThickness, boxDepth]}
|
||||||
|
position={[xOffset, yPos, zOffset]}
|
||||||
>
|
>
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
color="#ef4444"
|
color="#6b7280" // 회색 (고정)
|
||||||
roughness={0.6}
|
roughness={0.4}
|
||||||
metalness={0.2}
|
metalness={0.7}
|
||||||
emissive={isSelected ? "#ef4444" : "#000000"}
|
emissive={isSelected ? "#9ca3af" : "#000000"}
|
||||||
emissiveIntensity={isSelected ? glowIntensity * 0.3 : 0}
|
emissiveIntensity={isSelected ? glowIntensity * 0.3 : 0}
|
||||||
transparent
|
|
||||||
opacity={0.7}
|
|
||||||
/>
|
/>
|
||||||
|
{/* 각 철판 외곽선 */}
|
||||||
|
<lineSegments>
|
||||||
|
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth, locPlateThickness, boxDepth)]} />
|
||||||
|
<lineBasicMaterial color="#374151" opacity={0.8} transparent />
|
||||||
|
</lineSegments>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
);
|
||||||
|
})}
|
||||||
{/* Location 이름 */}
|
|
||||||
|
{/* Location 이름 - 실제 폴리곤 높이 기준, 뒤쪽(+Z)에 배치 */}
|
||||||
{placement.name && (
|
{placement.name && (
|
||||||
<Text
|
<Text
|
||||||
position={[0, boxHeight / 2 + 0.3, 0]}
|
position={[0, locYOffset + locVisibleStackHeight + 0.3, boxDepth * 0.3]}
|
||||||
rotation={[-Math.PI / 2, 0, 0]}
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
|
fontSize={Math.min(boxWidth, boxDepth) * 0.18}
|
||||||
color="#ffffff"
|
color="#374151"
|
||||||
anchorX="center"
|
anchorX="center"
|
||||||
anchorY="middle"
|
anchorY="middle"
|
||||||
outlineWidth={0.03}
|
outlineWidth={0.03}
|
||||||
outlineColor="#000000"
|
outlineColor="#ffffff"
|
||||||
>
|
>
|
||||||
{placement.name}
|
{placement.name}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 자재 개수 */}
|
{/* 수량 표시 텍스트 - 실제 폴리곤 높이 기준, 앞쪽(-Z)에 배치 */}
|
||||||
{placement.material_count !== undefined && placement.material_count > 0 && (
|
{locPlateCount > 0 && (
|
||||||
<Text
|
<Text
|
||||||
position={[0, boxHeight / 2 + 0.6, 0]}
|
position={[0, locYOffset + locVisibleStackHeight + 0.3, -boxDepth * 0.3]}
|
||||||
rotation={[-Math.PI / 2, 0, 0]}
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
fontSize={Math.min(boxWidth, boxDepth) * 0.12}
|
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
|
||||||
color="#fbbf24"
|
color="#1f2937"
|
||||||
anchorX="center"
|
anchorX="center"
|
||||||
anchorY="middle"
|
anchorY="middle"
|
||||||
outlineWidth={0.03}
|
outlineWidth={0.02}
|
||||||
outlineColor="#000000"
|
outlineColor="#ffffff"
|
||||||
>
|
>
|
||||||
{`자재: ${placement.material_count}개`}
|
{`${locPlateCount}장`}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -886,83 +895,79 @@ function MaterialBox({
|
|||||||
|
|
||||||
case "plate-stack":
|
case "plate-stack":
|
||||||
default:
|
default:
|
||||||
// 후판 스택: 팔레트 + 박스 (기존 렌더링)
|
// 후판 스택: 회색 철판들이 데이터 개수만큼 쌓이는 형태
|
||||||
|
const plateCount = placement.material_count || placement.quantity || 5; // 데이터 개수 (기본 5장)
|
||||||
|
const visiblePlateCount = plateCount; // 데이터 개수만큼 모두 렌더링
|
||||||
|
const plateThickness = 0.15; // 각 철판 두께
|
||||||
|
const plateGap = 0.03; // 철판 사이 미세한 간격
|
||||||
|
// 실제 렌더링되는 폴리곤 기준으로 높이 계산
|
||||||
|
const visibleStackHeight = visiblePlateCount * (plateThickness + plateGap);
|
||||||
|
// 그룹의 position_y를 상쇄해서 바닥(y=0)부터 시작하도록
|
||||||
|
const yOffset = -placement.position_y;
|
||||||
|
const plateBaseY = yOffset + plateThickness / 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 팔레트 그룹 - 박스 하단에 붙어있도록 */}
|
{/* 철판 스택 - 데이터 개수만큼 회색 판 쌓기 (최대 20개) */}
|
||||||
<group position={[0, palletYOffset, 0]}>
|
{Array.from({ length: visiblePlateCount }).map((_, idx) => {
|
||||||
{/* 상단 가로 판자들 (5개) */}
|
const yPos = plateBaseY + idx * (plateThickness + plateGap);
|
||||||
{[-boxDepth * 0.4, -boxDepth * 0.2, 0, boxDepth * 0.2, boxDepth * 0.4].map((zOffset, idx) => (
|
// 약간의 랜덤 오프셋으로 자연스러움 추가 (실제 철판처럼)
|
||||||
|
const xOffset = (Math.sin(idx * 0.5) * 0.02);
|
||||||
|
const zOffset = (Math.cos(idx * 0.7) * 0.02);
|
||||||
|
|
||||||
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={`top-${idx}`}
|
key={`plate-${idx}`}
|
||||||
args={[boxWidth * 0.95, palletHeight * 0.3, boxDepth * 0.15]}
|
args={[boxWidth, plateThickness, boxDepth]}
|
||||||
position={[0, palletHeight * 0.35, zOffset]}
|
position={[xOffset, yPos, zOffset]}
|
||||||
>
|
>
|
||||||
<meshStandardMaterial color="#8B4513" roughness={0.95} metalness={0.0} />
|
<meshStandardMaterial
|
||||||
|
color="#6b7280" // 회색 (고정)
|
||||||
|
roughness={0.4}
|
||||||
|
metalness={0.7}
|
||||||
|
emissive={isSelected ? "#9ca3af" : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.3 : 0}
|
||||||
|
/>
|
||||||
|
{/* 각 철판 외곽선 */}
|
||||||
<lineSegments>
|
<lineSegments>
|
||||||
<edgesGeometry
|
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth, plateThickness, boxDepth)]} />
|
||||||
args={[new THREE.BoxGeometry(boxWidth * 0.95, palletHeight * 0.3, boxDepth * 0.15)]}
|
<lineBasicMaterial color="#374151" opacity={0.8} transparent />
|
||||||
/>
|
|
||||||
<lineBasicMaterial color="#000000" opacity={0.3} transparent />
|
|
||||||
</lineSegments>
|
</lineSegments>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{/* 중간 세로 받침대 (3개) */}
|
|
||||||
{[-boxWidth * 0.35, 0, boxWidth * 0.35].map((xOffset, idx) => (
|
{/* 수량 표시 텍스트 (상단) - 앞쪽(-Z)에 배치 */}
|
||||||
<Box
|
{plateCount > 0 && (
|
||||||
key={`middle-${idx}`}
|
<Text
|
||||||
args={[boxWidth * 0.12, palletHeight * 0.4, boxDepth * 0.2]}
|
position={[0, yOffset + visibleStackHeight + 0.3, -boxDepth * 0.3]}
|
||||||
position={[xOffset, 0, 0]}
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
>
|
fontSize={Math.min(boxWidth, boxDepth) * 0.18}
|
||||||
<meshStandardMaterial color="#654321" roughness={0.98} metalness={0.0} />
|
color="#374151"
|
||||||
<lineSegments>
|
anchorX="center"
|
||||||
<edgesGeometry
|
anchorY="middle"
|
||||||
args={[new THREE.BoxGeometry(boxWidth * 0.12, palletHeight * 0.4, boxDepth * 0.2)]}
|
outlineWidth={0.03}
|
||||||
/>
|
outlineColor="#ffffff"
|
||||||
<lineBasicMaterial color="#000000" opacity={0.4} transparent />
|
>
|
||||||
</lineSegments>
|
{`${plateCount}장`}
|
||||||
</Box>
|
</Text>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
{/* 하단 가로 판자들 (3개) */}
|
{/* 자재명 표시 (있는 경우) - 뒤쪽(+Z)에 배치 */}
|
||||||
{[-boxDepth * 0.3, 0, boxDepth * 0.3].map((zOffset, idx) => (
|
{placement.material_name && (
|
||||||
<Box
|
<Text
|
||||||
key={`bottom-${idx}`}
|
position={[0, yOffset + visibleStackHeight + 0.3, boxDepth * 0.3]}
|
||||||
args={[boxWidth * 0.95, palletHeight * 0.25, boxDepth * 0.18]}
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
position={[0, -palletHeight * 0.35, zOffset]}
|
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
|
||||||
>
|
color="#1f2937"
|
||||||
<meshStandardMaterial color="#6B4423" roughness={0.97} metalness={0.0} />
|
anchorX="center"
|
||||||
<lineSegments>
|
anchorY="middle"
|
||||||
<edgesGeometry
|
outlineWidth={0.02}
|
||||||
args={[new THREE.BoxGeometry(boxWidth * 0.95, palletHeight * 0.25, boxDepth * 0.18)]}
|
outlineColor="#ffffff"
|
||||||
/>
|
>
|
||||||
<lineBasicMaterial color="#000000" opacity={0.3} transparent />
|
{placement.material_name}
|
||||||
</lineSegments>
|
</Text>
|
||||||
</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="#000000" opacity={0.6} transparent linewidth={1.5} />
|
|
||||||
</lineSegments>
|
|
||||||
</Box>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1114,20 +1119,11 @@ function Scene({
|
|||||||
{/* 배경색 */}
|
{/* 배경색 */}
|
||||||
<color attach="background" args={["#f3f4f6"]} />
|
<color attach="background" args={["#f3f4f6"]} />
|
||||||
|
|
||||||
{/* 바닥 그리드 (타일을 4등분) */}
|
{/* 바닥 - 단색 평면 (그리드 제거) */}
|
||||||
<Grid
|
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.01, 0]}>
|
||||||
args={[100, 100]}
|
<planeGeometry args={[200, 200]} />
|
||||||
cellSize={gridSize / 2} // 타일을 2x2로 나눔 (2.5칸)
|
<meshStandardMaterial color="#e5e7eb" roughness={0.9} metalness={0.1} />
|
||||||
cellThickness={0.6}
|
</mesh>
|
||||||
cellColor="#d1d5db" // 얇은 선 (서브 그리드) - 밝은 회색
|
|
||||||
sectionSize={gridSize} // 타일 경계선 (5칸마다)
|
|
||||||
sectionThickness={1.5}
|
|
||||||
sectionColor="#6b7280" // 타일 경계는 조금 어둡게
|
|
||||||
fadeDistance={200}
|
|
||||||
fadeStrength={1}
|
|
||||||
followCamera={false}
|
|
||||||
infiniteGrid={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 자재 박스들 */}
|
{/* 자재 박스들 */}
|
||||||
{placements.map((placement) => (
|
{placements.map((placement) => (
|
||||||
|
|||||||
@@ -1953,6 +1953,139 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 🆕 버튼 활성화 조건 설정 */}
|
||||||
|
<div className="mt-4 border-t pt-4">
|
||||||
|
<h5 className="mb-3 text-xs font-medium text-muted-foreground">버튼 활성화 조건</h5>
|
||||||
|
|
||||||
|
{/* 출발지/도착지 필수 체크 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="require-location">출발지/도착지 필수</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">선택하지 않으면 버튼 비활성화</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="require-location"
|
||||||
|
checked={config.action?.requireLocationFields === true}
|
||||||
|
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.requireLocationFields", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.action?.requireLocationFields && (
|
||||||
|
<div className="mt-3 space-y-2 rounded-md bg-orange-50 p-3 dark:bg-orange-950">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label>출발지 필드명</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="departure"
|
||||||
|
value={config.action?.trackingDepartureField || "departure"}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.action.trackingDepartureField", e.target.value)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>도착지 필드명</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="destination"
|
||||||
|
value={config.action?.trackingArrivalField || "destination"}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.action.trackingArrivalField", e.target.value)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 상태 기반 활성화 조건 */}
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="enable-on-status">상태 기반 활성화</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">특정 상태일 때만 버튼 활성화</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="enable-on-status"
|
||||||
|
checked={config.action?.enableOnStatusCheck === true}
|
||||||
|
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.enableOnStatusCheck", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.action?.enableOnStatusCheck && (
|
||||||
|
<div className="mt-3 space-y-2 rounded-md bg-purple-50 p-3 dark:bg-purple-950">
|
||||||
|
<div>
|
||||||
|
<Label>상태 조회 테이블</Label>
|
||||||
|
<Select
|
||||||
|
value={config.action?.statusCheckTableName || "vehicles"}
|
||||||
|
onValueChange={(value) => onUpdateProperty("componentConfig.action.statusCheckTableName", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableTables.map((table) => (
|
||||||
|
<SelectItem key={table.name} value={table.name} className="text-xs">
|
||||||
|
{table.label || table.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||||
|
상태를 조회할 테이블 (기본: vehicles)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>조회 키 필드</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="user_id"
|
||||||
|
value={config.action?.statusCheckKeyField || "user_id"}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.action.statusCheckKeyField", e.target.value)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||||
|
현재 로그인 사용자 ID로 조회할 필드 (기본: user_id)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>상태 컬럼명</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="status"
|
||||||
|
value={config.action?.statusCheckField || "status"}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.action.statusCheckField", e.target.value)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||||
|
상태 값이 저장된 컬럼명 (기본: status)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>상태 조건</Label>
|
||||||
|
<Select
|
||||||
|
value={config.action?.statusConditionType || "enableOn"}
|
||||||
|
onValueChange={(value) => onUpdateProperty("componentConfig.action.statusConditionType", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="enableOn">이 상태일 때만 활성화</SelectItem>
|
||||||
|
<SelectItem value="disableOn">이 상태일 때 비활성화</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>상태값 (쉼표로 구분)</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="예: active, inactive"
|
||||||
|
value={config.action?.statusConditionValues || ""}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.action.statusConditionValues", e.target.value)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||||
|
여러 상태값은 쉼표(,)로 구분
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
|
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
|
||||||
<p className="text-xs text-blue-900 dark:text-blue-100">
|
<p className="text-xs text-blue-900 dark:text-blue-100">
|
||||||
<strong>사용 예시:</strong>
|
<strong>사용 예시:</strong>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
|||||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||||
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||||
import { applyMappingRules } from "@/lib/utils/dataMapping";
|
import { applyMappingRules } from "@/lib/utils/dataMapping";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
||||||
config?: ButtonPrimaryConfig;
|
config?: ButtonPrimaryConfig;
|
||||||
@@ -148,6 +149,149 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||||||
return result;
|
return result;
|
||||||
}, [flowConfig, currentStep, component.id, component.label]);
|
}, [flowConfig, currentStep, component.id, component.label]);
|
||||||
|
|
||||||
|
// 🆕 운행알림 버튼 조건부 비활성화 (출발지/도착지 필수, 상태 체크)
|
||||||
|
// 상태는 API로 조회 (formData에 없는 경우)
|
||||||
|
const [vehicleStatus, setVehicleStatus] = useState<string | null>(null);
|
||||||
|
const [statusLoading, setStatusLoading] = useState(false);
|
||||||
|
|
||||||
|
// 상태 조회 (operation_control + enableOnStatusCheck일 때)
|
||||||
|
const actionConfig = component.componentConfig?.action;
|
||||||
|
const shouldFetchStatus = actionConfig?.type === "operation_control" && actionConfig?.enableOnStatusCheck && userId;
|
||||||
|
const statusTableName = actionConfig?.statusCheckTableName || "vehicles";
|
||||||
|
const statusKeyField = actionConfig?.statusCheckKeyField || "user_id";
|
||||||
|
const statusFieldName = actionConfig?.statusCheckField || "status";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldFetchStatus) return;
|
||||||
|
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const fetchStatus = async () => {
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(`/table-management/tables/${statusTableName}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 1,
|
||||||
|
search: { [statusKeyField]: userId },
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
const rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || [];
|
||||||
|
const firstRow = Array.isArray(rows) ? rows[0] : null;
|
||||||
|
|
||||||
|
if (response.data?.success && firstRow) {
|
||||||
|
const newStatus = firstRow[statusFieldName];
|
||||||
|
if (newStatus !== vehicleStatus) {
|
||||||
|
// console.log("🔄 [ButtonPrimary] 상태 변경 감지:", { 이전: vehicleStatus, 현재: newStatus, buttonLabel: component.label });
|
||||||
|
}
|
||||||
|
setVehicleStatus(newStatus);
|
||||||
|
} else {
|
||||||
|
setVehicleStatus(null);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// console.error("❌ [ButtonPrimary] 상태 조회 오류:", error?.message);
|
||||||
|
if (isMounted) setVehicleStatus(null);
|
||||||
|
} finally {
|
||||||
|
if (isMounted) setStatusLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 즉시 실행
|
||||||
|
setStatusLoading(true);
|
||||||
|
fetchStatus();
|
||||||
|
|
||||||
|
// 2초마다 갱신
|
||||||
|
const interval = setInterval(fetchStatus, 2000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [shouldFetchStatus, statusTableName, statusKeyField, statusFieldName, userId, component.label]);
|
||||||
|
|
||||||
|
// 버튼 비활성화 조건 계산
|
||||||
|
const isOperationButtonDisabled = useMemo(() => {
|
||||||
|
const actionConfig = component.componentConfig?.action;
|
||||||
|
|
||||||
|
if (actionConfig?.type !== "operation_control") return false;
|
||||||
|
|
||||||
|
// 1. 출발지/도착지 필수 체크
|
||||||
|
if (actionConfig?.requireLocationFields) {
|
||||||
|
const departureField = actionConfig.trackingDepartureField || "departure";
|
||||||
|
const destinationField = actionConfig.trackingArrivalField || "destination";
|
||||||
|
|
||||||
|
const departure = formData?.[departureField];
|
||||||
|
const destination = formData?.[destinationField];
|
||||||
|
|
||||||
|
// console.log("🔍 [ButtonPrimary] 출발지/도착지 체크:", {
|
||||||
|
// departureField, destinationField, departure, destination,
|
||||||
|
// buttonLabel: component.label
|
||||||
|
// });
|
||||||
|
|
||||||
|
if (!departure || departure === "" || !destination || destination === "") {
|
||||||
|
// console.log("🚫 [ButtonPrimary] 출발지/도착지 미선택 → 비활성화:", component.label);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 상태 기반 활성화 조건 (API로 조회한 vehicleStatus 우선 사용)
|
||||||
|
if (actionConfig?.enableOnStatusCheck) {
|
||||||
|
const statusField = actionConfig.statusCheckField || "status";
|
||||||
|
// API 조회 결과를 우선 사용 (실시간 DB 상태 반영)
|
||||||
|
const currentStatus = vehicleStatus || formData?.[statusField];
|
||||||
|
|
||||||
|
const conditionType = actionConfig.statusConditionType || "enableOn";
|
||||||
|
const conditionValues = (actionConfig.statusConditionValues || "")
|
||||||
|
.split(",")
|
||||||
|
.map((v: string) => v.trim())
|
||||||
|
.filter((v: string) => v);
|
||||||
|
|
||||||
|
// console.log("🔍 [ButtonPrimary] 상태 조건 체크:", {
|
||||||
|
// statusField,
|
||||||
|
// formDataStatus: formData?.[statusField],
|
||||||
|
// apiStatus: vehicleStatus,
|
||||||
|
// currentStatus,
|
||||||
|
// conditionType,
|
||||||
|
// conditionValues,
|
||||||
|
// buttonLabel: component.label,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// 상태 로딩 중이면 비활성화
|
||||||
|
if (statusLoading) {
|
||||||
|
// console.log("⏳ [ButtonPrimary] 상태 로딩 중 → 비활성화:", component.label);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태값이 없으면 → 비활성화 (조건 확인 불가)
|
||||||
|
if (!currentStatus) {
|
||||||
|
// console.log("🚫 [ButtonPrimary] 상태값 없음 → 비활성화:", component.label);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditionValues.length > 0) {
|
||||||
|
if (conditionType === "enableOn") {
|
||||||
|
// 이 상태일 때만 활성화
|
||||||
|
if (!conditionValues.includes(currentStatus)) {
|
||||||
|
// console.log(`🚫 [ButtonPrimary] 상태 ${currentStatus} ∉ [${conditionValues}] → 비활성화:`, component.label);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (conditionType === "disableOn") {
|
||||||
|
// 이 상태일 때 비활성화
|
||||||
|
if (conditionValues.includes(currentStatus)) {
|
||||||
|
// console.log(`🚫 [ButtonPrimary] 상태 ${currentStatus} ∈ [${conditionValues}] → 비활성화:`, component.label);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// console.log("✅ [ButtonPrimary] 버튼 활성화:", component.label);
|
||||||
|
return false;
|
||||||
|
}, [component.componentConfig?.action, formData, vehicleStatus, statusLoading, component.label]);
|
||||||
|
|
||||||
// 확인 다이얼로그 상태
|
// 확인 다이얼로그 상태
|
||||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||||
const [pendingAction, setPendingAction] = useState<{
|
const [pendingAction, setPendingAction] = useState<{
|
||||||
@@ -877,6 +1021,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 최종 비활성화 상태 (설정 + 조건부 비활성화)
|
||||||
|
const finalDisabled = componentConfig.disabled || isOperationButtonDisabled || statusLoading;
|
||||||
|
|
||||||
// 공통 버튼 스타일
|
// 공통 버튼 스타일
|
||||||
const buttonElementStyle: React.CSSProperties = {
|
const buttonElementStyle: React.CSSProperties = {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -884,12 +1031,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||||||
minHeight: "40px",
|
minHeight: "40px",
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: "0.5rem",
|
borderRadius: "0.5rem",
|
||||||
background: componentConfig.disabled ? "#e5e7eb" : buttonColor,
|
background: finalDisabled ? "#e5e7eb" : buttonColor,
|
||||||
color: componentConfig.disabled ? "#9ca3af" : "white",
|
color: finalDisabled ? "#9ca3af" : "white",
|
||||||
// 🔧 크기 설정 적용 (sm/md/lg)
|
// 🔧 크기 설정 적용 (sm/md/lg)
|
||||||
fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem",
|
fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
cursor: componentConfig.disabled ? "not-allowed" : "pointer",
|
cursor: finalDisabled ? "not-allowed" : "pointer",
|
||||||
outline: "none",
|
outline: "none",
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -900,7 +1047,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||||||
componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem",
|
componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem",
|
||||||
margin: "0",
|
margin: "0",
|
||||||
lineHeight: "1.25",
|
lineHeight: "1.25",
|
||||||
boxShadow: componentConfig.disabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
boxShadow: finalDisabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||||
// 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height 제외)
|
// 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height 제외)
|
||||||
...(component.style ? Object.fromEntries(
|
...(component.style ? Object.fromEntries(
|
||||||
Object.entries(component.style).filter(([key]) => key !== 'width' && key !== 'height')
|
Object.entries(component.style).filter(([key]) => key !== 'width' && key !== 'height')
|
||||||
@@ -925,7 +1072,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||||||
// 일반 모드: button으로 렌더링
|
// 일반 모드: button으로 렌더링
|
||||||
<button
|
<button
|
||||||
type={componentConfig.actionType || "button"}
|
type={componentConfig.actionType || "button"}
|
||||||
disabled={componentConfig.disabled || false}
|
disabled={finalDisabled}
|
||||||
className="transition-colors duration-150 hover:opacity-90 active:scale-95 transition-transform"
|
className="transition-colors duration-150 hover:opacity-90 active:scale-95 transition-transform"
|
||||||
style={buttonElementStyle}
|
style={buttonElementStyle}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|||||||
@@ -3613,6 +3613,112 @@ export class ButtonActionExecutor {
|
|||||||
|
|
||||||
await this.saveLocationToHistory(tripId, departure, arrival, departureName, destinationName, vehicleId, "completed");
|
await this.saveLocationToHistory(tripId, departure, arrival, departureName, destinationName, vehicleId, "completed");
|
||||||
|
|
||||||
|
// 🆕 거리/시간 계산 및 저장
|
||||||
|
if (tripId) {
|
||||||
|
try {
|
||||||
|
const tripStats = await this.calculateTripStats(tripId);
|
||||||
|
console.log("📊 운행 통계:", tripStats);
|
||||||
|
|
||||||
|
// 운행 통계를 두 테이블에 저장
|
||||||
|
if (tripStats) {
|
||||||
|
const distanceMeters = Math.round(tripStats.totalDistanceKm * 1000); // km → m
|
||||||
|
const timeMinutes = tripStats.totalTimeMinutes;
|
||||||
|
const userId = this.trackingUserId || context.userId;
|
||||||
|
|
||||||
|
console.log("💾 운행 통계 DB 저장 시도:", {
|
||||||
|
tripId,
|
||||||
|
userId,
|
||||||
|
distanceMeters,
|
||||||
|
timeMinutes,
|
||||||
|
startTime: tripStats.startTime,
|
||||||
|
endTime: tripStats.endTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
|
|
||||||
|
// 1️⃣ vehicle_location_history 마지막 레코드에 통계 저장 (이력용)
|
||||||
|
try {
|
||||||
|
const lastRecordResponse = await apiClient.post(`/table-management/tables/vehicle_location_history/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 1,
|
||||||
|
search: { trip_id: tripId },
|
||||||
|
sortBy: "recorded_at",
|
||||||
|
sortOrder: "desc",
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastRecordData = lastRecordResponse.data?.data?.data || lastRecordResponse.data?.data?.rows || [];
|
||||||
|
if (lastRecordData.length > 0) {
|
||||||
|
const lastRecordId = lastRecordData[0].id;
|
||||||
|
console.log("📍 마지막 레코드 ID:", lastRecordId);
|
||||||
|
|
||||||
|
const historyUpdates = [
|
||||||
|
{ field: "trip_distance", value: distanceMeters },
|
||||||
|
{ field: "trip_time", value: timeMinutes },
|
||||||
|
{ field: "trip_start", value: tripStats.startTime },
|
||||||
|
{ field: "trip_end", value: tripStats.endTime },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const update of historyUpdates) {
|
||||||
|
await apiClient.put(`/dynamic-form/update-field`, {
|
||||||
|
tableName: "vehicle_location_history",
|
||||||
|
keyField: "id",
|
||||||
|
keyValue: lastRecordId,
|
||||||
|
updateField: update.field,
|
||||||
|
updateValue: update.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log("✅ vehicle_location_history 통계 저장 완료");
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ trip_id에 해당하는 레코드를 찾을 수 없음:", tripId);
|
||||||
|
}
|
||||||
|
} catch (historyError) {
|
||||||
|
console.warn("⚠️ vehicle_location_history 저장 실패:", historyError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2️⃣ vehicles 테이블에도 마지막 운행 통계 업데이트 (최신 정보용)
|
||||||
|
if (userId) {
|
||||||
|
try {
|
||||||
|
const vehicleUpdates = [
|
||||||
|
{ field: "last_trip_distance", value: distanceMeters },
|
||||||
|
{ field: "last_trip_time", value: timeMinutes },
|
||||||
|
{ field: "last_trip_start", value: tripStats.startTime },
|
||||||
|
{ field: "last_trip_end", value: tripStats.endTime },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const update of vehicleUpdates) {
|
||||||
|
await apiClient.put(`/dynamic-form/update-field`, {
|
||||||
|
tableName: "vehicles",
|
||||||
|
keyField: "user_id",
|
||||||
|
keyValue: userId,
|
||||||
|
updateField: update.field,
|
||||||
|
updateValue: update.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log("✅ vehicles 테이블 통계 업데이트 완료");
|
||||||
|
} catch (vehicleError) {
|
||||||
|
console.warn("⚠️ vehicles 테이블 저장 실패:", vehicleError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이벤트로 통계 전달 (UI에서 표시용)
|
||||||
|
window.dispatchEvent(new CustomEvent("tripCompleted", {
|
||||||
|
detail: {
|
||||||
|
tripId,
|
||||||
|
totalDistanceKm: tripStats.totalDistanceKm,
|
||||||
|
totalTimeMinutes: tripStats.totalTimeMinutes,
|
||||||
|
startTime: tripStats.startTime,
|
||||||
|
endTime: tripStats.endTime,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
toast.success(`운행 종료! 총 ${tripStats.totalDistanceKm.toFixed(1)}km, ${tripStats.totalTimeMinutes}분 소요`);
|
||||||
|
}
|
||||||
|
} catch (statsError) {
|
||||||
|
console.warn("⚠️ 운행 통계 계산 실패:", statsError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 상태 변경 (vehicles 테이블 등)
|
// 상태 변경 (vehicles 테이블 등)
|
||||||
const effectiveConfig = config.trackingStatusOnStop ? config : this.trackingConfig;
|
const effectiveConfig = config.trackingStatusOnStop ? config : this.trackingConfig;
|
||||||
const effectiveContext = context.userId ? context : this.trackingContext;
|
const effectiveContext = context.userId ? context : this.trackingContext;
|
||||||
@@ -3662,6 +3768,104 @@ export class ButtonActionExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운행 통계 계산 (거리, 시간)
|
||||||
|
*/
|
||||||
|
private static async calculateTripStats(tripId: string): Promise<{
|
||||||
|
totalDistanceKm: number;
|
||||||
|
totalTimeMinutes: number;
|
||||||
|
startTime: string | null;
|
||||||
|
endTime: string | null;
|
||||||
|
} | null> {
|
||||||
|
try {
|
||||||
|
// vehicle_location_history에서 해당 trip의 모든 위치 조회
|
||||||
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
|
|
||||||
|
const response = await apiClient.post(`/table-management/tables/vehicle_location_history/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 10000,
|
||||||
|
search: { trip_id: tripId },
|
||||||
|
sortBy: "recorded_at",
|
||||||
|
sortOrder: "asc",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.data?.success) {
|
||||||
|
console.log("📊 통계 계산: API 응답 실패");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 응답 형식: data.data.data 또는 data.data.rows
|
||||||
|
const rows = response.data?.data?.data || response.data?.data?.rows || [];
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
console.log("📊 통계 계산: 데이터 없음");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const locations = rows;
|
||||||
|
console.log(`📊 통계 계산: ${locations.length}개 위치 데이터`);
|
||||||
|
|
||||||
|
// 시간 계산
|
||||||
|
const startTime = locations[0].recorded_at;
|
||||||
|
const endTime = locations[locations.length - 1].recorded_at;
|
||||||
|
const totalTimeMs = new Date(endTime).getTime() - new Date(startTime).getTime();
|
||||||
|
const totalTimeMinutes = Math.round(totalTimeMs / 60000);
|
||||||
|
|
||||||
|
// 거리 계산 (Haversine 공식)
|
||||||
|
let totalDistanceM = 0;
|
||||||
|
for (let i = 1; i < locations.length; i++) {
|
||||||
|
const prev = locations[i - 1];
|
||||||
|
const curr = locations[i];
|
||||||
|
|
||||||
|
if (prev.latitude && prev.longitude && curr.latitude && curr.longitude) {
|
||||||
|
const distance = this.calculateDistance(
|
||||||
|
parseFloat(prev.latitude),
|
||||||
|
parseFloat(prev.longitude),
|
||||||
|
parseFloat(curr.latitude),
|
||||||
|
parseFloat(curr.longitude)
|
||||||
|
);
|
||||||
|
totalDistanceM += distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalDistanceKm = totalDistanceM / 1000;
|
||||||
|
|
||||||
|
console.log("📊 운행 통계 결과:", {
|
||||||
|
tripId,
|
||||||
|
totalDistanceKm,
|
||||||
|
totalTimeMinutes,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
pointCount: locations.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalDistanceKm,
|
||||||
|
totalTimeMinutes,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 운행 통계 계산 오류:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 두 좌표 간 거리 계산 (Haversine 공식, 미터 단위)
|
||||||
|
*/
|
||||||
|
private static calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const R = 6371000; // 지구 반경 (미터)
|
||||||
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||||
|
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||||
|
Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
return R * c;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 위치 이력 테이블에 저장 (내부 헬퍼)
|
* 위치 이력 테이블에 저장 (내부 헬퍼)
|
||||||
* + vehicles 테이블의 latitude/longitude도 함께 업데이트
|
* + vehicles 테이블의 latitude/longitude도 함께 업데이트
|
||||||
@@ -4217,6 +4421,28 @@ export class ButtonActionExecutor {
|
|||||||
try {
|
try {
|
||||||
console.log("🔄 운행알림/종료 액션 실행:", { config, context });
|
console.log("🔄 운행알림/종료 액션 실행:", { config, context });
|
||||||
|
|
||||||
|
// 🆕 출발지/도착지 필수 체크 (운행 시작 모드일 때만)
|
||||||
|
// updateTrackingMode가 "start"이거나 updateTargetValue가 "active"/"inactive"인 경우
|
||||||
|
const isStartMode = config.updateTrackingMode === "start" ||
|
||||||
|
config.updateTargetValue === "active" ||
|
||||||
|
config.updateTargetValue === "inactive";
|
||||||
|
|
||||||
|
if (isStartMode) {
|
||||||
|
// 출발지/도착지 필드명 (기본값: departure, destination)
|
||||||
|
const departureField = config.trackingDepartureField || "departure";
|
||||||
|
const destinationField = config.trackingArrivalField || "destination";
|
||||||
|
|
||||||
|
const departure = context.formData?.[departureField];
|
||||||
|
const destination = context.formData?.[destinationField];
|
||||||
|
|
||||||
|
console.log("📍 출발지/도착지 체크:", { departureField, destinationField, departure, destination });
|
||||||
|
|
||||||
|
if (!departure || departure === "" || !destination || destination === "") {
|
||||||
|
toast.error("출발지와 도착지를 먼저 선택해주세요.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🆕 공차 추적 중지 (운행 시작 시 공차 추적 종료)
|
// 🆕 공차 추적 중지 (운행 시작 시 공차 추적 종료)
|
||||||
if (this.emptyVehicleWatchId !== null) {
|
if (this.emptyVehicleWatchId !== null) {
|
||||||
this.stopEmptyVehicleTracking();
|
this.stopEmptyVehicleTracking();
|
||||||
|
|||||||
Reference in New Issue
Block a user