diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index 3a4b1901..b99b58af 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -648,7 +648,14 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi 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( (obj) => (obj.type === "location-bed" || @@ -657,10 +664,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi obj.type === "location-dest") && obj.locaKey, ); - if (locationObjects.length > 0) { + if (locationObjects.length > 0 && dbConnectionId && materialTableName) { const locaKeys = locationObjects.map((obj) => obj.locaKey!); setTimeout(() => { - loadMaterialCountsForLocations(locaKeys); + loadMaterialCountsForLocations(locaKeys, dbConnectionId, materialTableName); }, 100); } } else { @@ -1045,11 +1052,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi }; // Location별 자재 개수 로드 (locaKeys를 직접 받음) - const loadMaterialCountsForLocations = async (locaKeys: string[]) => { - if (!selectedDbConnection || locaKeys.length === 0) return; + const loadMaterialCountsForLocations = async (locaKeys: string[], dbConnectionId?: number, materialTableName?: string) => { + const connectionId = dbConnectionId || selectedDbConnection; + const tableName = materialTableName || selectedTables.material; + if (!connectionId || locaKeys.length === 0) return; 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) { // 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외) setPlacedObjects((prev) => @@ -1060,13 +1071,23 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi ) { 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) { + // count 또는 material_count 필드 사용 + const count = materialCount.count || materialCount.material_count || 0; + const maxLayer = materialCount.max_layer || count; + console.log(`📊 ${obj.locaKey}: 자재 ${count}개`); return { ...obj, - materialCount: materialCount.material_count, + materialCount: Number(count), materialPreview: { - height: materialCount.max_layer * 1.5, // 층당 1.5 높이 (시각적) + height: maxLayer * 1.5, // 층당 1.5 높이 (시각적) }, }; } diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx index f2445d50..91804987 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx @@ -54,15 +54,17 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) // 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원) 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 저장 + let hierarchyConfigData: any = null; if (layout.hierarchy_config) { - const config = + hierarchyConfigData = typeof layout.hierarchy_config === "string" ? JSON.parse(layout.hierarchy_config) : layout.hierarchy_config; - setHierarchyConfig(config); + setHierarchyConfig(hierarchyConfigData); } // 객체 데이터 변환 @@ -103,6 +105,47 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) }); 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 { throw new Error(response.error || "레이아웃 조회 실패"); } diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx index 892acc88..a3b29042 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx @@ -1,7 +1,7 @@ "use client"; import { Canvas, useThree } from "@react-three/fiber"; -import { OrbitControls, Grid, Box, Text } from "@react-three/drei"; +import { OrbitControls, Box, Text } from "@react-three/drei"; import { Suspense, useRef, useState, useEffect, useMemo } from "react"; import * as THREE from "three"; @@ -525,68 +525,77 @@ function MaterialBox({ case "location-bed": case "location-temp": 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 ( <> - - - - - {/* 대표 자재 스택 (자재가 있을 때만) */} - {placement.material_count !== undefined && - placement.material_count > 0 && - placement.material_preview_height && ( + {/* 철판 스택 - 데이터 개수만큼 회색 판 쌓기 (최대 20개) */} + {Array.from({ length: locVisiblePlateCount }).map((_, idx) => { + const yPos = locPlateBaseY + idx * (locPlateThickness + locPlateGap); + // 약간의 랜덤 오프셋으로 자연스러움 추가 + const xOffset = (Math.sin(idx * 0.5) * 0.02); + const zOffset = (Math.cos(idx * 0.7) * 0.02); + + return ( + {/* 각 철판 외곽선 */} + + + + - )} - - {/* Location 이름 */} + ); + })} + + {/* Location 이름 - 실제 폴리곤 높이 기준, 뒤쪽(+Z)에 배치 */} {placement.name && ( {placement.name} )} - {/* 자재 개수 */} - {placement.material_count !== undefined && placement.material_count > 0 && ( + {/* 수량 표시 텍스트 - 실제 폴리곤 높이 기준, 앞쪽(-Z)에 배치 */} + {locPlateCount > 0 && ( - {`자재: ${placement.material_count}개`} + {`${locPlateCount}장`} )} @@ -886,83 +895,79 @@ function MaterialBox({ case "plate-stack": 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 ( <> - {/* 팔레트 그룹 - 박스 하단에 붙어있도록 */} - - {/* 상단 가로 판자들 (5개) */} - {[-boxDepth * 0.4, -boxDepth * 0.2, 0, boxDepth * 0.2, boxDepth * 0.4].map((zOffset, idx) => ( + {/* 철판 스택 - 데이터 개수만큼 회색 판 쌓기 (최대 20개) */} + {Array.from({ length: visiblePlateCount }).map((_, idx) => { + const yPos = plateBaseY + idx * (plateThickness + plateGap); + // 약간의 랜덤 오프셋으로 자연스러움 추가 (실제 철판처럼) + const xOffset = (Math.sin(idx * 0.5) * 0.02); + const zOffset = (Math.cos(idx * 0.7) * 0.02); + + return ( - + + {/* 각 철판 외곽선 */} - - + + - ))} - - {/* 중간 세로 받침대 (3개) */} - {[-boxWidth * 0.35, 0, boxWidth * 0.35].map((xOffset, idx) => ( - - - - - - - - ))} - - {/* 하단 가로 판자들 (3개) */} - {[-boxDepth * 0.3, 0, boxDepth * 0.3].map((zOffset, idx) => ( - - - - - - - - ))} - - - {/* 메인 박스 */} - - {/* 메인 재질 - 골판지 느낌 */} - - - {/* 외곽선 - 더 진하게 */} - - - - - + ); + })} + + {/* 수량 표시 텍스트 (상단) - 앞쪽(-Z)에 배치 */} + {plateCount > 0 && ( + + {`${plateCount}장`} + + )} + + {/* 자재명 표시 (있는 경우) - 뒤쪽(+Z)에 배치 */} + {placement.material_name && ( + + {placement.material_name} + + )} ); } @@ -1114,20 +1119,11 @@ function Scene({ {/* 배경색 */} - {/* 바닥 그리드 (타일을 4등분) */} - + {/* 바닥 - 단색 평면 (그리드 제거) */} + + + + {/* 자재 박스들 */} {placements.map((placement) => ( diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index ba88befd..36f420fd 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -1953,6 +1953,139 @@ export const ButtonConfigPanel: React.FC = ({ )} + {/* 🆕 버튼 활성화 조건 설정 */} +
+
버튼 활성화 조건
+ + {/* 출발지/도착지 필수 체크 */} +
+
+ +

선택하지 않으면 버튼 비활성화

+
+ onUpdateProperty("componentConfig.action.requireLocationFields", checked)} + /> +
+ + {config.action?.requireLocationFields && ( +
+
+
+ + onUpdateProperty("componentConfig.action.trackingDepartureField", e.target.value)} + className="h-8 text-xs" + /> +
+
+ + onUpdateProperty("componentConfig.action.trackingArrivalField", e.target.value)} + className="h-8 text-xs" + /> +
+
+
+ )} + + {/* 상태 기반 활성화 조건 */} +
+
+ +

특정 상태일 때만 버튼 활성화

+
+ onUpdateProperty("componentConfig.action.enableOnStatusCheck", checked)} + /> +
+ + {config.action?.enableOnStatusCheck && ( +
+
+ + +

+ 상태를 조회할 테이블 (기본: vehicles) +

+
+
+ + onUpdateProperty("componentConfig.action.statusCheckKeyField", e.target.value)} + className="h-8 text-xs" + /> +

+ 현재 로그인 사용자 ID로 조회할 필드 (기본: user_id) +

+
+
+ + onUpdateProperty("componentConfig.action.statusCheckField", e.target.value)} + className="h-8 text-xs" + /> +

+ 상태 값이 저장된 컬럼명 (기본: status) +

+
+
+ + +
+
+ + onUpdateProperty("componentConfig.action.statusConditionValues", e.target.value)} + className="h-8 text-xs" + /> +

+ 여러 상태값은 쉼표(,)로 구분 +

+
+
+ )} +
+

사용 예시: diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 5816940a..6f5c8739 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -26,6 +26,7 @@ import { useScreenPreview } from "@/contexts/ScreenPreviewContext"; import { useScreenContextOptional } from "@/contexts/ScreenContext"; import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext"; import { applyMappingRules } from "@/lib/utils/dataMapping"; +import { apiClient } from "@/lib/api/client"; export interface ButtonPrimaryComponentProps extends ComponentRendererProps { config?: ButtonPrimaryConfig; @@ -148,6 +149,149 @@ export const ButtonPrimaryComponent: React.FC = ({ return result; }, [flowConfig, currentStep, component.id, component.label]); + // 🆕 운행알림 버튼 조건부 비활성화 (출발지/도착지 필수, 상태 체크) + // 상태는 API로 조회 (formData에 없는 경우) + const [vehicleStatus, setVehicleStatus] = useState(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 [pendingAction, setPendingAction] = useState<{ @@ -877,6 +1021,9 @@ export const ButtonPrimaryComponent: React.FC = ({ } } + // 🆕 최종 비활성화 상태 (설정 + 조건부 비활성화) + const finalDisabled = componentConfig.disabled || isOperationButtonDisabled || statusLoading; + // 공통 버튼 스타일 const buttonElementStyle: React.CSSProperties = { width: "100%", @@ -884,12 +1031,12 @@ export const ButtonPrimaryComponent: React.FC = ({ minHeight: "40px", border: "none", borderRadius: "0.5rem", - background: componentConfig.disabled ? "#e5e7eb" : buttonColor, - color: componentConfig.disabled ? "#9ca3af" : "white", + background: finalDisabled ? "#e5e7eb" : buttonColor, + color: finalDisabled ? "#9ca3af" : "white", // 🔧 크기 설정 적용 (sm/md/lg) fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem", fontWeight: "600", - cursor: componentConfig.disabled ? "not-allowed" : "pointer", + cursor: finalDisabled ? "not-allowed" : "pointer", outline: "none", boxSizing: "border-box", display: "flex", @@ -900,7 +1047,7 @@ export const ButtonPrimaryComponent: React.FC = ({ componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem", margin: "0", 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 제외) ...(component.style ? Object.fromEntries( Object.entries(component.style).filter(([key]) => key !== 'width' && key !== 'height') @@ -925,7 +1072,7 @@ export const ButtonPrimaryComponent: React.FC = ({ // 일반 모드: button으로 렌더링