From 1e1bc0b2c68c9131781ceb812f855aac39c08c44 Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Fri, 21 Nov 2025 12:22:27 +0900
Subject: [PATCH 01/18] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?=
=?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20=EB=94=94?=
=?UTF-8?q?=EC=A7=80=ED=84=B8=20=ED=8A=B8=EC=9C=88=20UX=20=EA=B0=9C?=
=?UTF-8?q?=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../widgets/yard-3d/DigitalTwinEditor.tsx | 116 +++++++++---------
.../widgets/yard-3d/DigitalTwinViewer.tsx | 21 ++--
.../widgets/yard-3d/HierarchyConfigPanel.tsx | 14 +--
.../dashboard/widgets/yard-3d/constants.ts | 30 +++++
4 files changed, 103 insertions(+), 78 deletions(-)
create mode 100644 frontend/components/admin/dashboard/widgets/yard-3d/constants.ts
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx
index 88e844d3..ac9aac19 100644
--- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx
+++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx
@@ -25,6 +25,7 @@ import {
import type { MaterialData } from "@/types/digitalTwin";
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
import HierarchyConfigPanel, { HierarchyConfig } from "./HierarchyConfigPanel";
+import { OBJECT_COLORS, DEFAULT_COLOR } from "./constants";
import { validateSpatialContainment, updateChildrenPositions, getAllDescendants } from "./spatialContainment";
// 백엔드 DB 객체 타입 (snake_case)
@@ -702,7 +703,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
name: objectName,
position: { x, y: yPosition, z },
size: defaults.size || { x: 5, y: 5, z: 5 },
- color: defaults.color || "#9ca3af",
+ color: OBJECT_COLORS[draggedTool] || DEFAULT_COLOR, // 타입별 기본 색상
areaKey,
locaKey,
locType,
@@ -1169,8 +1170,8 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
도구:
{[
{ type: "area" as ToolType, label: "영역", icon: Grid3x3, color: "text-blue-500" },
- { type: "location-bed" as ToolType, label: "베드", icon: Package, color: "text-emerald-500" },
- { type: "location-stp" as ToolType, label: "정차", icon: Move, color: "text-orange-500" },
+ { type: "location-bed" as ToolType, label: "베드", icon: Package, color: "text-blue-600" },
+ { type: "location-stp" as ToolType, label: "정차", icon: Move, color: "text-gray-500" },
// { type: "crane-gantry" as ToolType, label: "겐트리", icon: Combine, color: "text-green-500" },
{ type: "crane-mobile" as ToolType, label: "크레인", icon: Truck, color: "text-yellow-500" },
{ type: "rack" as ToolType, label: "랙", icon: Box, color: "text-purple-500" },
@@ -1221,54 +1222,6 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
- {/* 창고 테이블 및 컬럼 매핑 */}
- {selectedDbConnection && (
-
-
-
- {/* 이 레이아웃의 창고 선택 */}
- {hierarchyConfig?.warehouse?.tableName && hierarchyConfig?.warehouse?.keyColumn && (
-
-
- {loadingWarehouses ? (
-
-
-
- ) : (
-
- )}
-
- )}
-
- )}
-
{/* 계층 설정 패널 (신규) */}
{selectedDbConnection && (
)}
+ {/* 창고 선택 (HierarchyConfigPanel 아래로 이동) */}
+ {selectedDbConnection && hierarchyConfig?.warehouse?.tableName && hierarchyConfig?.warehouse?.keyColumn && (
+
+
+
+
+
+ {loadingWarehouses ? (
+
+
+
+ ) : (
+
+ )}
+
+
+ )}
+
{/* Area 목록 */}
{selectedDbConnection && selectedWarehouse && (
@@ -1605,7 +1605,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
handleObjectUpdate({ name: e.target.value })}
className="mt-1.5 h-9 text-sm"
/>
@@ -1622,7 +1622,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
handleObjectUpdate({
position: {
@@ -1641,7 +1641,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
handleObjectUpdate({
position: {
@@ -1669,7 +1669,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
type="number"
step="5"
min="5"
- value={selectedObject.size.x}
+ value={selectedObject.size?.x || 5}
onChange={(e) =>
handleObjectUpdate({
size: {
@@ -1688,7 +1688,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
handleObjectUpdate({
size: {
@@ -1709,7 +1709,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
type="number"
step="5"
min="5"
- value={selectedObject.size.z}
+ value={selectedObject.size?.z || 5}
onChange={(e) =>
handleObjectUpdate({
size: {
@@ -1732,7 +1732,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
handleObjectUpdate({ color: e.target.value })}
className="mt-1.5 h-9"
/>
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx
index 3945a692..94ef98fb 100644
--- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx
+++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx
@@ -10,6 +10,7 @@ import dynamic from "next/dynamic";
import { useToast } from "@/hooks/use-toast";
import type { PlacedObject, MaterialData } from "@/types/digitalTwin";
import { getLayoutById, getMaterials } from "@/lib/api/digitalTwin";
+import { OBJECT_COLORS, DEFAULT_COLOR } from "./constants";
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
ssr: false,
@@ -81,7 +82,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
z: parseFloat(obj.size_z),
},
rotation: obj.rotation ? parseFloat(obj.rotation) : 0,
- color: getObjectColor(objectType), // 타입별 기본 색상 사용
+ color: getObjectColor(objectType, obj.color), // 저장된 색상 우선, 없으면 타입별 기본 색상
areaKey: obj.area_key,
locaKey: obj.loca_key,
locType: obj.loc_type,
@@ -225,17 +226,11 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
// 객체 타입별 기본 색상 (useMemo로 최적화)
const getObjectColor = useMemo(() => {
- return (type: string): string => {
- const colorMap: Record
= {
- area: "#3b82f6", // 파란색
- "location-bed": "#2563eb", // 진한 파란색
- "location-stp": "#6b7280", // 회색
- "location-temp": "#f59e0b", // 주황색
- "location-dest": "#10b981", // 초록색
- "crane-mobile": "#8b5cf6", // 보라색
- rack: "#ef4444", // 빨간색
- };
- return colorMap[type] || "#3b82f6";
+ return (type: string, savedColor?: string): string => {
+ // 저장된 색상이 있으면 우선 사용
+ if (savedColor) return savedColor;
+ // 없으면 타입별 기본 색상 사용
+ return OBJECT_COLORS[type] || DEFAULT_COLOR;
};
}, []);
@@ -383,7 +378,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
{typeLabel}
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx
index 8a6f4bfd..d309c92f 100644
--- a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx
+++ b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx
@@ -257,7 +257,7 @@ export default function HierarchyConfigPanel({
handleLevelChange(level.level, "name", e.target.value)}
className="h-7 w-32 text-xs"
placeholder="레벨명"
@@ -276,7 +276,7 @@ export default function HierarchyConfigPanel({
);
}
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx
index 94ef98fb..cc34fb19 100644
--- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx
+++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useMemo } from "react";
-import { Loader2, Search, Filter, X } from "lucide-react";
+import { Loader2, Search, X, Grid3x3, Package } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
@@ -11,6 +11,7 @@ import { useToast } from "@/hooks/use-toast";
import type { PlacedObject, MaterialData } from "@/types/digitalTwin";
import { getLayoutById, getMaterials } from "@/lib/api/digitalTwin";
import { OBJECT_COLORS, DEFAULT_COLOR } from "./constants";
+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
ssr: false,
@@ -94,6 +95,9 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
displayOrder: obj.display_order,
locked: obj.locked,
visible: obj.visible !== false,
+ hierarchyLevel: obj.hierarchy_level,
+ parentKey: obj.parent_key,
+ externalKey: obj.external_key,
};
});
@@ -352,61 +356,154 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
{searchQuery ? "검색 결과가 없습니다" : "객체가 없습니다"}
) : (
-
- {filteredObjects.map((obj) => {
- // 타입별 레이블
- let typeLabel = obj.type;
- if (obj.type === "location-bed") typeLabel = "베드(BED)";
- else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
- else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
- else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
- else if (obj.type === "crane-mobile") typeLabel = "크레인";
- else if (obj.type === "area") typeLabel = "Area";
- else if (obj.type === "rack") typeLabel = "랙";
+ (() => {
+ // Area 객체가 있는 경우 계층 트리 아코디언 적용
+ const areaObjects = filteredObjects.filter((obj) => obj.type === "area");
+ // Area가 없으면 기존 평면 리스트 유지
+ if (areaObjects.length === 0) {
return (
-
handleObjectClick(obj.id)}
- className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
- selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
- }`}
- >
-
-
-
{obj.name}
-
-
- {typeLabel}
-
-
-
+
+ {filteredObjects.map((obj) => {
+ let typeLabel = obj.type;
+ if (obj.type === "location-bed") typeLabel = "베드(BED)";
+ else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
+ else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
+ else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
+ else if (obj.type === "crane-mobile") typeLabel = "크레인";
+ else if (obj.type === "area") typeLabel = "Area";
+ else if (obj.type === "rack") typeLabel = "랙";
- {/* 추가 정보 */}
-
- {obj.areaKey && (
-
- Area: {obj.areaKey}
-
- )}
- {obj.locaKey && (
-
- Location: {obj.locaKey}
-
- )}
- {obj.materialCount !== undefined && obj.materialCount > 0 && (
-
- 자재: {obj.materialCount}개
-
- )}
-
+ return (
+
handleObjectClick(obj.id)}
+ className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
+ selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
+ }`}
+ >
+
+
+
{obj.name}
+
+
+ {typeLabel}
+
+
+
+
+ {obj.areaKey && (
+
+ Area: {obj.areaKey}
+
+ )}
+ {obj.locaKey && (
+
+ Location: {obj.locaKey}
+
+ )}
+ {obj.materialCount !== undefined && obj.materialCount > 0 && (
+
+ 자재: {obj.materialCount}개
+
+ )}
+
+
+ );
+ })}
);
- })}
-
+ }
+
+ // Area가 있는 경우: Area → Location 계층 아코디언
+ return (
+
+ {areaObjects.map((areaObj) => {
+ const childLocations = filteredObjects.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.position.x.toFixed(1)},{" "}
+ {locationObj.position.z.toFixed(1)})
+
+ {locationObj.locaKey && (
+
+ Location: {locationObj.locaKey}
+
+ )}
+ {locationObj.materialCount !== undefined && locationObj.materialCount > 0 && (
+
+ 자재: {locationObj.materialCount}개
+
+ )}
+
+ ))}
+
+ )}
+
+
+ );
+ })}
+
+ );
+ })()
)}
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/HIERARCHY_MIGRATION_GUIDE.md b/frontend/components/admin/dashboard/widgets/yard-3d/HIERARCHY_MIGRATION_GUIDE.md
index 5a7e01fb..915722b4 100644
--- a/frontend/components/admin/dashboard/widgets/yard-3d/HIERARCHY_MIGRATION_GUIDE.md
+++ b/frontend/components/admin/dashboard/widgets/yard-3d/HIERARCHY_MIGRATION_GUIDE.md
@@ -408,3 +408,4 @@ const handleObjectMove = (
**작성일**: 2025-11-20
**작성자**: AI Assistant
+
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx
index 770a2bad..186ac63f 100644
--- a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx
+++ b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx
@@ -49,6 +49,8 @@ interface ColumnInfo {
column_name: string;
data_type?: string;
description?: string;
+ // 백엔드에서 내려주는 Primary Key 플래그 ("YES"/"NO" 또는 boolean)
+ is_primary_key?: string | boolean;
}
interface HierarchyConfigPanelProps {
@@ -78,6 +80,18 @@ export default function HierarchyConfigPanel({
const [loadingColumns, setLoadingColumns] = useState(false);
const [columnsCache, setColumnsCache] = useState<{ [tableName: string]: ColumnInfo[] }>({});
+ // 동일한 column_name 이 여러 번 내려오는 경우(조인 중복 등) 제거
+ const normalizeColumns = (columns: ColumnInfo[]): ColumnInfo[] => {
+ const map = new Map();
+ for (const col of columns) {
+ const key = col.column_name;
+ if (!map.has(key)) {
+ map.set(key, col);
+ }
+ }
+ return Array.from(map.values());
+ };
+
// 외부에서 변경된 경우 동기화 및 컬럼 자동 로드
// 외부에서 변경된 경우 동기화 및 컬럼 자동 로드
useEffect(() => {
@@ -111,7 +125,8 @@ export default function HierarchyConfigPanel({
if (!columnsCache[tableName]) {
try {
const columns = await onLoadColumns(tableName);
- setColumnsCache((prev) => ({ ...prev, [tableName]: columns }));
+ const normalized = normalizeColumns(columns);
+ setColumnsCache((prev) => ({ ...prev, [tableName]: normalized }));
} catch (error) {
console.error(`컬럼 로드 실패 (${tableName}):`, error);
}
@@ -125,21 +140,83 @@ export default function HierarchyConfigPanel({
}
}, [hierarchyConfig, externalDbConnectionId]);
- // 테이블 선택 시 컬럼 로드
+ // 지정된 컬럼이 Primary Key 인지 여부
+ const isPrimaryKey = (col: ColumnInfo): boolean => {
+ if (col.is_primary_key === true) return true;
+ if (typeof col.is_primary_key === "string") {
+ const v = col.is_primary_key.toUpperCase();
+ return v === "YES" || v === "Y" || v === "TRUE" || v === "PK";
+ }
+ return false;
+ };
+
+ // 테이블 선택 시 컬럼 로드 + PK 기반 기본값 설정
const handleTableChange = async (tableName: string, type: "warehouse" | "material" | number) => {
- if (columnsCache[tableName]) {
- return; // 이미 로드된 경우 스킵
+ let loadedColumns = columnsCache[tableName];
+
+ // 아직 캐시에 없으면 먼저 컬럼 조회
+ if (!loadedColumns) {
+ setLoadingColumns(true);
+ try {
+ const fetched = await onLoadColumns(tableName);
+ loadedColumns = normalizeColumns(fetched);
+ setColumnsCache((prev) => ({ ...prev, [tableName]: loadedColumns! }));
+ } catch (error) {
+ console.error("컬럼 로드 실패:", error);
+ loadedColumns = [];
+ } finally {
+ setLoadingColumns(false);
+ }
}
- setLoadingColumns(true);
- try {
- const columns = await onLoadColumns(tableName);
- setColumnsCache((prev) => ({ ...prev, [tableName]: columns }));
- } catch (error) {
- console.error("컬럼 로드 실패:", error);
- } finally {
- setLoadingColumns(false);
- }
+ const columns = loadedColumns || [];
+
+ // PK 기반으로 keyColumn 기본값 자동 설정 (이미 값이 있으면 건드리지 않음)
+ // PK 정보가 없으면 첫 번째 컬럼을 기본값으로 사용
+ setLocalConfig((prev) => {
+ const next = { ...prev };
+ const primaryColumns = columns.filter((col) => isPrimaryKey(col));
+ const pkName = (primaryColumns[0] || columns[0])?.column_name;
+
+ if (!pkName) {
+ return next;
+ }
+
+ if (type === "warehouse") {
+ const wh = {
+ ...(next.warehouse || { tableName }),
+ tableName: next.warehouse?.tableName || tableName,
+ };
+ if (!wh.keyColumn) {
+ wh.keyColumn = pkName;
+ }
+ next.warehouse = wh;
+ } else if (type === "material") {
+ const material = {
+ ...(next.material || { tableName }),
+ tableName: next.material?.tableName || tableName,
+ };
+ if (!material.keyColumn) {
+ material.keyColumn = pkName;
+ }
+ next.material = material as NonNullable;
+ } else if (typeof type === "number") {
+ // 계층 레벨
+ next.levels = next.levels.map((lvl) => {
+ if (lvl.level !== type) return lvl;
+ const updated: HierarchyLevel = {
+ ...lvl,
+ tableName: lvl.tableName || tableName,
+ };
+ if (!updated.keyColumn) {
+ updated.keyColumn = pkName;
+ }
+ return updated;
+ });
+ }
+
+ return next;
+ });
};
// 창고 키 변경 (제거됨 - 상위 컴포넌트에서 처리)
@@ -271,16 +348,22 @@ export default function HierarchyConfigPanel({
- {columnsCache[localConfig.warehouse.tableName].map((col) => (
-
-
- {col.column_name}
- {col.description && (
- {col.description}
- )}
-
-
- ))}
+ {columnsCache[localConfig.warehouse.tableName].map((col) => {
+ const pk = isPrimaryKey(col);
+ return (
+
+
+
+ {col.column_name}
+ {pk && PK}
+
+ {col.description && (
+ {col.description}
+ )}
+
+
+ );
+ })}
@@ -310,6 +393,15 @@ export default function HierarchyConfigPanel({
)}
+
+ {localConfig.warehouse?.tableName &&
+ !columnsCache[localConfig.warehouse.tableName] &&
+ loadingColumns && (
+
+
+ 컬럼 정보를 불러오는 중입니다...
+
+ )}
@@ -385,16 +477,22 @@ export default function HierarchyConfigPanel({
- {columnsCache[level.tableName].map((col) => (
-
-
- {col.column_name}
- {col.description && (
- {col.description}
- )}
-
-
- ))}
+ {columnsCache[level.tableName].map((col) => {
+ const pk = isPrimaryKey(col);
+ return (
+
+
+
+ {col.column_name}
+ {pk && PK}
+
+ {col.description && (
+ {col.description}
+ )}
+
+
+ );
+ })}
@@ -475,6 +573,13 @@ export default function HierarchyConfigPanel({
>
)}
+
+ {level.tableName && !columnsCache[level.tableName] && loadingColumns && (
+
+
+ 컬럼 정보를 불러오는 중입니다...
+
+ )}
))}
@@ -528,21 +633,27 @@ export default function HierarchyConfigPanel({
value={localConfig.material.keyColumn || ""}
onValueChange={(val) => handleMaterialChange("keyColumn", val)}
>
-
-
-
-
- {columnsCache[localConfig.material.tableName].map((col) => (
-
-
- {col.column_name}
- {col.description && (
- {col.description}
- )}
-
-
- ))}
-
+
+
+
+
+ {columnsCache[localConfig.material.tableName].map((col) => {
+ const pk = isPrimaryKey(col);
+ return (
+
+
+
+ {col.column_name}
+ {pk && PK}
+
+ {col.description && (
+ {col.description}
+ )}
+
+
+ );
+ })}
+
@@ -673,6 +784,15 @@ export default function HierarchyConfigPanel({
>
)}
+
+ {localConfig.material?.tableName &&
+ !columnsCache[localConfig.material.tableName] &&
+ loadingColumns && (
+
+
+ 컬럼 정보를 불러오는 중입니다...
+
+ )}
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts b/frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts
index ebedb9f2..f2df7e70 100644
--- a/frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts
+++ b/frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts
@@ -163,3 +163,4 @@ export function getAllDescendants(
}
+
diff --git a/frontend/lib/api/digitalTwin.ts b/frontend/lib/api/digitalTwin.ts
index c20525b4..7a67ff39 100644
--- a/frontend/lib/api/digitalTwin.ts
+++ b/frontend/lib/api/digitalTwin.ts
@@ -19,6 +19,21 @@ interface ApiResponse {
error?: string;
}
+// 매핑 템플릿 타입
+export interface DigitalTwinMappingTemplate {
+ id: string;
+ company_code: string;
+ name: string;
+ description?: string;
+ external_db_connection_id: number;
+ layout_type: string;
+ config: any;
+ created_by: string;
+ created_at: string;
+ updated_by: string;
+ updated_at: string;
+}
+
// ========== 레이아웃 관리 API ==========
// 레이아웃 목록 조회
@@ -281,3 +296,60 @@ export const getChildrenData = async (
};
}
};
+
+// ========== 매핑 템플릿 API ==========
+
+// 템플릿 목록 조회 (회사 단위, 현재 사용자 기준)
+export const getMappingTemplates = async (params?: {
+ externalDbConnectionId?: number;
+ layoutType?: string;
+}): Promise> => {
+ try {
+ const response = await apiClient.get("/digital-twin/mapping-templates", {
+ params: {
+ externalDbConnectionId: params?.externalDbConnectionId,
+ layoutType: params?.layoutType,
+ },
+ });
+ return response.data;
+ } catch (error: any) {
+ return {
+ success: false,
+ error: error.response?.data?.message || error.message,
+ };
+ }
+};
+
+// 템플릿 생성
+export const createMappingTemplate = async (data: {
+ name: string;
+ description?: string;
+ externalDbConnectionId: number;
+ layoutType?: string;
+ config: any;
+}): Promise> => {
+ try {
+ const response = await apiClient.post("/digital-twin/mapping-templates", data);
+ return response.data;
+ } catch (error: any) {
+ return {
+ success: false,
+ error: error.response?.data?.message || error.message,
+ };
+ }
+};
+
+// 템플릿 단건 조회
+export const getMappingTemplateById = async (
+ id: string,
+): Promise> => {
+ try {
+ const response = await apiClient.get(`/digital-twin/mapping-templates/${id}`);
+ return response.data;
+ } catch (error: any) {
+ return {
+ success: false,
+ error: error.response?.data?.message || error.message,
+ };
+ }
+};
From 5609e32daf6e665cdfda5d658523de75a9c6ef79 Mon Sep 17 00:00:00 2001
From: SeongHyun Kim
Date: Tue, 25 Nov 2025 14:23:54 +0900
Subject: [PATCH 14/18] =?UTF-8?q?feat:=20=EC=88=98=EC=A3=BC=EA=B4=80?=
=?UTF-8?q?=EB=A6=AC=20=ED=92=88=EB=AA=A9=20CRUD=20=EB=B0=8F=20=EA=B3=B5?=
=?UTF-8?q?=ED=86=B5=20=ED=95=84=EB=93=9C=20=EC=9E=90=EB=8F=99=20=EB=B3=B5?=
=?UTF-8?q?=EC=82=AC=20=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 품목 추가 시 공통 필드(거래처, 담당자, 메모) 자동 복사
- ModalRepeaterTable onChange 시 groupData 반영
- 백엔드 타입 캐스팅으로 PostgreSQL 에러 해결
- 타입 정규화로 불필요한 UPDATE 방지
- 수정 모달에서 거래처/수주번호 읽기 전용 처리
---
.../src/services/dynamicFormService.ts | 34 ++++++-
frontend/components/screen/EditModal.tsx | 89 ++++++++++++-------
.../screen/InteractiveScreenViewerDynamic.tsx | 5 ++
.../lib/registry/DynamicComponentRenderer.tsx | 11 ++-
.../ConditionalSectionViewer.tsx | 18 ++--
.../ModalRepeaterTableComponent.tsx | 7 +-
6 files changed, 117 insertions(+), 47 deletions(-)
diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts
index 4d33dc1c..e9485620 100644
--- a/backend-node/src/services/dynamicFormService.ts
+++ b/backend-node/src/services/dynamicFormService.ts
@@ -811,9 +811,39 @@ export class DynamicFormService {
const primaryKeyColumn = primaryKeys[0];
console.log(`🔑 테이블 ${tableName}의 기본키: ${primaryKeyColumn}`);
- // 동적 UPDATE SQL 생성 (변경된 필드만)
+ // 🆕 컬럼 타입 조회 (타입 캐스팅용)
+ const columnTypesQuery = `
+ SELECT column_name, data_type
+ FROM information_schema.columns
+ WHERE table_name = $1 AND table_schema = 'public'
+ `;
+ const columnTypesResult = await query<{ column_name: string; data_type: string }>(
+ columnTypesQuery,
+ [tableName]
+ );
+ const columnTypes: Record = {};
+ columnTypesResult.forEach((row) => {
+ columnTypes[row.column_name] = row.data_type;
+ });
+
+ console.log("📊 컬럼 타입 정보:", columnTypes);
+
+ // 🆕 동적 UPDATE SQL 생성 (타입 캐스팅 포함)
const setClause = Object.keys(changedFields)
- .map((key, index) => `${key} = $${index + 1}`)
+ .map((key, index) => {
+ const dataType = columnTypes[key];
+ // 숫자 타입인 경우 명시적 캐스팅
+ if (dataType === 'integer' || dataType === 'bigint' || dataType === 'smallint') {
+ return `${key} = $${index + 1}::integer`;
+ } else if (dataType === 'numeric' || dataType === 'decimal' || dataType === 'real' || dataType === 'double precision') {
+ return `${key} = $${index + 1}::numeric`;
+ } else if (dataType === 'boolean') {
+ return `${key} = $${index + 1}::boolean`;
+ } else {
+ // 문자열 타입은 캐스팅 불필요
+ return `${key} = $${index + 1}`;
+ }
+ })
.join(", ");
const values: any[] = Object.values(changedFields);
diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx
index 3280891f..f9b803b2 100644
--- a/frontend/components/screen/EditModal.tsx
+++ b/frontend/components/screen/EditModal.tsx
@@ -320,43 +320,24 @@ export const EditModal: React.FC = ({ className }) => {
let updatedCount = 0;
let deletedCount = 0;
- // 🆕 sales_order_mng 테이블의 실제 컬럼만 포함 (조인된 컬럼 제외)
- const salesOrderColumns = [
- "id",
- "order_no",
- "customer_code",
- "customer_name",
- "order_date",
- "delivery_date",
- "item_code",
- "quantity",
- "unit_price",
- "amount",
- "status",
- "notes",
- "created_at",
- "updated_at",
- "company_code",
- ];
-
// 1️⃣ 신규 품목 추가 (id가 없는 항목)
for (const currentData of groupData) {
if (!currentData.id) {
console.log("➕ 신규 품목 추가:", currentData);
+ console.log("📋 [신규 품목] currentData 키 목록:", Object.keys(currentData));
- // 실제 테이블 컬럼만 추출
- const insertData: Record = {};
- Object.keys(currentData).forEach((key) => {
- if (salesOrderColumns.includes(key) && key !== "id") {
- insertData[key] = currentData[key];
- }
- });
+ // 🆕 모든 데이터를 포함 (id 제외)
+ const insertData: Record = { ...currentData };
+ console.log("📦 [신규 품목] 복사 직후 insertData:", insertData);
+ console.log("📋 [신규 품목] insertData 키 목록:", Object.keys(insertData));
+
+ delete insertData.id; // id는 자동 생성되므로 제거
// 🆕 groupByColumns의 값을 강제로 포함 (order_no 등)
if (modalState.groupByColumns && modalState.groupByColumns.length > 0) {
modalState.groupByColumns.forEach((colName) => {
- // 기존 품목(groupData[0])에서 groupByColumns 값 가져오기
- const referenceData = originalGroupData[0] || groupData.find(item => item.id);
+ // 기존 품목(originalGroupData[0])에서 groupByColumns 값 가져오기
+ const referenceData = originalGroupData[0] || groupData.find((item) => item.id);
if (referenceData && referenceData[colName]) {
insertData[colName] = referenceData[colName];
console.log(`🔑 [신규 품목] ${colName} 값 추가:`, referenceData[colName]);
@@ -364,7 +345,31 @@ export const EditModal: React.FC = ({ className }) => {
});
}
+ // 🆕 공통 필드 추가 (거래처, 담당자, 납품처, 메모 등)
+ // formData에서 품목별 필드가 아닌 공통 필드를 복사
+ const commonFields = [
+ 'partner_id', // 거래처
+ 'manager_id', // 담당자
+ 'delivery_partner_id', // 납품처
+ 'delivery_address', // 납품장소
+ 'memo', // 메모
+ 'order_date', // 주문일
+ 'due_date', // 납기일
+ 'shipping_method', // 배송방법
+ 'status', // 상태
+ 'sales_type', // 영업유형
+ ];
+
+ commonFields.forEach((fieldName) => {
+ // formData에 값이 있으면 추가
+ if (formData[fieldName] !== undefined && formData[fieldName] !== null) {
+ insertData[fieldName] = formData[fieldName];
+ console.log(`🔗 [공통 필드] ${fieldName} 값 추가:`, formData[fieldName]);
+ }
+ });
+
console.log("📦 [신규 품목] 최종 insertData:", insertData);
+ console.log("📋 [신규 품목] 최종 insertData 키 목록:", Object.keys(insertData));
try {
const response = await dynamicFormApi.saveFormData({
@@ -398,16 +403,32 @@ export const EditModal: React.FC = ({ className }) => {
continue;
}
- // 변경된 필드만 추출
+ // 🆕 값 정규화 함수 (타입 통일)
+ const normalizeValue = (val: any): any => {
+ if (val === null || val === undefined || val === "") return null;
+ if (typeof val === "string" && !isNaN(Number(val))) {
+ // 숫자로 변환 가능한 문자열은 숫자로
+ return Number(val);
+ }
+ return val;
+ };
+
+ // 변경된 필드만 추출 (id 제외)
const changedData: Record = {};
Object.keys(currentData).forEach((key) => {
- // sales_order_mng 테이블의 컬럼만 처리 (조인 컬럼 제외)
- if (!salesOrderColumns.includes(key)) {
+ // id는 변경 불가
+ if (key === "id") {
return;
}
- if (currentData[key] !== originalItemData[key]) {
- changedData[key] = currentData[key];
+ // 🆕 타입 정규화 후 비교
+ const currentValue = normalizeValue(currentData[key]);
+ const originalValue = normalizeValue(originalItemData[key]);
+
+ // 값이 변경된 경우만 포함
+ if (currentValue !== originalValue) {
+ console.log(`🔍 [품목 수정 감지] ${key}: ${originalValue} → ${currentValue}`);
+ changedData[key] = currentData[key]; // 원본 값 사용 (문자열 그대로)
}
});
@@ -677,6 +698,8 @@ export const EditModal: React.FC = ({ className }) => {
isInModal={true}
// 🆕 그룹 데이터를 ModalRepeaterTable에 전달
groupedData={groupData.length > 0 ? groupData : undefined}
+ // 🆕 수정 모달에서 읽기 전용 필드 지정 (수주번호, 거래처)
+ disabledFields={["order_no", "partner_id"]}
/>
);
})}
diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx
index fb5046c3..aa46ed40 100644
--- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx
+++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx
@@ -48,6 +48,8 @@ interface InteractiveScreenViewerProps {
companyCode?: string;
// 🆕 그룹 데이터 (EditModal에서 전달)
groupedData?: Record[];
+ // 🆕 비활성화할 필드 목록 (EditModal에서 전달)
+ disabledFields?: string[];
// 🆕 EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록)
isInModal?: boolean;
}
@@ -66,6 +68,7 @@ export const InteractiveScreenViewerDynamic: React.FC {
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
@@ -341,6 +344,8 @@ export const InteractiveScreenViewerDynamic: React.FC {
diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx
index bf2b6ecb..cf6037eb 100644
--- a/frontend/lib/registry/DynamicComponentRenderer.tsx
+++ b/frontend/lib/registry/DynamicComponentRenderer.tsx
@@ -110,6 +110,8 @@ export interface DynamicComponentRendererProps {
selectedRows?: any[];
// 🆕 그룹 데이터 (EditModal → ModalRepeaterTable)
groupedData?: Record[];
+ // 🆕 비활성화할 필드 목록 (EditModal → 각 컴포넌트)
+ disabledFields?: string[];
selectedRowsData?: any[];
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[]) => void;
// 테이블 정렬 정보 (엑셀 다운로드용)
@@ -168,6 +170,9 @@ export const DynamicComponentRenderer: React.FC =
}
};
+ // 🆕 disabledFields 체크
+ const isFieldDisabled = props.disabledFields?.includes(columnName) || (component as any).readonly;
+
return (
=
onChange={handleChange}
placeholder={component.componentConfig?.placeholder || "선택하세요"}
required={(component as any).required}
- disabled={(component as any).readonly}
+ disabled={isFieldDisabled}
className="w-full"
/>
);
@@ -271,6 +276,7 @@ export const DynamicComponentRenderer: React.FC =
onConfigChange,
isPreview,
autoGeneration,
+ disabledFields, // 🆕 비활성화 필드 목록
...restProps
} = props;
@@ -368,7 +374,8 @@ export const DynamicComponentRenderer: React.FC =
mode,
isInModal,
readonly: component.readonly,
- disabled: component.readonly,
+ // 🆕 disabledFields 체크 또는 기존 readonly
+ disabled: disabledFields?.includes(fieldName) || component.readonly,
originalData,
allComponents,
onUpdateLayout,
diff --git a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx
index 9709b620..735fac6d 100644
--- a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx
+++ b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx
@@ -154,18 +154,18 @@ export function ConditionalSectionViewer({
}}
>
+ />
);
})}
diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx
index 3941a89f..59ce35a8 100644
--- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx
+++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx
@@ -195,13 +195,18 @@ export function ModalRepeaterTableComponent({
const columnName = component?.columnName;
const value = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
- // ✅ onChange 래퍼 (기존 onChange 콜백만 호출, formData는 beforeFormSave에서 처리)
+ // ✅ onChange 래퍼 (기존 onChange 콜백 + onFormDataChange 호출)
const handleChange = (newData: any[]) => {
// 기존 onChange 콜백 호출 (호환성)
const externalOnChange = componentConfig?.onChange || propOnChange;
if (externalOnChange) {
externalOnChange(newData);
}
+
+ // 🆕 onFormDataChange 호출하여 EditModal의 groupData 업데이트
+ if (onFormDataChange && columnName) {
+ onFormDataChange(columnName, newData);
+ }
};
// uniqueField 자동 보정: order_no는 item_info 테이블에 없으므로 item_number로 변경
From 080188b419a534894e3985d3cbfbfab71a4d2a90 Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Tue, 25 Nov 2025 14:57:48 +0900
Subject: [PATCH 15/18] =?UTF-8?q?=EC=99=B8=EB=B6=80=20DB=20=EC=97=B0?=
=?UTF-8?q?=EA=B2=B0=20=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=EC=BF=BC=EB=A6=AC?=
=?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EB=B3=B4=EC=99=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../widgets/yard-3d/HierarchyConfigPanel.tsx | 33 ++++++++++++-------
frontend/lib/api/externalDbConnection.ts | 5 +++
2 files changed, 27 insertions(+), 11 deletions(-)
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx
index 186ac63f..0dffb0de 100644
--- a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx
+++ b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx
@@ -119,18 +119,29 @@ export default function HierarchyConfigPanel({
tablesToLoad.push(hierarchyConfig.material.tableName);
}
- // 중복 제거 후 로드
+ // 중복 제거 후, 아직 캐시에 없는 테이블만 병렬로 로드
const uniqueTables = [...new Set(tablesToLoad)];
- for (const tableName of uniqueTables) {
- if (!columnsCache[tableName]) {
- try {
- const columns = await onLoadColumns(tableName);
- const normalized = normalizeColumns(columns);
- setColumnsCache((prev) => ({ ...prev, [tableName]: normalized }));
- } catch (error) {
- console.error(`컬럼 로드 실패 (${tableName}):`, error);
- }
- }
+ const tablesToFetch = uniqueTables.filter((tableName) => !columnsCache[tableName]);
+
+ if (tablesToFetch.length === 0) {
+ return;
+ }
+
+ setLoadingColumns(true);
+ try {
+ await Promise.all(
+ tablesToFetch.map(async (tableName) => {
+ try {
+ const columns = await onLoadColumns(tableName);
+ const normalized = normalizeColumns(columns);
+ setColumnsCache((prev) => ({ ...prev, [tableName]: normalized }));
+ } catch (error) {
+ console.error(`컬럼 로드 실패 (${tableName}):`, error);
+ }
+ }),
+ );
+ } finally {
+ setLoadingColumns(false);
}
};
diff --git a/frontend/lib/api/externalDbConnection.ts b/frontend/lib/api/externalDbConnection.ts
index 034a60ef..6d211b3d 100644
--- a/frontend/lib/api/externalDbConnection.ts
+++ b/frontend/lib/api/externalDbConnection.ts
@@ -290,8 +290,13 @@ export class ExternalDbConnectionAPI {
static async getTableColumns(connectionId: number, tableName: string): Promise> {
try {
console.log("컬럼 정보 API 요청:", `${this.BASE_PATH}/${connectionId}/tables/${tableName}/columns`);
+ // 컬럼 메타데이터 조회는 외부 DB 성능/네트워크 영향으로 오래 걸릴 수 있으므로
+ // 기본 30초보다 넉넉한 타임아웃을 사용
const response = await apiClient.get>(
`${this.BASE_PATH}/${connectionId}/tables/${tableName}/columns`,
+ {
+ timeout: 120000, // 120초
+ },
);
console.log("컬럼 정보 API 응답:", response.data);
return response.data;
From 60832e88ff2e762d633fba43ba0333e84ac65f86 Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Tue, 25 Nov 2025 15:01:47 +0900
Subject: [PATCH 16/18] =?UTF-8?q?3d=ED=95=84=EB=93=9C=20=EC=83=9D=EC=84=B1?=
=?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../admin/dashboard/widgets/YardManagement3DWidget.tsx | 2 +-
.../dashboard/widgets/yard-3d/YardLayoutCreateModal.tsx | 8 ++++----
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx b/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx
index 815ef07c..8bfcadb4 100644
--- a/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx
+++ b/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx
@@ -173,7 +173,7 @@ export default function YardManagement3DWidget({
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardLayoutCreateModal.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardLayoutCreateModal.tsx
index 6fcaca8e..d554dac3 100644
--- a/frontend/components/admin/dashboard/widgets/yard-3d/YardLayoutCreateModal.tsx
+++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardLayoutCreateModal.tsx
@@ -68,15 +68,15 @@ export default function YardLayoutCreateModal({ isOpen, onClose, onCreate }: Yar
e.stopPropagation()}>
- 새 야드 생성
- 야드 이름을 입력하세요
+ 새로운 3d필드 생성
+ 필드 이름을 입력하세요
From f59218aa4365f4518d3ff8ba8b411a3bbfd86b31 Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Tue, 25 Nov 2025 15:06:55 +0900
Subject: [PATCH 17/18] =?UTF-8?q?3d=ED=95=84=EB=93=9C=EB=A1=9C=20=ED=85=8D?=
=?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=B3=80=EA=B2=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend-node/src/app.ts | 4 +-
.../admin/dashboard/CanvasElement.tsx | 4 +-
.../admin/dashboard/DashboardDesigner.tsx | 2 +-
.../admin/dashboard/DashboardTopMenu.tsx | 2 +-
.../admin/dashboard/WidgetConfigSidebar.tsx | 4 +-
frontend/components/admin/dashboard/types.ts | 6 +-
.../widgets/YardManagement3DWidget.tsx | 63 +++++++++----------
.../widgets/yard-3d/YardLayoutCreateModal.tsx | 2 +-
.../admin/dashboard/widgets/yard-3d/types.ts | 2 +-
9 files changed, 43 insertions(+), 46 deletions(-)
diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts
index be51e70e..fc69cdb1 100644
--- a/backend-node/src/app.ts
+++ b/backend-node/src/app.ts
@@ -57,7 +57,7 @@ import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관
import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
-import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D
+import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드
//import materialRoutes from "./routes/materialRoutes"; // 자재 관리
import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제)
import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
@@ -222,7 +222,7 @@ app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리
app.use("/api/todos", todoRoutes); // To-Do 관리
app.use("/api/bookings", bookingRoutes); // 예약 요청 관리
app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회
-app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D
+app.use("/api/yard-layouts", yardLayoutRoutes); // 3D 필드
// app.use("/api/materials", materialRoutes); // 자재 관리 (임시 주석)
app.use("/api/digital-twin", digitalTwinRoutes); // 디지털 트윈 (야드 관제)
app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결
diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx
index beb1e483..090985ba 100644
--- a/frontend/components/admin/dashboard/CanvasElement.tsx
+++ b/frontend/components/admin/dashboard/CanvasElement.tsx
@@ -193,7 +193,7 @@ import { ListWidget } from "./widgets/ListWidget";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
-// 야드 관리 3D 위젯
+// 3D 필드 위젯
const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DWidget"), {
ssr: false,
loading: () => (
@@ -1085,7 +1085,7 @@ export function CanvasElement({
) : element.type === "widget" && element.subtype === "yard-management-3d" ? (
- // 야드 관리 3D 위젯 렌더링
+ // 3D 필드 위젯 렌더링
리스트
통계 카드
리스크/알림
- 야드 관리 3D
+ 3D 필드
{/* 커스텀 통계 카드 */}
{/* 커스텀 상태 카드 */}
diff --git a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx
index db608645..10af48e8 100644
--- a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx
+++ b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx
@@ -93,7 +93,7 @@ const getWidgetTitle = (subtype: ElementSubtype): string => {
chart: "차트",
"map-summary-v2": "지도",
"risk-alert-v2": "리스크 알림",
- "yard-management-3d": "야드 관리 3D",
+ "yard-management-3d": "3D 필드",
weather: "날씨 위젯",
exchange: "환율 위젯",
calculator: "계산기",
@@ -449,7 +449,7 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
- {/* 레이아웃 선택 (야드 관리 3D 위젯 전용) */}
+ {/* 레이아웃 선택 (3D 필드 위젯 전용) */}
{element.subtype === "yard-management-3d" && (
);
}
@@ -164,30 +162,31 @@ export default function YardManagement3DWidget({
// 편집 모드: 레이아웃 선택 UI
if (isEditMode) {
return (
-
+
-
야드 레이아웃 선택
-
- {config?.layoutName ? `선택됨: ${config.layoutName}` : "표시할 야드 레이아웃을 선택하세요"}
+
3D 필드 선택
+
+ {config?.layoutName ? `선택됨: ${config.layoutName}` : "표시할 3D필드를 선택하세요"}
{isLoading ? (
) : layouts.length === 0 ? (
🏗️
-
생성된 야드 레이아웃이 없습니다
-
먼저 야드 레이아웃을 생성하세요
+
생성된 3D필드가 없습니다
+
먼저 3D필드가 생성하세요
) : (
@@ -202,11 +201,11 @@ export default function YardManagement3DWidget({