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({
handleLevelChange(level.level, "keyColumn", val)} > @@ -319,7 +319,7 @@ export default function HierarchyConfigPanel({
handleLevelChange(level.level, "parentKeyColumn", val)} > @@ -422,7 +422,7 @@ export default function HierarchyConfigPanel({
handleMaterialChange("locationKeyColumn", val)} > diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/constants.ts b/frontend/components/admin/dashboard/widgets/yard-3d/constants.ts new file mode 100644 index 00000000..d4959a6c --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/yard-3d/constants.ts @@ -0,0 +1,30 @@ +/** + * 디지털 트윈 3D 야드 - 공통 상수 + */ + +// 객체 타입별 색상 매핑 (HEX 코드) +export const OBJECT_COLORS: Record = { + area: "#3b82f6", // 파란색 + "location-bed": "#2563eb", // 진한 파란색 + "location-stp": "#6b7280", // 회색 + "location-temp": "#f59e0b", // 주황색 + "location-dest": "#10b981", // 초록색 + "crane-mobile": "#8b5cf6", // 보라색 + rack: "#ef4444", // 빨간색 +}; + +// Tailwind 색상 클래스 매핑 (아이콘용) +export const OBJECT_COLOR_CLASSES: Record = { + area: "text-blue-500", + "location-bed": "text-blue-600", + "location-stp": "text-gray-500", + "location-temp": "text-orange-500", + "location-dest": "text-emerald-500", + "crane-mobile": "text-purple-500", + rack: "text-red-500", +}; + +// 기본 색상 +export const DEFAULT_COLOR = "#3b82f6"; +export const DEFAULT_COLOR_CLASS = "text-blue-500"; + From 6ccaa854133405a6f5493f7d54e9a20059b66be5 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 21 Nov 2025 14:29:17 +0900 Subject: [PATCH 02/18] =?UTF-8?q?=EC=9A=B0=EC=B8=A1=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EC=BB=AC=EB=9F=BC=20=EC=9D=B8=ED=92=8B=20?= =?UTF-8?q?=EB=8B=A4=20=EC=A7=80=EC=9A=B0=EB=A9=B4=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=EA=B0=92=EC=9D=B4=20=EB=93=A4=EC=96=B4=EC=98=A4=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx index d309c92f..4def424c 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx @@ -526,7 +526,7 @@ export default function HierarchyConfigPanel({ {col} {isSelected && ( { const currentDisplay = localConfig.material?.displayColumns || []; const newDisplay = currentDisplay.map((d) => From dd913d3ecfb595891727fd5d7e1ebaa8a15b0170 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 21 Nov 2025 15:44:52 +0900 Subject: [PATCH 03/18] =?UTF-8?q?3d=EC=97=90=EC=84=9C=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EA=B0=80=EC=A0=B8=EC=98=AC=20=EB=95=8C=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94,=20=EC=BB=AC=EB=9F=BC=20=EC=BD=94=EB=A9=98?= =?UTF-8?q?=ED=8A=B8=20=EA=B0=99=EC=9D=B4=20=EA=B0=80=EC=A0=B8=EC=98=A4?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/database/MariaDBConnector.ts | 37 +++-- .../widgets/yard-3d/DigitalTwinEditor.tsx | 18 +-- .../widgets/yard-3d/HierarchyConfigPanel.tsx | 133 ++++++++++++++---- 3 files changed, 137 insertions(+), 51 deletions(-) diff --git a/backend-node/src/database/MariaDBConnector.ts b/backend-node/src/database/MariaDBConnector.ts index 3f469330..d104b9e3 100644 --- a/backend-node/src/database/MariaDBConnector.ts +++ b/backend-node/src/database/MariaDBConnector.ts @@ -1,7 +1,11 @@ -import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector'; -import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; +import { + DatabaseConnector, + ConnectionConfig, + QueryResult, +} from "../interfaces/DatabaseConnector"; +import { ConnectionTestResult, TableInfo } from "../types/externalDbTypes"; // @ts-ignore -import * as mysql from 'mysql2/promise'; +import * as mysql from "mysql2/promise"; export class MariaDBConnector implements DatabaseConnector { private connection: mysql.Connection | null = null; @@ -20,7 +24,7 @@ export class MariaDBConnector implements DatabaseConnector { password: this.config.password, database: this.config.database, connectTimeout: this.config.connectionTimeoutMillis, - ssl: typeof this.config.ssl === 'boolean' ? undefined : this.config.ssl, + ssl: typeof this.config.ssl === "boolean" ? undefined : this.config.ssl, }); } } @@ -36,7 +40,9 @@ export class MariaDBConnector implements DatabaseConnector { const startTime = Date.now(); try { await this.connect(); - const [rows] = await this.connection!.query("SELECT VERSION() as version"); + const [rows] = await this.connection!.query( + "SELECT VERSION() as version" + ); const version = (rows as any[])[0]?.version || "Unknown"; const responseTime = Date.now() - startTime; await this.disconnect(); @@ -111,21 +117,28 @@ export class MariaDBConnector implements DatabaseConnector { console.log(`[MariaDBConnector] getColumns 호출: tableName=${tableName}`); await this.connect(); console.log(`[MariaDBConnector] 연결 완료, 쿼리 실행 시작`); - - const [rows] = await this.connection!.query(` + + const [rows] = await this.connection!.query( + ` SELECT COLUMN_NAME as column_name, DATA_TYPE as data_type, IS_NULLABLE as is_nullable, - COLUMN_DEFAULT as column_default + COLUMN_DEFAULT as column_default, + COLUMN_COMMENT as description FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION; - `, [tableName]); - + `, + [tableName] + ); + console.log(`[MariaDBConnector] 쿼리 결과:`, rows); - console.log(`[MariaDBConnector] 결과 개수:`, Array.isArray(rows) ? rows.length : 'not array'); - + console.log( + `[MariaDBConnector] 결과 개수:`, + Array.isArray(rows) ? rows.length : "not array" + ); + await this.disconnect(); return rows as any[]; } catch (error: any) { diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index ac9aac19..5443d150 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -95,7 +95,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi // 동적 계층 구조 설정 const [hierarchyConfig, setHierarchyConfig] = useState(null); - const [availableTables, setAvailableTables] = useState([]); + const [availableTables, setAvailableTables] = useState>([]); const [loadingTables, setLoadingTables] = useState(false); // 레거시: 테이블 매핑 (구 Area/Location 방식 호환용) @@ -210,9 +210,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi const { getTables } = await import("@/lib/api/digitalTwin"); const response = await getTables(selectedDbConnection); if (response.success && response.data) { - const tableNames = response.data.map((t) => t.table_name); - setAvailableTables(tableNames); - console.log("📋 테이블 목록:", tableNames); + // 테이블 정보 전체 저장 (이름 + 설명) + setAvailableTables(response.data); + console.log("📋 테이블 목록:", response.data); } } catch (error) { console.error("테이블 목록 조회 실패:", error); @@ -1266,10 +1266,12 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi try { const response = await ExternalDbConnectionAPI.getTableColumns(selectedDbConnection, tableName); if (response.success && response.data) { - // 객체 배열을 문자열 배열로 변환 - return response.data.map((col: any) => - typeof col === "string" ? col : col.column_name || col.COLUMN_NAME || String(col), - ); + // 컬럼 정보 객체 배열로 반환 (이름 + 설명) + return response.data.map((col: any) => ({ + column_name: typeof col === "string" ? col : col.column_name || col.COLUMN_NAME || String(col), + data_type: col.data_type || col.DATA_TYPE, + description: col.description || col.COLUMN_COMMENT || undefined, + })); } return []; } catch (error) { diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx index 4def424c..00473450 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx @@ -40,13 +40,24 @@ export interface HierarchyConfig { }; } +interface TableInfo { + table_name: string; + description?: string; +} + +interface ColumnInfo { + column_name: string; + data_type?: string; + description?: string; +} + interface HierarchyConfigPanelProps { externalDbConnectionId: number | null; hierarchyConfig: HierarchyConfig | null; onHierarchyConfigChange: (config: HierarchyConfig) => void; - availableTables: string[]; + availableTables: TableInfo[]; onLoadTables: () => Promise; - onLoadColumns: (tableName: string) => Promise; + onLoadColumns: (tableName: string) => Promise; } export default function HierarchyConfigPanel({ @@ -65,7 +76,7 @@ export default function HierarchyConfigPanel({ ); const [loadingColumns, setLoadingColumns] = useState(false); - const [columnsCache, setColumnsCache] = useState<{ [tableName: string]: string[] }>({}); + const [columnsCache, setColumnsCache] = useState<{ [tableName: string]: ColumnInfo[] }>({}); // 외부에서 변경된 경우 동기화 useEffect(() => { @@ -187,8 +198,13 @@ export default function HierarchyConfigPanel({ {availableTables.map((table) => ( - - {table} + +
+ {table.table_name} + {table.description && ( + {table.description} + )} +
))}
@@ -209,8 +225,13 @@ export default function HierarchyConfigPanel({ {columnsCache[localConfig.warehouse.tableName].map((col) => ( - - {col} + +
+ {col.column_name} + {col.description && ( + {col.description} + )} +
))}
@@ -228,8 +249,13 @@ export default function HierarchyConfigPanel({ {columnsCache[localConfig.warehouse.tableName].map((col) => ( - - {col} + +
+ {col.column_name} + {col.description && ( + {col.description} + )} +
))}
@@ -287,8 +313,13 @@ export default function HierarchyConfigPanel({ {availableTables.map((table) => ( - - {table} + +
+ {table.table_name} + {table.description && ( + {table.description} + )} +
))}
@@ -308,8 +339,13 @@ export default function HierarchyConfigPanel({ {columnsCache[level.tableName].map((col) => ( - - {col} + +
+ {col.column_name} + {col.description && ( + {col.description} + )} +
))}
@@ -327,8 +363,13 @@ export default function HierarchyConfigPanel({ {columnsCache[level.tableName].map((col) => ( - - {col} + +
+ {col.column_name} + {col.description && ( + {col.description} + )} +
))}
@@ -409,8 +450,13 @@ export default function HierarchyConfigPanel({ {availableTables.map((table) => ( - - {table} + +
+ {table.table_name} + {table.description && ( + {table.description} + )} +
))}
@@ -430,8 +476,13 @@ export default function HierarchyConfigPanel({ {columnsCache[localConfig.material.tableName].map((col) => ( - - {col} + +
+ {col.column_name} + {col.description && ( + {col.description} + )} +
))}
@@ -449,8 +500,13 @@ export default function HierarchyConfigPanel({ {columnsCache[localConfig.material.tableName].map((col) => ( - - {col} + +
+ {col.column_name} + {col.description && ( + {col.description} + )} +
))}
@@ -469,8 +525,13 @@ export default function HierarchyConfigPanel({ 없음 {columnsCache[localConfig.material.tableName].map((col) => ( - - {col} + +
+ {col.column_name} + {col.description && ( + {col.description} + )} +
))}
@@ -489,8 +550,13 @@ export default function HierarchyConfigPanel({ 없음 {columnsCache[localConfig.material.tableName].map((col) => ( - - {col} + +
+ {col.column_name} + {col.description && ( + {col.description} + )} +
))}
@@ -507,30 +573,35 @@ export default function HierarchyConfigPanel({

{columnsCache[localConfig.material.tableName].map((col) => { - const displayItem = localConfig.material?.displayColumns?.find((d) => d.column === col); + const displayItem = localConfig.material?.displayColumns?.find((d) => d.column === col.column_name); const isSelected = !!displayItem; return ( -
+
{ const currentDisplay = localConfig.material?.displayColumns || []; const newDisplay = e.target.checked - ? [...currentDisplay, { column: col, label: col }] - : currentDisplay.filter((d) => d.column !== col); + ? [...currentDisplay, { column: col.column_name, label: col.column_name }] + : currentDisplay.filter((d) => d.column !== col.column_name); handleMaterialChange("displayColumns", newDisplay); }} className="h-3 w-3 shrink-0" /> - {col} +
+ {col.column_name} + {col.description && ( + {col.description} + )} +
{isSelected && ( { const currentDisplay = localConfig.material?.displayColumns || []; const newDisplay = currentDisplay.map((d) => - d.column === col ? { ...d, label: e.target.value } : d, + d.column === col.column_name ? { ...d, label: e.target.value } : d, ); handleMaterialChange("displayColumns", newDisplay); }} From 68e8e7b36bf38eb4e78732a625f31036c9059277 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 21 Nov 2025 16:13:50 +0900 Subject: [PATCH 04/18] =?UTF-8?q?=EC=B4=88=EA=B8=B0=EC=97=90=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=B6=88=EB=9F=AC?= =?UTF-8?q?=EC=99=80=EC=A7=80=EC=A7=80=20=EC=95=8A=EB=8A=94=20=ED=98=84?= =?UTF-8?q?=EC=83=81=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/yard-3d/HierarchyConfigPanel.tsx | 54 ++++++++++++++++--- 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx index 00473450..31cca6f8 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx @@ -78,16 +78,43 @@ export default function HierarchyConfigPanel({ const [loadingColumns, setLoadingColumns] = useState(false); const [columnsCache, setColumnsCache] = useState<{ [tableName: string]: ColumnInfo[] }>({}); - // 외부에서 변경된 경우 동기화 + // 외부에서 변경된 경우 동기화 및 컬럼 자동 로드 useEffect(() => { if (hierarchyConfig) { setLocalConfig(hierarchyConfig); + + // 저장된 설정이 있으면 해당 테이블들의 컬럼을 자동 로드 + const loadSavedColumns = async () => { + // 창고 테이블 컬럼 로드 + if (hierarchyConfig.warehouse?.tableName) { + await handleTableChange(hierarchyConfig.warehouse.tableName, "warehouse"); + } + + // 레벨 테이블 컬럼 로드 + if (hierarchyConfig.levels) { + for (const level of hierarchyConfig.levels) { + if (level.tableName) { + await handleTableChange(level.tableName, level.level); + } + } + } + + // 자재 테이블 컬럼 로드 + if (hierarchyConfig.material?.tableName) { + await handleTableChange(hierarchyConfig.material.tableName, "material"); + } + }; + + loadSavedColumns(); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [hierarchyConfig]); // 테이블 선택 시 컬럼 로드 const handleTableChange = async (tableName: string, type: "warehouse" | "material" | number) => { - if (columnsCache[tableName]) return; // 이미 로드된 경우 스킵 + if (columnsCache[tableName]) { + return; // 이미 로드된 경우 스킵 + } setLoadingColumns(true); try { @@ -209,6 +236,11 @@ export default function HierarchyConfigPanel({ ))} + {!localConfig.warehouse?.tableName && ( +

+ ℹ️ 창고 테이블을 선택하고 "설정 적용"을 눌러주세요 +

+ )}
{/* 창고 컬럼 매핑 */} @@ -387,8 +419,13 @@ export default function HierarchyConfigPanel({ {columnsCache[level.tableName].map((col) => ( - - {col} + +
+ {col.column_name} + {col.description && ( + {col.description} + )} +
))}
@@ -409,8 +446,13 @@ export default function HierarchyConfigPanel({ 없음 {columnsCache[level.tableName].map((col) => ( - - {col} + +
+ {col.column_name} + {col.description && ( + {col.description} + )} +
))}
From 7994b2a72adc451c82141b64bba0ed0966b62a29 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 24 Nov 2025 15:57:28 +0900 Subject: [PATCH 05/18] =?UTF-8?q?=EA=B3=84=EC=B8=B5=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=82=AC=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B7=B8=EB=A3=B9=20=EC=9D=B4=EB=8F=99=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=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/DigitalTwinEditor.tsx | 45 +++++++++- .../widgets/yard-3d/Yard3DCanvas.tsx | 85 ++++++++++++++++--- 2 files changed, 114 insertions(+), 16 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index 5443d150..4aa3db0f 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -657,13 +657,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 || "새 객체"; @@ -697,12 +697,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: OBJECT_COLORS[draggedTool] || DEFAULT_COLOR, // 타입별 기본 색상 areaKey, locaKey, diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx index 3de44b02..f0f406b7 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx @@ -442,20 +442,79 @@ function MaterialBox({ )} - {/* Area 이름 텍스트 */} + {/* Area 이름 텍스트 - 위쪽 (바닥) */} {placement.name && ( - - {placement.name} - + <> + + {placement.name} + + + {/* 4면에 텍스트 표시 */} + {/* 앞면 (+Z) */} + + {placement.name} + + + {/* 뒷면 (-Z) */} + + {placement.name} + + + {/* 왼쪽면 (-X) */} + + {placement.name} + + + {/* 오른쪽면 (+X) */} + + {placement.name} + + )} ); From 90b7c2b0f0fc2f4dfa7fb9807fdf94c3e98dd860 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 24 Nov 2025 16:52:22 +0900 Subject: [PATCH 06/18] =?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 1139cea838bf5e42a139ed3701db6a61ee229312 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 24 Nov 2025 16:54:31 +0900 Subject: [PATCH 07/18] =?UTF-8?q?feat(table-list):=20=EC=BB=AC=EB=9F=BC=20?= =?UTF-8?q?=EB=84=88=EB=B9=84=20=EC=9E=90=EB=8F=99=20=EC=A1=B0=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A0=95=EB=A0=AC=20=EC=83=81=ED=83=9C=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 데이터 내용 기반 컬럼 너비 자동 계산 (상위 50개 샘플링) - 사용자가 조정한 컬럼 너비를 localStorage에 저장/복원 - 정렬 상태(컬럼, 방향)를 localStorage에 저장/복원 - 사용자별, 테이블별 독립적인 설정 관리 변경: - TableListComponent.tsx: calculateOptimalColumnWidth 추가, 정렬 상태 저장/복원 로직 추가 - README.md: 새로운 기능 문서화 저장 키: - table_column_widths_{테이블}_{사용자}: 컬럼 너비 - table_sort_state_{테이블}_{사용자}: 정렬 상태 Fixes: 수주관리 화면에서 컬럼 너비 수동 조정 번거로움, 정렬 설정 미유지 문제 --- .../table-list/TableListComponent.tsx | 130 ++++++++++++++++-- 1 file changed, 117 insertions(+), 13 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 12bdb7d1..a8356721 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -322,6 +322,7 @@ export const TableListComponent: React.FC = ({ const [searchTerm, setSearchTerm] = useState(""); const [sortColumn, setSortColumn] = useState(null); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); + const hasInitializedSort = useRef(false); const [columnLabels, setColumnLabels] = useState>({}); const [tableLabel, setTableLabel] = useState(""); const [localPageSize, setLocalPageSize] = useState(tableConfig.pagination?.pageSize || 20); @@ -508,6 +509,28 @@ export const TableListComponent: React.FC = ({ unregisterTable, ]); + // 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기 + useEffect(() => { + if (!tableConfig.selectedTable || !userId || hasInitializedSort.current) return; + + const storageKey = `table_sort_state_${tableConfig.selectedTable}_${userId}`; + const savedSort = localStorage.getItem(storageKey); + + if (savedSort) { + try { + const { column, direction } = JSON.parse(savedSort); + if (column && direction) { + setSortColumn(column); + setSortDirection(direction); + hasInitializedSort.current = true; + console.log("📂 localStorage에서 정렬 상태 복원:", { column, direction }); + } + } catch (error) { + console.error("❌ 정렬 상태 복원 실패:", error); + } + } + }, [tableConfig.selectedTable, userId]); + // 🆕 초기 로드 시 localStorage에서 컬럼 순서 불러오기 useEffect(() => { if (!tableConfig.selectedTable || !userId) return; @@ -955,6 +978,20 @@ export const TableListComponent: React.FC = ({ newSortDirection = "asc"; } + // 🎯 정렬 상태를 localStorage에 저장 (사용자별) + if (tableConfig.selectedTable && userId) { + const storageKey = `table_sort_state_${tableConfig.selectedTable}_${userId}`; + try { + localStorage.setItem(storageKey, JSON.stringify({ + column: newSortColumn, + direction: newSortDirection + })); + console.log("💾 정렬 상태 저장:", { column: newSortColumn, direction: newSortDirection }); + } catch (error) { + console.error("❌ 정렬 상태 저장 실패:", error); + } + } + console.log("📊 새로운 정렬 정보:", { newSortColumn, newSortDirection }); console.log("🔍 onSelectedRowsChange 존재 여부:", !!onSelectedRowsChange); @@ -1876,11 +1913,59 @@ export const TableListComponent: React.FC = ({ }; }, [tableConfig.selectedTable, isDesignMode]); - // 초기 컬럼 너비 측정 (한 번만) + // 🎯 컬럼 너비 자동 계산 (내용 기반) + const calculateOptimalColumnWidth = useCallback((columnName: string, displayName: string): number => { + // 기본 너비 설정 + const MIN_WIDTH = 100; + const MAX_WIDTH = 400; + const PADDING = 48; // 좌우 패딩 + 여유 공간 + const HEADER_PADDING = 60; // 헤더 추가 여유 (정렬 아이콘 등) + + // 헤더 텍스트 너비 계산 (대략 8px per character) + const headerWidth = (displayName?.length || columnName.length) * 10 + HEADER_PADDING; + + // 데이터 셀 너비 계산 (상위 50개 샘플링) + const sampleSize = Math.min(50, data.length); + let maxDataWidth = headerWidth; + + for (let i = 0; i < sampleSize; i++) { + const cellValue = data[i]?.[columnName]; + if (cellValue !== null && cellValue !== undefined) { + const cellText = String(cellValue); + // 숫자는 좁게, 텍스트는 넓게 계산 + const isNumber = !isNaN(Number(cellValue)) && cellValue !== ""; + const charWidth = isNumber ? 8 : 9; + const cellWidth = cellText.length * charWidth + PADDING; + maxDataWidth = Math.max(maxDataWidth, cellWidth); + } + } + + // 최소/최대 범위 내로 제한 + return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.ceil(maxDataWidth))); + }, [data]); + + // 🎯 localStorage에서 컬럼 너비 불러오기 및 초기 계산 useEffect(() => { - if (!hasInitializedWidths.current && visibleColumns.length > 0) { - // 약간의 지연을 두고 DOM이 완전히 렌더링된 후 측정 + if (!hasInitializedWidths.current && visibleColumns.length > 0 && data.length > 0) { const timer = setTimeout(() => { + const storageKey = tableConfig.selectedTable && userId + ? `table_column_widths_${tableConfig.selectedTable}_${userId}` + : null; + + // 1. localStorage에서 저장된 너비 불러오기 + let savedWidths: Record = {}; + if (storageKey) { + try { + const saved = localStorage.getItem(storageKey); + if (saved) { + savedWidths = JSON.parse(saved); + } + } catch (error) { + console.error("컬럼 너비 불러오기 실패:", error); + } + } + + // 2. 자동 계산 또는 저장된 너비 적용 const newWidths: Record = {}; let hasAnyWidth = false; @@ -1888,13 +1973,18 @@ export const TableListComponent: React.FC = ({ // 체크박스 컬럼은 제외 (고정 48px) if (column.columnName === "__checkbox__") return; - const thElement = columnRefs.current[column.columnName]; - if (thElement) { - const measuredWidth = thElement.offsetWidth; - if (measuredWidth > 0) { - newWidths[column.columnName] = measuredWidth; - hasAnyWidth = true; - } + // 저장된 너비가 있으면 우선 사용 + if (savedWidths[column.columnName]) { + newWidths[column.columnName] = savedWidths[column.columnName]; + hasAnyWidth = true; + } else { + // 저장된 너비가 없으면 자동 계산 + const optimalWidth = calculateOptimalColumnWidth( + column.columnName, + columnLabels[column.columnName] || column.displayName + ); + newWidths[column.columnName] = optimalWidth; + hasAnyWidth = true; } }); @@ -1902,11 +1992,11 @@ export const TableListComponent: React.FC = ({ setColumnWidths(newWidths); hasInitializedWidths.current = true; } - }, 100); + }, 150); // DOM 렌더링 대기 return () => clearTimeout(timer); } - }, [visibleColumns]); + }, [visibleColumns, data, tableConfig.selectedTable, userId, calculateOptimalColumnWidth, columnLabels]); // ======================================== // 페이지네이션 JSX @@ -2241,7 +2331,21 @@ export const TableListComponent: React.FC = ({ // 최종 너비를 state에 저장 if (thElement) { const finalWidth = Math.max(80, thElement.offsetWidth); - setColumnWidths((prev) => ({ ...prev, [column.columnName]: finalWidth })); + setColumnWidths((prev) => { + const newWidths = { ...prev, [column.columnName]: finalWidth }; + + // 🎯 localStorage에 컬럼 너비 저장 (사용자별) + if (tableConfig.selectedTable && userId) { + const storageKey = `table_column_widths_${tableConfig.selectedTable}_${userId}`; + try { + localStorage.setItem(storageKey, JSON.stringify(newWidths)); + } catch (error) { + console.error("컬럼 너비 저장 실패:", error); + } + } + + return newWidths; + }); } // 텍스트 선택 복원 From b80d6cb85ee851008cb5369e948aca3436e09ba8 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 24 Nov 2025 17:02:22 +0900 Subject: [PATCH 08/18] =?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 09/18] =?UTF-8?q?=EC=B4=88=EA=B8=B0=20=EB=B0=B0=EC=B9=98?= =?UTF-8?q?=20=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 10/18] =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EC=8B=9C=20?= =?UTF-8?q?=EA=B8=B0=EC=A1=B4=20=EC=84=B8=ED=8C=85=20=EA=B0=80=EC=A0=B8?= =?UTF-8?q?=EC=98=A4=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 11/18] =?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} +

+ )} +
+ ))} +
+ )} + + + ); + }); + })()} + )}
From a9f57add62f2256f86a08ea21d12bdd8271e7d1d Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 25 Nov 2025 12:07:14 +0900 Subject: [PATCH 12/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=20=EC=B6=94=EA=B0=80/=EC=88=98?= =?UTF-8?q?=EC=A0=95/=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=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 - EditModal의 handleSave가 button-primary까지 전달되도록 수정 - ConditionalContainer/ConditionalSectionViewer에 onSave prop 추가 - DynamicComponentRenderer와 InteractiveScreenViewerDynamic에 onSave 전달 로직 추가 - ButtonActionExecutor에서 context.onSave 콜백 우선 실행 로직 구현 - 신규 품목 추가 시 groupByColumns 값 자동 포함 처리 기능: - 품목 추가: order_no 자동 설정 - 품목 수정: 변경 필드만 부분 업데이트 - 품목 삭제: originalGroupData 비교 후 제거 --- frontend/components/screen/EditModal.tsx | 220 ++++++++++++------ .../screen/InteractiveScreenViewerDynamic.tsx | 6 + .../lib/registry/DynamicComponentRenderer.tsx | 3 + .../button-primary/ButtonPrimaryComponent.tsx | 7 + .../ConditionalContainerComponent.tsx | 3 + .../ConditionalSectionViewer.tsx | 20 +- .../components/conditional-container/types.ts | 2 + frontend/lib/utils/buttonActions.ts | 19 +- 8 files changed, 204 insertions(+), 76 deletions(-) diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 4e756600..3280891f 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -305,84 +305,173 @@ export const EditModal: React.FC = ({ className }) => { } try { - // 🆕 그룹 데이터가 있는 경우: 모든 품목 일괄 수정 - if (groupData.length > 0) { - console.log("🔄 그룹 데이터 일괄 수정 시작:", { + // 🆕 그룹 데이터가 있는 경우: 모든 품목 일괄 처리 (추가/수정/삭제) + if (groupData.length > 0 || originalGroupData.length > 0) { + console.log("🔄 그룹 데이터 일괄 처리 시작:", { groupDataLength: groupData.length, originalGroupDataLength: originalGroupData.length, + groupData, + originalGroupData, + tableName: screenData.screenInfo.tableName, + screenId: modalState.screenId, }); + let insertedCount = 0; let updatedCount = 0; + let deletedCount = 0; - for (let i = 0; i < groupData.length; i++) { - const currentData = groupData[i]; - const originalItemData = originalGroupData[i]; + // 🆕 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", + ]; - if (!originalItemData) { - console.warn(`원본 데이터가 없습니다 (index: ${i})`); - continue; - } + // 1️⃣ 신규 품목 추가 (id가 없는 항목) + for (const currentData of groupData) { + if (!currentData.id) { + console.log("➕ 신규 품목 추가:", currentData); - // 변경된 필드만 추출 - const changedData: Record = {}; - - // 🆕 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", - ]; - - Object.keys(currentData).forEach((key) => { - // sales_order_mng 테이블의 컬럼만 처리 (조인 컬럼 제외) - if (!salesOrderColumns.includes(key)) { - return; + // 실제 테이블 컬럼만 추출 + const insertData: Record = {}; + Object.keys(currentData).forEach((key) => { + if (salesOrderColumns.includes(key) && key !== "id") { + insertData[key] = currentData[key]; + } + }); + + // 🆕 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); + if (referenceData && referenceData[colName]) { + insertData[colName] = referenceData[colName]; + console.log(`🔑 [신규 품목] ${colName} 값 추가:`, referenceData[colName]); + } + }); } - - if (currentData[key] !== originalItemData[key]) { - changedData[key] = currentData[key]; + + console.log("📦 [신규 품목] 최종 insertData:", insertData); + + try { + const response = await dynamicFormApi.saveFormData({ + screenId: modalState.screenId || 0, + tableName: screenData.screenInfo.tableName, + data: insertData, + }); + + if (response.success) { + insertedCount++; + console.log("✅ 신규 품목 추가 성공:", response.data); + } else { + console.error("❌ 신규 품목 추가 실패:", response.message); + } + } catch (error: any) { + console.error("❌ 신규 품목 추가 오류:", error); } - }); - - // 변경사항이 없으면 스킵 - if (Object.keys(changedData).length === 0) { - console.log(`변경사항 없음 (index: ${i})`); - continue; - } - - // 기본키 확인 - const recordId = originalItemData.id || Object.values(originalItemData)[0]; - - // UPDATE 실행 - const response = await dynamicFormApi.updateFormDataPartial( - recordId, - originalItemData, - changedData, - screenData.screenInfo.tableName, - ); - - if (response.success) { - updatedCount++; - console.log(`✅ 품목 ${i + 1} 수정 성공 (id: ${recordId})`); - } else { - console.error(`❌ 품목 ${i + 1} 수정 실패 (id: ${recordId}):`, response.message); } } - if (updatedCount > 0) { - toast.success(`${updatedCount}개의 품목이 수정되었습니다.`); + // 2️⃣ 기존 품목 수정 (id가 있는 항목) + for (const currentData of groupData) { + if (currentData.id) { + // id 기반 매칭 (인덱스 기반 X) + const originalItemData = originalGroupData.find( + (orig) => orig.id === currentData.id + ); + + if (!originalItemData) { + console.warn(`원본 데이터를 찾을 수 없습니다 (id: ${currentData.id})`); + continue; + } + + // 변경된 필드만 추출 + const changedData: Record = {}; + Object.keys(currentData).forEach((key) => { + // sales_order_mng 테이블의 컬럼만 처리 (조인 컬럼 제외) + if (!salesOrderColumns.includes(key)) { + return; + } + + if (currentData[key] !== originalItemData[key]) { + changedData[key] = currentData[key]; + } + }); + + // 변경사항이 없으면 스킵 + if (Object.keys(changedData).length === 0) { + console.log(`변경사항 없음 (id: ${currentData.id})`); + continue; + } + + // UPDATE 실행 + try { + const response = await dynamicFormApi.updateFormDataPartial( + currentData.id, + originalItemData, + changedData, + screenData.screenInfo.tableName, + ); + + if (response.success) { + updatedCount++; + console.log(`✅ 품목 수정 성공 (id: ${currentData.id})`); + } else { + console.error(`❌ 품목 수정 실패 (id: ${currentData.id}):`, response.message); + } + } catch (error: any) { + console.error(`❌ 품목 수정 오류 (id: ${currentData.id}):`, error); + } + } + } + + // 3️⃣ 삭제된 품목 제거 (원본에는 있지만 현재 데이터에는 없는 항목) + const currentIds = new Set(groupData.map((item) => item.id).filter(Boolean)); + const deletedItems = originalGroupData.filter( + (orig) => orig.id && !currentIds.has(orig.id) + ); + + for (const deletedItem of deletedItems) { + console.log("🗑️ 품목 삭제:", deletedItem); + + try { + const response = await dynamicFormApi.deleteFormDataFromTable( + deletedItem.id, + screenData.screenInfo.tableName + ); + + if (response.success) { + deletedCount++; + console.log(`✅ 품목 삭제 성공 (id: ${deletedItem.id})`); + } else { + console.error(`❌ 품목 삭제 실패 (id: ${deletedItem.id}):`, response.message); + } + } catch (error: any) { + console.error(`❌ 품목 삭제 오류 (id: ${deletedItem.id}):`, error); + } + } + + // 결과 메시지 + const messages: string[] = []; + if (insertedCount > 0) messages.push(`${insertedCount}개 추가`); + if (updatedCount > 0) messages.push(`${updatedCount}개 수정`); + if (deletedCount > 0) messages.push(`${deletedCount}개 삭제`); + + if (messages.length > 0) { + toast.success(`품목이 저장되었습니다 (${messages.join(", ")})`); // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) if (modalState.onSave) { @@ -585,6 +674,7 @@ export const EditModal: React.FC = ({ className }) => { tableName: screenData.screenInfo?.tableName, }} onSave={handleSave} + isInModal={true} // 🆕 그룹 데이터를 ModalRepeaterTable에 전달 groupedData={groupData.length > 0 ? groupData : undefined} /> diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index d1cd2a5f..fb5046c3 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 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록) + isInModal?: boolean; } export const InteractiveScreenViewerDynamic: React.FC = ({ @@ -64,6 +66,7 @@ export const InteractiveScreenViewerDynamic: React.FC { const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const { userName: authUserName, user: authUser } = useAuth(); @@ -329,6 +332,7 @@ export const InteractiveScreenViewerDynamic: React.FC { @@ -401,6 +405,8 @@ export const InteractiveScreenViewerDynamic: React.FC { diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 92fd89e8..bf2b6ecb 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -105,6 +105,7 @@ export interface DynamicComponentRendererProps { companyCode?: string; // 🆕 현재 사용자의 회사 코드 onRefresh?: () => void; onClose?: () => void; + onSave?: () => Promise; // 🆕 EditModal의 handleSave 콜백 // 테이블 선택된 행 정보 (다중 선택 액션용) selectedRows?: any[]; // 🆕 그룹 데이터 (EditModal → ModalRepeaterTable) @@ -244,6 +245,7 @@ export const DynamicComponentRenderer: React.FC = selectedScreen, // 🆕 화면 정보 onRefresh, onClose, + onSave, // 🆕 EditModal의 handleSave 콜백 screenId, userId, // 🆕 사용자 ID userName, // 🆕 사용자 이름 @@ -358,6 +360,7 @@ export const DynamicComponentRenderer: React.FC = selectedScreen, // 🆕 화면 정보 onRefresh, onClose, + onSave, // 🆕 EditModal의 handleSave 콜백 screenId, userId, // 🆕 사용자 ID userName, // 🆕 사용자 이름 diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 112a285c..d2b69074 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -35,6 +35,7 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps { onRefresh?: () => void; onClose?: () => void; onFlowRefresh?: () => void; + onSave?: () => Promise; // 🆕 EditModal의 handleSave 콜백 // 폼 데이터 관련 originalData?: Record; // 부분 업데이트용 원본 데이터 @@ -83,6 +84,7 @@ export const ButtonPrimaryComponent: React.FC = ({ onRefresh, onClose, onFlowRefresh, + onSave, // 🆕 EditModal의 handleSave 콜백 sortBy, // 🆕 정렬 컬럼 sortOrder, // 🆕 정렬 방향 columnOrder, // 🆕 컬럼 순서 @@ -95,6 +97,10 @@ export const ButtonPrimaryComponent: React.FC = ({ ...props }) => { const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 + + // 🆕 props에서 onSave 추출 (명시적으로 선언되지 않은 경우 ...props에서 추출) + const propsOnSave = (props as any).onSave as (() => Promise) | undefined; + const finalOnSave = onSave || propsOnSave; // 🆕 플로우 단계별 표시 제어 const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig; @@ -415,6 +421,7 @@ export const ButtonPrimaryComponent: React.FC = ({ onRefresh, onClose, onFlowRefresh, // 플로우 새로고침 콜백 추가 + onSave: finalOnSave, // 🆕 EditModal의 handleSave 콜백 (props에서도 추출) // 테이블 선택된 행 정보 추가 selectedRows, selectedRowsData, diff --git a/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx b/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx index 2589026f..626ee137 100644 --- a/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx +++ b/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx @@ -41,6 +41,7 @@ export function ConditionalContainerComponent({ style, className, groupedData, // 🆕 그룹 데이터 + onSave, // 🆕 EditModal의 handleSave 콜백 }: ConditionalContainerProps) { console.log("🎯 ConditionalContainerComponent 렌더링!", { isDesignMode, @@ -179,6 +180,7 @@ export function ConditionalContainerComponent({ formData={formData} onFormDataChange={onFormDataChange} groupedData={groupedData} + onSave={onSave} /> ))}
@@ -199,6 +201,7 @@ export function ConditionalContainerComponent({ formData={formData} onFormDataChange={onFormDataChange} groupedData={groupedData} + onSave={onSave} /> ) : null ) diff --git a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx index f77dbcdb..9709b620 100644 --- a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx +++ b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx @@ -26,6 +26,7 @@ export function ConditionalSectionViewer({ formData, onFormDataChange, groupedData, // 🆕 그룹 데이터 + onSave, // 🆕 EditModal의 handleSave 콜백 }: ConditionalSectionViewerProps) { const { userId, userName, user } = useAuth(); const [isLoading, setIsLoading] = useState(false); @@ -153,17 +154,18 @@ export function ConditionalSectionViewer({ }} > + onSave={onSave} + />
); })} diff --git a/frontend/lib/registry/components/conditional-container/types.ts b/frontend/lib/registry/components/conditional-container/types.ts index 0cf741b2..bcd701ef 100644 --- a/frontend/lib/registry/components/conditional-container/types.ts +++ b/frontend/lib/registry/components/conditional-container/types.ts @@ -46,6 +46,7 @@ export interface ConditionalContainerProps { formData?: Record; onFormDataChange?: (fieldName: string, value: any) => void; groupedData?: Record[]; // 🆕 그룹 데이터 (EditModal → ModalRepeaterTable) + onSave?: () => Promise; // 🆕 EditModal의 handleSave 콜백 // 화면 편집기 관련 isDesignMode?: boolean; // 디자인 모드 여부 @@ -77,5 +78,6 @@ export interface ConditionalSectionViewerProps { formData?: Record; onFormDataChange?: (fieldName: string, value: any) => void; groupedData?: Record[]; // 🆕 그룹 데이터 + onSave?: () => Promise; // 🆕 EditModal의 handleSave 콜백 } diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 3b9b9d9e..ddcf0f18 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -112,6 +112,7 @@ export interface ButtonActionContext { onClose?: () => void; onRefresh?: () => void; onFlowRefresh?: () => void; // 플로우 새로고침 콜백 + onSave?: () => Promise; // 🆕 EditModal의 handleSave 콜백 // 테이블 선택된 행 정보 (다중 선택 액션용) selectedRows?: any[]; @@ -213,9 +214,23 @@ export class ButtonActionExecutor { * 저장 액션 처리 (INSERT/UPDATE 자동 판단 - DB 기반) */ private static async handleSave(config: ButtonActionConfig, context: ButtonActionContext): Promise { - const { formData, originalData, tableName, screenId } = context; + const { formData, originalData, tableName, screenId, onSave } = context; - console.log("💾 [handleSave] 저장 시작:", { formData, tableName, screenId }); + console.log("💾 [handleSave] 저장 시작:", { formData, tableName, screenId, hasOnSave: !!onSave }); + + // 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용 + if (onSave) { + console.log("✅ [handleSave] onSave 콜백 발견 - 콜백 실행"); + try { + await onSave(); + return true; + } catch (error) { + console.error("❌ [handleSave] onSave 콜백 실행 오류:", error); + throw error; + } + } + + console.log("⚠️ [handleSave] onSave 콜백 없음 - 기본 저장 로직 실행"); // 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집) // context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함 From ace80be8e1e27d4feccb13bfd225f1d7a73918c8 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 25 Nov 2025 13:55:00 +0900 Subject: [PATCH 13/18] =?UTF-8?q?N-Level=20=EA=B3=84=EC=B8=B5=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EB=B0=8F=20=EA=B3=B5=EA=B0=84=20=EC=A2=85=EC=86=8D?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PLAN.MD | 1 + PROJECT_STATUS_2025_11_20.md | 1 + .../digitalTwinTemplateController.ts | 163 ++++++++++ backend-node/src/database/MariaDBConnector.ts | 47 ++- .../src/database/PostgreSQLConnector.ts | 34 +- backend-node/src/routes/digitalTwinRoutes.ts | 20 +- .../services/DigitalTwinTemplateService.ts | 172 ++++++++++ .../admin/dashboard/CanvasElement.tsx | 18 ++ .../widgets/YardManagement3DWidget.tsx | 14 +- .../widgets/yard-3d/DigitalTwinEditor.tsx | 303 +++++++++++++++++- .../widgets/yard-3d/DigitalTwinViewer.tsx | 199 +++++++++--- .../yard-3d/HIERARCHY_MIGRATION_GUIDE.md | 1 + .../widgets/yard-3d/HierarchyConfigPanel.tsx | 216 ++++++++++--- .../widgets/yard-3d/spatialContainment.ts | 1 + frontend/lib/api/digitalTwin.ts | 72 +++++ 15 files changed, 1120 insertions(+), 142 deletions(-) create mode 100644 backend-node/src/controllers/digitalTwinTemplateController.ts create mode 100644 backend-node/src/services/DigitalTwinTemplateService.ts diff --git a/PLAN.MD b/PLAN.MD index 7c3b1007..787bef69 100644 --- a/PLAN.MD +++ b/PLAN.MD @@ -25,3 +25,4 @@ Digital Twin 에디터(`DigitalTwinEditor.tsx`)에서 발생한 런타임 에러 ## 진행 상태 - [진행중] 1단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중 + diff --git a/PROJECT_STATUS_2025_11_20.md b/PROJECT_STATUS_2025_11_20.md index 570dd789..1fe76c86 100644 --- a/PROJECT_STATUS_2025_11_20.md +++ b/PROJECT_STATUS_2025_11_20.md @@ -55,3 +55,4 @@ - `backend-node/src/routes/digitalTwinRoutes.ts` - `db/migrations/042_refactor_digital_twin_hierarchy.sql` + diff --git a/backend-node/src/controllers/digitalTwinTemplateController.ts b/backend-node/src/controllers/digitalTwinTemplateController.ts new file mode 100644 index 00000000..882d8e62 --- /dev/null +++ b/backend-node/src/controllers/digitalTwinTemplateController.ts @@ -0,0 +1,163 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { + DigitalTwinTemplateService, + DigitalTwinLayoutTemplate, +} from "../services/DigitalTwinTemplateService"; + +export const listMappingTemplates = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + } + + const externalDbConnectionId = req.query.externalDbConnectionId + ? Number(req.query.externalDbConnectionId) + : undefined; + const layoutType = + typeof req.query.layoutType === "string" + ? req.query.layoutType + : undefined; + + const result = await DigitalTwinTemplateService.listTemplates( + companyCode, + { + externalDbConnectionId, + layoutType, + }, + ); + + if (!result.success) { + return res.status(500).json({ + success: false, + message: result.message, + error: result.error, + }); + } + + return res.json({ + success: true, + data: result.data as DigitalTwinLayoutTemplate[], + }); + } catch (error: any) { + return res.status(500).json({ + success: false, + message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +export const getMappingTemplateById = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + const companyCode = req.user?.companyCode; + const { id } = req.params; + + if (!companyCode) { + return res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + } + + const result = await DigitalTwinTemplateService.getTemplateById( + companyCode, + id, + ); + + if (!result.success) { + return res.status(404).json({ + success: false, + message: result.message || "매핑 템플릿을 찾을 수 없습니다.", + error: result.error, + }); + } + + return res.json({ + success: true, + data: result.data, + }); + } catch (error: any) { + return res.status(500).json({ + success: false, + message: "매핑 템플릿 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +export const createMappingTemplate = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + + if (!companyCode || !userId) { + return res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + } + + const { + name, + description, + externalDbConnectionId, + layoutType, + config, + } = req.body; + + if (!name || !externalDbConnectionId || !config) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다.", + }); + } + + const result = await DigitalTwinTemplateService.createTemplate( + companyCode, + userId, + { + name, + description, + externalDbConnectionId, + layoutType, + config, + }, + ); + + if (!result.success || !result.data) { + return res.status(500).json({ + success: false, + message: result.message || "매핑 템플릿 생성 중 오류가 발생했습니다.", + error: result.error, + }); + } + + return res.status(201).json({ + success: true, + data: result.data, + }); + } catch (error: any) { + return res.status(500).json({ + success: false, + message: "매핑 템플릿 생성 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + + + diff --git a/backend-node/src/database/MariaDBConnector.ts b/backend-node/src/database/MariaDBConnector.ts index d104b9e3..cde351dc 100644 --- a/backend-node/src/database/MariaDBConnector.ts +++ b/backend-node/src/database/MariaDBConnector.ts @@ -95,15 +95,13 @@ export class MariaDBConnector implements DatabaseConnector { ORDER BY TABLE_NAME; `); - const tables: TableInfo[] = []; - for (const row of rows as any[]) { - const columns = await this.getColumns(row.table_name); - tables.push({ - table_name: row.table_name, - description: row.description || null, - columns: columns, - }); - } + // 테이블 목록만 반환 (컬럼 정보는 getColumns에서 개별 조회) + const tables: TableInfo[] = (rows as any[]).map((row) => ({ + table_name: row.table_name, + description: row.description || null, + columns: [], + })); + await this.disconnect(); return tables; } catch (error: any) { @@ -121,14 +119,29 @@ export class MariaDBConnector implements DatabaseConnector { const [rows] = await this.connection!.query( ` SELECT - COLUMN_NAME as column_name, - DATA_TYPE as data_type, - IS_NULLABLE as is_nullable, - COLUMN_DEFAULT as column_default, - COLUMN_COMMENT as description - FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? - ORDER BY ORDINAL_POSITION; + c.COLUMN_NAME AS column_name, + c.DATA_TYPE AS data_type, + c.IS_NULLABLE AS is_nullable, + c.COLUMN_DEFAULT AS column_default, + c.COLUMN_COMMENT AS description, + CASE + WHEN tc.CONSTRAINT_TYPE = 'PRIMARY KEY' THEN 'YES' + ELSE 'NO' + END AS is_primary_key + FROM information_schema.COLUMNS c + LEFT JOIN information_schema.KEY_COLUMN_USAGE k + ON c.TABLE_SCHEMA = k.TABLE_SCHEMA + AND c.TABLE_NAME = k.TABLE_NAME + AND c.COLUMN_NAME = k.COLUMN_NAME + LEFT JOIN information_schema.TABLE_CONSTRAINTS tc + ON k.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA + AND k.CONSTRAINT_NAME = tc.CONSTRAINT_NAME + AND k.TABLE_SCHEMA = tc.TABLE_SCHEMA + AND k.TABLE_NAME = tc.TABLE_NAME + AND tc.CONSTRAINT_TYPE = 'PRIMARY KEY' + WHERE c.TABLE_SCHEMA = DATABASE() + AND c.TABLE_NAME = ? + ORDER BY c.ORDINAL_POSITION; `, [tableName] ); diff --git a/backend-node/src/database/PostgreSQLConnector.ts b/backend-node/src/database/PostgreSQLConnector.ts index 52f9ce19..2461cc53 100644 --- a/backend-node/src/database/PostgreSQLConnector.ts +++ b/backend-node/src/database/PostgreSQLConnector.ts @@ -210,15 +210,33 @@ export class PostgreSQLConnector implements DatabaseConnector { const result = await tempClient.query( ` SELECT - column_name, - data_type, - is_nullable, - column_default, - col_description(c.oid, a.attnum) as column_comment + isc.column_name, + isc.data_type, + isc.is_nullable, + isc.column_default, + col_description(c.oid, a.attnum) as column_comment, + CASE + WHEN tc.constraint_type = 'PRIMARY KEY' THEN 'YES' + ELSE 'NO' + END AS is_primary_key FROM information_schema.columns isc - LEFT JOIN pg_class c ON c.relname = isc.table_name - LEFT JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = isc.column_name - WHERE isc.table_schema = 'public' AND isc.table_name = $1 + LEFT JOIN pg_class c + ON c.relname = isc.table_name + AND c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = isc.table_schema) + LEFT JOIN pg_attribute a + ON a.attrelid = c.oid + AND a.attname = isc.column_name + LEFT JOIN information_schema.key_column_usage k + ON k.table_name = isc.table_name + AND k.table_schema = isc.table_schema + AND k.column_name = isc.column_name + LEFT JOIN information_schema.table_constraints tc + ON tc.constraint_name = k.constraint_name + AND tc.table_schema = k.table_schema + AND tc.table_name = k.table_name + AND tc.constraint_type = 'PRIMARY KEY' + WHERE isc.table_schema = 'public' + AND isc.table_name = $1 ORDER BY isc.ordinal_position; `, [tableName] diff --git a/backend-node/src/routes/digitalTwinRoutes.ts b/backend-node/src/routes/digitalTwinRoutes.ts index 904096f7..467813f0 100644 --- a/backend-node/src/routes/digitalTwinRoutes.ts +++ b/backend-node/src/routes/digitalTwinRoutes.ts @@ -9,6 +9,11 @@ import { updateLayout, deleteLayout, } from "../controllers/digitalTwinLayoutController"; +import { + listMappingTemplates, + getMappingTemplateById, + createMappingTemplate, +} from "../controllers/digitalTwinTemplateController"; // 외부 DB 데이터 조회 import { @@ -27,11 +32,16 @@ const router = express.Router(); router.use(authenticateToken); // ========== 레이아웃 관리 API ========== -router.get("/layouts", getLayouts); // 레이아웃 목록 -router.get("/layouts/:id", getLayoutById); // 레이아웃 상세 -router.post("/layouts", createLayout); // 레이아웃 생성 -router.put("/layouts/:id", updateLayout); // 레이아웃 수정 -router.delete("/layouts/:id", deleteLayout); // 레이아웃 삭제 +router.get("/layouts", getLayouts); // 레이아웃 목록 +router.get("/layouts/:id", getLayoutById); // 레이아웃 상세 +router.post("/layouts", createLayout); // 레이아웃 생성 +router.put("/layouts/:id", updateLayout); // 레이아웃 수정 +router.delete("/layouts/:id", deleteLayout); // 레이아웃 삭제 + +// ========== 매핑 템플릿 API ========== +router.get("/mapping-templates", listMappingTemplates); +router.get("/mapping-templates/:id", getMappingTemplateById); +router.post("/mapping-templates", createMappingTemplate); // ========== 외부 DB 데이터 조회 API ========== diff --git a/backend-node/src/services/DigitalTwinTemplateService.ts b/backend-node/src/services/DigitalTwinTemplateService.ts new file mode 100644 index 00000000..d4818b3a --- /dev/null +++ b/backend-node/src/services/DigitalTwinTemplateService.ts @@ -0,0 +1,172 @@ +import { pool } from "../database/db"; +import logger from "../utils/logger"; + +export interface DigitalTwinLayoutTemplate { + id: string; + company_code: string; + name: string; + description?: string | null; + external_db_connection_id: number; + layout_type: string; + config: any; + created_by: string; + created_at: Date; + updated_by: string; + updated_at: Date; +} + +interface ServiceResponse { + success: boolean; + data?: T; + message?: string; + error?: string; +} + +export class DigitalTwinTemplateService { + static async listTemplates( + companyCode: string, + options: { externalDbConnectionId?: number; layoutType?: string } = {}, + ): Promise> { + try { + const params: any[] = [companyCode]; + let paramIndex = 2; + + let query = ` + SELECT * + FROM digital_twin_layout_template + WHERE company_code = $1 + `; + + if (options.layoutType) { + query += ` AND layout_type = $${paramIndex++}`; + params.push(options.layoutType); + } + + if (options.externalDbConnectionId) { + query += ` AND external_db_connection_id = $${paramIndex++}`; + params.push(options.externalDbConnectionId); + } + + query += ` + ORDER BY updated_at DESC, name ASC + `; + + const result = await pool.query(query, params); + + logger.info("디지털 트윈 매핑 템플릿 목록 조회", { + companyCode, + count: result.rowCount, + }); + + return { + success: true, + data: result.rows as DigitalTwinLayoutTemplate[], + }; + } catch (error: any) { + logger.error("디지털 트윈 매핑 템플릿 목록 조회 실패", error); + return { + success: false, + error: error.message, + message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.", + }; + } + } + + static async getTemplateById( + companyCode: string, + id: string, + ): Promise> { + try { + const query = ` + SELECT * + FROM digital_twin_layout_template + WHERE id = $1 AND company_code = $2 + `; + + const result = await pool.query(query, [id, companyCode]); + + if (result.rowCount === 0) { + return { + success: false, + message: "매핑 템플릿을 찾을 수 없습니다.", + }; + } + + return { + success: true, + data: result.rows[0] as DigitalTwinLayoutTemplate, + }; + } catch (error: any) { + logger.error("디지털 트윈 매핑 템플릿 조회 실패", error); + return { + success: false, + error: error.message, + message: "매핑 템플릿 조회 중 오류가 발생했습니다.", + }; + } + } + + static async createTemplate( + companyCode: string, + userId: string, + payload: { + name: string; + description?: string; + externalDbConnectionId: number; + layoutType?: string; + config: any; + }, + ): Promise> { + try { + const query = ` + INSERT INTO digital_twin_layout_template ( + company_code, + name, + description, + external_db_connection_id, + layout_type, + config, + created_by, + created_at, + updated_by, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $7, NOW()) + RETURNING * + `; + + const values = [ + companyCode, + payload.name, + payload.description || null, + payload.externalDbConnectionId, + payload.layoutType || "yard-3d", + JSON.stringify(payload.config), + userId, + ]; + + const result = await pool.query(query, values); + + logger.info("디지털 트윈 매핑 템플릿 생성", { + companyCode, + templateId: result.rows[0].id, + externalDbConnectionId: payload.externalDbConnectionId, + }); + + return { + success: true, + data: result.rows[0] as DigitalTwinLayoutTemplate, + }; + } catch (error: any) { + logger.error("디지털 트윈 매핑 템플릿 생성 실패", error); + return { + success: false, + error: error.message, + message: "매핑 템플릿 생성 중 오류가 발생했습니다.", + }; + } + } +} + + + diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 2bb85051..beb1e483 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -312,6 +312,24 @@ export function CanvasElement({ return; } + // 위젯 테두리(바깥쪽 영역)를 클릭한 경우에만 선택/드래그 허용 + // - 내용 영역을 클릭해도 대시보드 설정 사이드바가 튀어나오지 않도록 하기 위함 + const container = elementRef.current; + if (container) { + const rect = container.getBoundingClientRect(); + const BORDER_HIT_WIDTH = 8; // px, 테두리로 인식할 범위 + const isOnBorder = + e.clientX <= rect.left + BORDER_HIT_WIDTH || + e.clientX >= rect.right - BORDER_HIT_WIDTH || + e.clientY <= rect.top + BORDER_HIT_WIDTH || + e.clientY >= rect.bottom - BORDER_HIT_WIDTH; + + if (!isOnBorder) { + // 테두리가 아닌 내부 클릭은 선택/드래그 처리하지 않음 + return; + } + } + // 선택되지 않은 경우에만 선택 처리 if (!isSelected) { onSelect(element.id); diff --git a/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx b/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx index 91f58650..815ef07c 100644 --- a/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx @@ -145,11 +145,17 @@ export default function YardManagement3DWidget({ // 편집 모드: 편집 중인 경우 DigitalTwinEditor 표시 if (isEditMode && editingLayout) { return ( -
- e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > +
); diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index a81e75dc..9a71c338 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -20,13 +20,24 @@ import { getMaterials, getHierarchyData, getChildrenData, + getMappingTemplates, + createMappingTemplate, type HierarchyData, + type DigitalTwinMappingTemplate, } from "@/lib/api/digitalTwin"; 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"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; // 백엔드 DB 객체 타입 (snake_case) interface DbObject { @@ -94,6 +105,14 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi const [loadingMaterials, setLoadingMaterials] = useState(false); const [showMaterialPanel, setShowMaterialPanel] = useState(false); + // 매핑 템플릿 + const [mappingTemplates, setMappingTemplates] = useState([]); + const [selectedTemplateId, setSelectedTemplateId] = useState(""); + const [loadingTemplates, setLoadingTemplates] = useState(false); + const [isSaveTemplateDialogOpen, setIsSaveTemplateDialogOpen] = useState(false); + const [newTemplateName, setNewTemplateName] = useState(""); + const [newTemplateDescription, setNewTemplateDescription] = useState(""); + // 동적 계층 구조 설정 const [hierarchyConfig, setHierarchyConfig] = useState(null); const [availableTables, setAvailableTables] = useState>([]); @@ -166,6 +185,36 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi })); }, [placedObjects, layoutId]); + // 외부 DB 또는 레이아웃 타입이 변경될 때 템플릿 목록 로드 + useEffect(() => { + const loadTemplates = async () => { + if (!selectedDbConnection) { + setMappingTemplates([]); + setSelectedTemplateId(""); + return; + } + + try { + setLoadingTemplates(true); + const response = await getMappingTemplates({ + externalDbConnectionId: selectedDbConnection, + layoutType: "yard-3d", + }); + if (response.success && response.data) { + setMappingTemplates(response.data); + } else { + setMappingTemplates([]); + } + } catch (error) { + console.error("매핑 템플릿 목록 조회 실패:", error); + } finally { + setLoadingTemplates(false); + } + }; + + loadTemplates(); + }, [selectedDbConnection]); + // 외부 DB 연결 목록 로드 useEffect(() => { const loadExternalDbConnections = async () => { @@ -208,12 +257,23 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi const loadTables = async () => { try { setLoadingTables(true); - const { getTables } = await import("@/lib/api/digitalTwin"); - const response = await getTables(selectedDbConnection); + // 외부 DB 메타데이터 API 사용 (테이블 + 설명) + const response = await ExternalDbConnectionAPI.getTables(selectedDbConnection); if (response.success && response.data) { - // 테이블 정보 전체 저장 (이름 + 설명) - setAvailableTables(response.data); - console.log("📋 테이블 목록:", response.data); + const rawTables = response.data as any[]; + const normalized = rawTables.map((t: any) => + typeof t === "string" + ? { table_name: t } + : { + table_name: t.table_name || t.TABLE_NAME || String(t), + description: t.description || t.table_description || undefined, + }, + ); + setAvailableTables(normalized); + console.log("📋 테이블 목록:", normalized); + } else { + setAvailableTables([]); + console.warn("테이블 목록 조회 실패:", response.message || response.error); } } catch (error) { console.error("테이블 목록 조회 실패:", error); @@ -976,6 +1036,110 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi } }; + // 매핑 템플릿 적용 + const handleApplyTemplate = (templateId: string) => { + if (!templateId) return; + const template = mappingTemplates.find((t) => t.id === templateId); + if (!template) { + toast({ + variant: "destructive", + title: "템플릿 적용 실패", + description: "선택한 템플릿을 찾을 수 없습니다.", + }); + return; + } + + const config = template.config as HierarchyConfig; + setHierarchyConfig(config); + + // 선택된 테이블 정보 동기화 + const newSelectedTables: any = { + warehouse: config.warehouse?.tableName || "", + area: "", + location: "", + material: "", + }; + + if (config.levels && config.levels.length > 0) { + // 레벨 1 = Area + if (config.levels[0]?.tableName) { + newSelectedTables.area = config.levels[0].tableName; + } + // 레벨 2 = Location + if (config.levels[1]?.tableName) { + newSelectedTables.location = config.levels[1].tableName; + } + } + + if (config.material?.tableName) { + newSelectedTables.material = config.material.tableName; + } + + setSelectedTables(newSelectedTables); + setSelectedWarehouse(config.warehouseKey || null); + setHasUnsavedChanges(true); + + toast({ + title: "템플릿 적용 완료", + description: `"${template.name}" 템플릿이 적용되었습니다.`, + }); + }; + + // 매핑 템플릿 저장 + const handleSaveTemplate = async () => { + if (!selectedDbConnection || !hierarchyConfig) { + toast({ + variant: "destructive", + title: "템플릿 저장 불가", + description: "외부 DB와 계층 설정을 먼저 완료해주세요.", + }); + return; + } + + if (!newTemplateName.trim()) { + toast({ + variant: "destructive", + title: "템플릿 이름 필요", + description: "템플릿 이름을 입력해주세요.", + }); + return; + } + + try { + const response = await createMappingTemplate({ + name: newTemplateName.trim(), + description: newTemplateDescription.trim() || undefined, + externalDbConnectionId: selectedDbConnection, + layoutType: "yard-3d", + config: hierarchyConfig, + }); + + if (response.success && response.data) { + setMappingTemplates((prev) => [response.data!, ...prev]); + setIsSaveTemplateDialogOpen(false); + setNewTemplateName(""); + setNewTemplateDescription(""); + toast({ + title: "템플릿 저장 완료", + description: `"${response.data.name}" 템플릿이 저장되었습니다.`, + }); + } else { + toast({ + variant: "destructive", + title: "템플릿 저장 실패", + description: response.error || "템플릿을 저장하지 못했습니다.", + }); + } + } catch (error) { + console.error("매핑 템플릿 저장 실패:", error); + toast({ + variant: "destructive", + title: "템플릿 저장 실패", + description: "템플릿을 저장하는 중 오류가 발생했습니다.", + }); + } + }; + // 객체 이동 const handleObjectMove = (objectId: number, newX: number, newZ: number, newY?: number) => { setPlacedObjects((prev) => { @@ -1288,7 +1452,8 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
- {/* 도구 팔레트 */} + {/* 도구 팔레트 (현재 숨김 처리 - 나중에 재사용 가능) */} + {/*
도구: {[ @@ -1314,6 +1479,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi ); })}
+ */} {/* 메인 영역 */}
@@ -1329,6 +1495,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi onValueChange={(value) => { setSelectedDbConnection(parseInt(value)); setSelectedWarehouse(null); + setSelectedTemplateId(""); setHasUnsavedChanges(true); }} > @@ -1343,6 +1510,65 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi ))} + + {/* 매핑 템플릿 선택/저장 */} + {selectedDbConnection && ( +
+
+ + +
+
+ + +
+
+ )}
{/* 계층 설정 패널 (신규) */} @@ -1387,13 +1613,20 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi }} onLoadColumns={async (tableName: string) => { try { - const response = await ExternalDbConnectionAPI.getTableColumns(selectedDbConnection, tableName); + const response = await ExternalDbConnectionAPI.getTableColumns( + selectedDbConnection, + tableName, + ); if (response.success && response.data) { - // 컬럼 정보 객체 배열로 반환 (이름 + 설명) + // 컬럼 정보 객체 배열로 반환 (이름 + 설명 + PK 플래그) return response.data.map((col: any) => ({ - column_name: typeof col === "string" ? col : col.column_name || col.COLUMN_NAME || String(col), + column_name: + typeof col === "string" + ? col + : col.column_name || col.COLUMN_NAME || String(col), data_type: col.data_type || col.DATA_TYPE, description: col.description || col.COLUMN_COMMENT || undefined, + is_primary_key: col.is_primary_key ?? col.IS_PRIMARY_KEY, })); } return []; @@ -1984,6 +2217,58 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi )}
+ {/* 매핑 템플릿 저장 다이얼로그 */} + + + + 매핑 템플릿 저장 + + 현재 창고/계층/자재 설정을 템플릿으로 저장하여 다른 레이아웃에서 재사용할 수 있습니다. + + +
+
+ + setNewTemplateName(e.target.value)} + placeholder="예: 동연 야드 표준 매핑" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + setNewTemplateDescription(e.target.value)} + placeholder="이 템플릿에 대한 설명을 입력하세요" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + + + +
+
); } 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.name} +
+ +
+

+ 위치: ({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({