From 90b7c2b0f0fc2f4dfa7fb9807fdf94c3e98dd860 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 24 Nov 2025 16:52:22 +0900 Subject: [PATCH 1/5] =?UTF-8?q?=EC=9E=90=EC=9E=AC=20=EA=B0=9C=EC=88=98?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EB=86=92=EC=9D=B4=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/yard-3d/DigitalTwinEditor.tsx | 45 +++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index 88e844d3..3a8975c8 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -656,13 +656,13 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi }; // 캔버스에 드롭 - const handleCanvasDrop = (x: number, z: number) => { + const handleCanvasDrop = async (x: number, z: number) => { if (!draggedTool) return; const defaults = getToolDefaults(draggedTool); // Area는 바닥(y=0.05)에, 다른 객체는 중앙 정렬 - const yPosition = draggedTool === "area" ? 0.05 : (defaults.size?.y || 1) / 2; + let yPosition = draggedTool === "area" ? 0.05 : (defaults.size?.y || 1) / 2; // 외부 DB 데이터에서 드래그한 경우 해당 정보 사용 let objectName = defaults.name || "새 객체"; @@ -696,12 +696,51 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi externalKey = draggedLocationData.LOCAKEY; } + // 기본 크기 설정 + let objectSize = defaults.size || { x: 5, y: 5, z: 5 }; + + // Location 배치 시 자재 개수에 따라 높이 자동 설정 + if ( + (draggedTool === "location-bed" || + draggedTool === "location-stp" || + draggedTool === "location-temp" || + draggedTool === "location-dest") && + locaKey && + selectedDbConnection && + hierarchyConfig?.material + ) { + try { + // 해당 Location의 자재 개수 조회 + const countsResponse = await getMaterialCounts(selectedDbConnection, hierarchyConfig.material.tableName, [ + locaKey, + ]); + + if (countsResponse.success && countsResponse.data && countsResponse.data.length > 0) { + const materialCount = countsResponse.data[0].count; + + // 자재 개수에 비례해서 높이(Y축) 설정 (최소 5, 최대 30) + // 자재 1개 = 높이 5, 자재 10개 = 높이 15, 자재 50개 = 높이 30 + const calculatedHeight = Math.min(30, Math.max(5, 5 + materialCount * 0.5)); + + objectSize = { + ...objectSize, + y: calculatedHeight, // Y축이 높이! + }; + + // 높이가 높아진 만큼 Y 위치도 올려서 바닥을 뚫지 않게 조정 + yPosition = calculatedHeight / 2; + } + } catch (error) { + console.error("자재 개수 조회 실패, 기본 높이 사용:", error); + } + } + const newObject: PlacedObject = { id: nextObjectId, type: draggedTool, name: objectName, position: { x, y: yPosition, z }, - size: defaults.size || { x: 5, y: 5, z: 5 }, + size: objectSize, color: defaults.color || "#9ca3af", areaKey, locaKey, From b80d6cb85ee851008cb5369e948aca3436e09ba8 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 24 Nov 2025 17:02:22 +0900 Subject: [PATCH 2/5] =?UTF-8?q?=EC=98=81=EC=97=AD=EC=9D=98=20=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=EB=A5=BC=20=E2=80=9C=ED=95=B4=EB=8B=B9=20=EC=98=81?= =?UTF-8?q?=EC=97=AD=E2=80=9D=EC=97=90=EB=A7=8C=20=EB=B0=B0=EC=B9=98?= =?UTF-8?q?=EA=B0=80=20=EA=B0=80=EB=8A=A5=ED=95=98=EA=B2=8C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/yard-3d/DigitalTwinEditor.tsx | 79 ++++++++++++++++++- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index 3a8975c8..8b2d88f8 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -778,9 +778,32 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi return; } - // 부모 ID 설정 + // 부모 ID 설정 및 논리적 유효성 검사 if (validation.parent) { + // 1. 부모 객체 찾기 + const parentObj = placedObjects.find((obj) => obj.id === validation.parent!.id); + + // 2. 논리적 키 검사 (DB에서 가져온 데이터인 경우) + if (parentObj && parentObj.externalKey && newObject.parentKey) { + if (parentObj.externalKey !== newObject.parentKey) { + toast({ + variant: "destructive", + title: "배치 오류", + description: `이 Location은 '${newObject.parentKey}' Area에만 배치할 수 있습니다. (현재 선택된 Area: ${parentObj.externalKey})`, + }); + return; + } + } + newObject.parentId = validation.parent.id; + } else if (newObject.parentKey) { + // DB 데이터인데 부모 영역 위에 놓이지 않은 경우 + toast({ + variant: "destructive", + title: "배치 오류", + description: `이 Location은 '${newObject.parentKey}' Area 내부에 배치해야 합니다.`, + }); + return; } } @@ -964,7 +987,59 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi return obj; }); - // 2. 그룹 이동: 자식 객체들도 함께 이동 + // 2. 하위 계층 객체 이동 시 논리적 키 검증 + if (hierarchyConfig && targetObj.hierarchyLevel && targetObj.hierarchyLevel > 1) { + const spatialObjects = updatedObjects.map((obj) => ({ + id: obj.id, + position: obj.position, + size: obj.size, + hierarchyLevel: obj.hierarchyLevel || 1, + parentId: obj.parentId, + })); + + const targetSpatialObj = spatialObjects.find((obj) => obj.id === objectId); + if (targetSpatialObj) { + const validation = validateSpatialContainment( + targetSpatialObj, + spatialObjects.filter((obj) => obj.id !== objectId), + ); + + // 새로운 부모 영역 찾기 + if (validation.parent) { + const newParentObj = prev.find((obj) => obj.id === validation.parent!.id); + + // DB에서 가져온 데이터인 경우 논리적 키 검증 + if (newParentObj && newParentObj.externalKey && targetObj.parentKey) { + if (newParentObj.externalKey !== targetObj.parentKey) { + toast({ + variant: "destructive", + title: "이동 불가", + description: `이 Location은 '${targetObj.parentKey}' Area에만 배치할 수 있습니다. (현재 선택된 Area: ${newParentObj.externalKey})`, + }); + return prev; // 이동 취소 + } + } + + // 부모 ID 업데이트 + updatedObjects = updatedObjects.map((obj) => { + if (obj.id === objectId) { + return { ...obj, parentId: validation.parent!.id }; + } + return obj; + }); + } else if (targetObj.parentKey) { + // DB 데이터인데 부모 영역 밖으로 이동하려는 경우 + toast({ + variant: "destructive", + title: "이동 불가", + description: `이 Location은 '${targetObj.parentKey}' Area 내부에 있어야 합니다.`, + }); + return prev; // 이동 취소 + } + } + } + + // 3. 그룹 이동: 자식 객체들도 함께 이동 const spatialObjects = updatedObjects.map((obj) => ({ id: obj.id, position: obj.position, From 711f2670dea2b0b8c74614fc01fcc39de62f8a7a Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 24 Nov 2025 18:16:15 +0900 Subject: [PATCH 3/5] =?UTF-8?q?=EC=B4=88=EA=B8=B0=20=EB=B0=B0=EC=B9=98=20?= =?UTF-8?q?=EC=8B=9C=20=ED=94=84=EB=A6=AC=EB=B7=B0=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/yard-3d/DigitalTwinEditor.tsx | 111 +++++++++++------- .../widgets/yard-3d/Yard3DCanvas.tsx | 48 ++++++++ 2 files changed, 119 insertions(+), 40 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index 8b2d88f8..47902b42 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -73,6 +73,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi const [draggedTool, setDraggedTool] = useState(null); const [draggedAreaData, setDraggedAreaData] = useState(null); // 드래그 중인 Area 정보 const [draggedLocationData, setDraggedLocationData] = useState(null); // 드래그 중인 Location 정보 + const [previewPosition, setPreviewPosition] = useState<{ x: number; z: number } | null>(null); // 드래그 프리뷰 위치 const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); @@ -832,7 +833,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi // Location의 자재 목록 로드 const loadMaterialsForLocation = async (locaKey: string) => { + console.log("🔍 자재 조회 시작:", { locaKey, selectedDbConnection, material: hierarchyConfig?.material }); + if (!selectedDbConnection || !hierarchyConfig?.material) { + console.error("❌ 설정 누락:", { selectedDbConnection, material: hierarchyConfig?.material }); toast({ variant: "destructive", title: "자재 조회 실패", @@ -844,10 +848,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi try { setLoadingMaterials(true); setShowMaterialPanel(true); - const response = await getMaterials(selectedDbConnection, { + + const materialConfig = { ...hierarchyConfig.material, locaKey: locaKey, - }); + }; + console.log("📡 API 호출:", { externalDbConnectionId: selectedDbConnection, materialConfig }); + + const response = await getMaterials(selectedDbConnection, materialConfig); + console.log("📦 API 응답:", response); if (response.success && response.data) { // layerColumn이 있으면 정렬 const sortedMaterials = hierarchyConfig.material.layerColumn @@ -1597,48 +1606,70 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi - {/* 중앙: 3D 캔버스 */} -
e.preventDefault()} - onDrop={(e) => { - e.preventDefault(); - const rect = e.currentTarget.getBoundingClientRect(); - const rawX = ((e.clientX - rect.left) / rect.width - 0.5) * 100; - const rawZ = ((e.clientY - rect.top) / rect.height - 0.5) * 100; + {/* 중앙: 3D 캔버스 */} +
+ {isLoading ? ( +
+ +
+ ) : ( + <> + handleObjectClick(placement?.id || null)} + onPlacementDrag={(id, position) => handleObjectMove(id, position.x, position.z, position.y)} + focusOnPlacementId={null} + onCollisionDetected={() => {}} + previewTool={draggedTool} + previewPosition={previewPosition} + onPreviewPositionUpdate={setPreviewPosition} + /> + {/* 드래그 중일 때 Canvas 위에 투명한 오버레이 (프리뷰 및 드롭 이벤트 캐치용) */} + {draggedTool && ( +
{ + e.preventDefault(); + const rect = e.currentTarget.getBoundingClientRect(); + const rawX = ((e.clientX - rect.left) / rect.width - 0.5) * 100; + const rawZ = ((e.clientY - rect.top) / rect.height - 0.5) * 100; - // 그리드 크기 (5 단위) - const gridSize = 5; + // 그리드 크기 (5 단위) + const gridSize = 5; - // 그리드에 스냅 - // Area(20x20)는 그리드 교차점에, 다른 객체(5x5)는 타일 중앙에 - let snappedX = Math.round(rawX / gridSize) * gridSize; - let snappedZ = Math.round(rawZ / gridSize) * gridSize; + // 그리드에 스냅 + let snappedX = Math.round(rawX / gridSize) * gridSize; + let snappedZ = Math.round(rawZ / gridSize) * gridSize; - // 5x5 객체는 타일 중앙으로 오프셋 (Area는 제외) - if (draggedTool !== "area") { - snappedX += gridSize / 2; - snappedZ += gridSize / 2; - } + // 5x5 객체는 타일 중앙으로 오프셋 (Area는 제외) + if (draggedTool !== "area") { + snappedX += gridSize / 2; + snappedZ += gridSize / 2; + } - handleCanvasDrop(snappedX, snappedZ); - }} - > - {isLoading ? ( -
- -
- ) : ( - handleObjectClick(placement?.id || null)} - onPlacementDrag={(id, position) => handleObjectMove(id, position.x, position.z, position.y)} - focusOnPlacementId={null} - onCollisionDetected={() => {}} - /> - )} -
+ setPreviewPosition({ x: snappedX, z: snappedZ }); + }} + onDragLeave={() => { + setPreviewPosition(null); + }} + onDrop={(e) => { + e.preventDefault(); + e.stopPropagation(); + if (previewPosition) { + handleCanvasDrop(previewPosition.x, previewPosition.z); + setPreviewPosition(null); + } + setDraggedTool(null); + setDraggedAreaData(null); + setDraggedLocationData(null); + }} + /> + )} + + )} +
{/* 우측: 객체 속성 편집 or 자재 목록 */}
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx index 3de44b02..cb70b75a 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx @@ -35,6 +35,9 @@ interface Yard3DCanvasProps { gridSize?: number; // 그리드 크기 (기본값: 5) onCollisionDetected?: () => void; // 충돌 감지 시 콜백 focusOnPlacementId?: number | null; // 카메라가 포커스할 요소 ID + previewTool?: string | null; // 드래그 중인 도구 타입 + previewPosition?: { x: number; z: number } | null; // 프리뷰 위치 + onPreviewPositionUpdate?: (position: { x: number; z: number } | null) => void; } // 좌표를 그리드 칸의 중심에 스냅 (마인크래프트 스타일) @@ -1007,10 +1010,26 @@ function Scene({ gridSize = 5, onCollisionDetected, focusOnPlacementId, + previewTool, + previewPosition, }: Yard3DCanvasProps) { const [isDraggingAny, setIsDraggingAny] = useState(false); const orbitControlsRef = useRef(null); + // 프리뷰 박스 크기 계산 + const getPreviewSize = (tool: string) => { + if (tool === "area") return { x: 20, y: 0.1, z: 20 }; + return { x: 5, y: 5, z: 5 }; + }; + + // 프리뷰 박스 색상 + const getPreviewColor = (tool: string) => { + if (tool === "area") return "#3b82f6"; + if (tool === "location-bed") return "#10b981"; + if (tool === "location-stp") return "#f59e0b"; + return "#9ca3af"; + }; + return ( <> {/* 카메라 포커스 컨트롤러 */} @@ -1069,6 +1088,30 @@ function Scene({ /> ))} + {/* 드래그 프리뷰 박스 */} + {previewTool && previewPosition && ( + + + + )} + {/* 카메라 컨트롤 */} { // Canvas의 빈 공간을 클릭했을 때만 선택 해제 @@ -1123,6 +1169,8 @@ export default function Yard3DCanvas({ gridSize={gridSize} onCollisionDetected={onCollisionDetected} focusOnPlacementId={focusOnPlacementId} + previewTool={previewTool} + previewPosition={previewPosition} /> From 216e1366efd36fbd691fad4b23b40c1735fc79fd Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 24 Nov 2025 18:23:00 +0900 Subject: [PATCH 4/5] =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EC=8B=9C=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20=EC=84=B8=ED=8C=85=20=EA=B0=80=EC=A0=B8=EC=98=A4?= =?UTF-8?q?=EB=8A=94=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/yard-3d/HierarchyConfigPanel.tsx | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx index 8a6f4bfd..9e9528cd 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx @@ -67,12 +67,51 @@ export default function HierarchyConfigPanel({ const [loadingColumns, setLoadingColumns] = useState(false); const [columnsCache, setColumnsCache] = useState<{ [tableName: string]: string[] }>({}); - // 외부에서 변경된 경우 동기화 + // 외부에서 변경된 경우 동기화 및 컬럼 자동 로드 useEffect(() => { if (hierarchyConfig) { setLocalConfig(hierarchyConfig); + + // 저장된 설정의 테이블들에 대한 컬럼 자동 로드 + const loadSavedColumns = async () => { + const tablesToLoad: string[] = []; + + // 창고 테이블 + if (hierarchyConfig.warehouse?.tableName) { + tablesToLoad.push(hierarchyConfig.warehouse.tableName); + } + + // 계층 레벨 테이블들 + hierarchyConfig.levels?.forEach((level) => { + if (level.tableName) { + tablesToLoad.push(level.tableName); + } + }); + + // 자재 테이블 + if (hierarchyConfig.material?.tableName) { + tablesToLoad.push(hierarchyConfig.material.tableName); + } + + // 중복 제거 후 로드 + const uniqueTables = [...new Set(tablesToLoad)]; + for (const tableName of uniqueTables) { + if (!columnsCache[tableName]) { + try { + const columns = await onLoadColumns(tableName); + setColumnsCache((prev) => ({ ...prev, [tableName]: columns })); + } catch (error) { + console.error(`컬럼 로드 실패 (${tableName}):`, error); + } + } + } + }; + + if (externalDbConnectionId) { + loadSavedColumns(); + } } - }, [hierarchyConfig]); + }, [hierarchyConfig, externalDbConnectionId]); // 테이블 선택 시 컬럼 로드 const handleTableChange = async (tableName: string, type: "warehouse" | "material" | number) => { From 119afcaf42fffc520bfea4c8ab7555b805e65ea4 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 25 Nov 2025 09:35:47 +0900 Subject: [PATCH 5/5] =?UTF-8?q?=EB=B0=B0=EC=B9=98=EB=90=9C=20=EA=B0=9D?= =?UTF-8?q?=EC=B2=B4=20=EB=AA=A9=EB=A1=9D=20=EA=B3=84=EC=B8=B5=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EB=B0=8F=20=EC=95=84=EC=BD=94=EB=94=94=EC=96=B8=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/yard-3d/DigitalTwinEditor.tsx | 134 +++++++++++++++--- 1 file changed, 112 insertions(+), 22 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index 47902b42..b08d4fc1 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -1575,33 +1575,123 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi )}
- {/* 배치된 객체 목록 */} -
+ {/* 배치된 객체 목록 (계층 구조) */} +

배치된 객체 ({placedObjects.length})

{placedObjects.length === 0 ? (
상단 도구를 드래그하여 배치하세요
) : ( -
- {placedObjects.map((obj) => ( -
handleObjectClick(obj.id)} - className={`cursor-pointer rounded-lg border p-3 transition-all ${ - selectedObject?.id === obj.id ? "border-primary bg-primary/10" : "hover:border-primary/50" - }`} - > -
- {obj.name} -
-
-

- 위치: ({obj.position.x.toFixed(1)}, {obj.position.z.toFixed(1)}) -

- {obj.areaKey &&

Area: {obj.areaKey}

} -
- ))} -
+ + {/* Area별로 그룹핑 */} + {(() => { + // Area 객체들 + const areaObjects = placedObjects.filter((obj) => obj.type === "area"); + + // Area가 없으면 기존 방식으로 표시 + if (areaObjects.length === 0) { + return ( +
+ {placedObjects.map((obj) => ( +
handleObjectClick(obj.id)} + className={`cursor-pointer rounded-lg border p-3 transition-all ${ + selectedObject?.id === obj.id + ? "border-primary bg-primary/10" + : "hover:border-primary/50" + }`} + > +
+ {obj.name} +
+
+

+ 위치: ({obj.position.x.toFixed(1)}, {obj.position.z.toFixed(1)}) +

+
+ ))} +
+ ); + } + + // Area별로 Location들을 그룹핑 + return areaObjects.map((areaObj) => { + // 이 Area의 자식 Location들 찾기 + const childLocations = placedObjects.filter( + (obj) => + obj.type !== "area" && + obj.areaKey === areaObj.areaKey && + (obj.parentId === areaObj.id || obj.externalKey === areaObj.externalKey), + ); + + return ( + + +
{ + e.stopPropagation(); + handleObjectClick(areaObj.id); + }} + > +
+ + {areaObj.name} +
+
+ ({childLocations.length}) +
+
+
+ + + {childLocations.length === 0 ? ( +

+ Location이 없습니다 +

+ ) : ( +
+ {childLocations.map((locationObj) => ( +
handleObjectClick(locationObj.id)} + className={`cursor-pointer rounded-lg border p-2 transition-all ${ + selectedObject?.id === locationObj.id + ? "border-primary bg-primary/10" + : "hover:border-primary/50" + }`} + > +
+
+ + {locationObj.name} +
+
+
+

+ 위치: ({locationObj.position.x.toFixed(1)}, {locationObj.position.z.toFixed(1)}) +

+ {locationObj.locaKey && ( +

+ Key: {locationObj.locaKey} +

+ )} +
+ ))} +
+ )} + + + ); + }); + })()} + )}