From 33350a4d46e559bdb44eb954d089843033d5db69 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 20 Nov 2025 10:15:58 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Digital=20Twin=20Editor=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EB=A7=A4=ED=95=91=20UI=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/scripts/test-digital-twin-db.ts | 209 +++ backend-node/src/app.ts | 2 + .../controllers/digitalTwinDataController.ts | 273 ++++ .../digitalTwinLayoutController.ts | 386 ++++++ backend-node/src/routes/digitalTwinRoutes.ts | 66 + .../admin/dashboard/CanvasElement.tsx | 176 +-- .../admin/dashboard/WidgetConfigSidebar.tsx | 14 + .../widgets/YardManagement3DWidget.tsx | 33 +- .../widgets/yard-3d/DigitalTwinEditor.tsx | 1119 +++++++++++++++-- .../widgets/yard-3d/DigitalTwinViewer.tsx | 683 ++++++---- .../widgets/yard-3d/Yard3DCanvas.tsx | 138 +- frontend/lib/api/digitalTwin.ts | 215 ++++ frontend/types/digitalTwin.ts | 155 +++ 13 files changed, 3007 insertions(+), 462 deletions(-) create mode 100644 backend-node/scripts/test-digital-twin-db.ts create mode 100644 backend-node/src/controllers/digitalTwinDataController.ts create mode 100644 backend-node/src/controllers/digitalTwinLayoutController.ts create mode 100644 backend-node/src/routes/digitalTwinRoutes.ts create mode 100644 frontend/lib/api/digitalTwin.ts create mode 100644 frontend/types/digitalTwin.ts diff --git a/backend-node/scripts/test-digital-twin-db.ts b/backend-node/scripts/test-digital-twin-db.ts new file mode 100644 index 00000000..7d0efce7 --- /dev/null +++ b/backend-node/scripts/test-digital-twin-db.ts @@ -0,0 +1,209 @@ +/** + * 디지털 트윈 외부 DB (DO_DY) 연결 및 쿼리 테스트 스크립트 + * READ-ONLY: SELECT 쿼리만 실행 + */ + +import { Pool } from "pg"; +import mysql from "mysql2/promise"; +import { CredentialEncryption } from "../src/utils/credentialEncryption"; + +async function testDigitalTwinDb() { + // 내부 DB 연결 (연결 정보 저장용) + const internalPool = new Pool({ + host: process.env.DB_HOST || "localhost", + port: parseInt(process.env.DB_PORT || "5432"), + database: process.env.DB_NAME || "plm", + user: process.env.DB_USER || "postgres", + password: process.env.DB_PASSWORD || "ph0909!!", + }); + + const encryptionKey = + process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-for-development"; + const encryption = new CredentialEncryption(encryptionKey); + + try { + console.log("🚀 디지털 트윈 외부 DB 연결 테스트 시작\n"); + + // 디지털 트윈 외부 DB 연결 정보 + const digitalTwinConnection = { + name: "디지털트윈_DO_DY", + description: "디지털 트윈 후판(자재) 재고 정보 데이터베이스 (MariaDB)", + dbType: "mysql", // MariaDB는 MySQL 프로토콜 사용 + host: "1.240.13.83", + port: 4307, + databaseName: "DO_DY", + username: "root", + password: "pohangms619!#", + sslEnabled: false, + isActive: true, + }; + + console.log("📝 연결 정보:"); + console.log(` - 이름: ${digitalTwinConnection.name}`); + console.log(` - DB 타입: ${digitalTwinConnection.dbType}`); + console.log(` - 호스트: ${digitalTwinConnection.host}:${digitalTwinConnection.port}`); + console.log(` - 데이터베이스: ${digitalTwinConnection.databaseName}\n`); + + // 1. 외부 DB 직접 연결 테스트 + console.log("🔍 외부 DB 직접 연결 테스트 중..."); + + const externalConnection = await mysql.createConnection({ + host: digitalTwinConnection.host, + port: digitalTwinConnection.port, + database: digitalTwinConnection.databaseName, + user: digitalTwinConnection.username, + password: digitalTwinConnection.password, + connectTimeout: 10000, + }); + + console.log("✅ 외부 DB 연결 성공!\n"); + + // 2. SELECT 쿼리 실행 + console.log("📊 WSTKKY 테이블 쿼리 실행 중...\n"); + + const query = ` + SELECT + SKUMKEY -- 제품번호 + , SKUDESC -- 자재명 + , SKUTHIC -- 두께 + , SKUWIDT -- 폭 + , SKULENG -- 길이 + , SKUWEIG -- 중량 + , STOTQTY -- 수량 + , SUOMKEY -- 단위 + FROM DO_DY.WSTKKY + LIMIT 10 + `; + + const [rows] = await externalConnection.execute(query); + + console.log("✅ 쿼리 실행 성공!\n"); + console.log(`📦 조회된 데이터: ${Array.isArray(rows) ? rows.length : 0}건\n`); + + if (Array.isArray(rows) && rows.length > 0) { + console.log("🔍 샘플 데이터 (첫 3건):\n"); + rows.slice(0, 3).forEach((row: any, index: number) => { + console.log(`[${index + 1}]`); + console.log(` 제품번호(SKUMKEY): ${row.SKUMKEY}`); + console.log(` 자재명(SKUDESC): ${row.SKUDESC}`); + console.log(` 두께(SKUTHIC): ${row.SKUTHIC}`); + console.log(` 폭(SKUWIDT): ${row.SKUWIDT}`); + console.log(` 길이(SKULENG): ${row.SKULENG}`); + console.log(` 중량(SKUWEIG): ${row.SKUWEIG}`); + console.log(` 수량(STOTQTY): ${row.STOTQTY}`); + console.log(` 단위(SUOMKEY): ${row.SUOMKEY}\n`); + }); + + // 전체 데이터 JSON 출력 + console.log("📄 전체 데이터 (JSON):"); + console.log(JSON.stringify(rows, null, 2)); + console.log("\n"); + } + + await externalConnection.end(); + + // 3. 내부 DB에 연결 정보 저장 + console.log("💾 내부 DB에 연결 정보 저장 중..."); + + const encryptedPassword = encryption.encrypt(digitalTwinConnection.password); + + // 중복 체크 + const existingResult = await internalPool.query( + "SELECT id FROM flow_external_db_connection WHERE name = $1", + [digitalTwinConnection.name] + ); + + let connectionId: number; + + if (existingResult.rows.length > 0) { + connectionId = existingResult.rows[0].id; + console.log(`⚠️ 이미 존재하는 연결 (ID: ${connectionId})`); + + // 기존 연결 업데이트 + await internalPool.query( + `UPDATE flow_external_db_connection + SET description = $1, + db_type = $2, + host = $3, + port = $4, + database_name = $5, + username = $6, + password_encrypted = $7, + ssl_enabled = $8, + is_active = $9, + updated_at = NOW(), + updated_by = 'system' + WHERE name = $10`, + [ + digitalTwinConnection.description, + digitalTwinConnection.dbType, + digitalTwinConnection.host, + digitalTwinConnection.port, + digitalTwinConnection.databaseName, + digitalTwinConnection.username, + encryptedPassword, + digitalTwinConnection.sslEnabled, + digitalTwinConnection.isActive, + digitalTwinConnection.name, + ] + ); + console.log(`✅ 연결 정보 업데이트 완료`); + } else { + // 새 연결 추가 + const result = await internalPool.query( + `INSERT INTO flow_external_db_connection ( + name, + description, + db_type, + host, + port, + database_name, + username, + password_encrypted, + ssl_enabled, + is_active, + created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'system') + RETURNING id`, + [ + digitalTwinConnection.name, + digitalTwinConnection.description, + digitalTwinConnection.dbType, + digitalTwinConnection.host, + digitalTwinConnection.port, + digitalTwinConnection.databaseName, + digitalTwinConnection.username, + encryptedPassword, + digitalTwinConnection.sslEnabled, + digitalTwinConnection.isActive, + ] + ); + connectionId = result.rows[0].id; + console.log(`✅ 새 연결 추가 완료 (ID: ${connectionId})`); + } + + console.log("\n✅ 모든 테스트 완료!"); + console.log(`\n📌 연결 ID: ${connectionId}`); + console.log(" 이 ID를 사용하여 플로우 관리나 제어 관리에서 외부 DB를 연동할 수 있습니다."); + + } catch (error: any) { + console.error("\n❌ 오류 발생:", error.message); + console.error("상세 정보:", error); + throw error; + } finally { + await internalPool.end(); + } +} + +// 스크립트 실행 +testDigitalTwinDb() + .then(() => { + console.log("\n🎉 스크립트 완료"); + process.exit(0); + }) + .catch((error) => { + console.error("\n💥 스크립트 실패:", error); + process.exit(1); + }); + + diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 37936f36..9ffaaa8a 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -59,6 +59,7 @@ import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리 import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리 import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D //import materialRoutes from "./routes/materialRoutes"; // 자재 관리 +import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제) import flowRoutes from "./routes/flowRoutes"; // 플로우 관리 import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결 import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리 @@ -221,6 +222,7 @@ app.use("/api/bookings", bookingRoutes); // 예약 요청 관리 app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회 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 연결 app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지) app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리 diff --git a/backend-node/src/controllers/digitalTwinDataController.ts b/backend-node/src/controllers/digitalTwinDataController.ts new file mode 100644 index 00000000..51dd85d8 --- /dev/null +++ b/backend-node/src/controllers/digitalTwinDataController.ts @@ -0,0 +1,273 @@ +import { Request, Response } from "express"; +import { pool, queryOne } from "../database/db"; +import logger from "../utils/logger"; +import { PasswordEncryption } from "../utils/passwordEncryption"; +import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory"; + +// 외부 DB 커넥터를 가져오는 헬퍼 함수 +export async function getExternalDbConnector(connectionId: number) { + // 외부 DB 연결 정보 조회 + const connection = await queryOne( + `SELECT * FROM external_db_connections WHERE id = $1`, + [connectionId] + ); + + if (!connection) { + throw new Error(`외부 DB 연결 정보를 찾을 수 없습니다. ID: ${connectionId}`); + } + + // 패스워드 복호화 + const decryptedPassword = PasswordEncryption.decrypt(connection.password); + + // DB 연결 설정 + const config = { + host: connection.host, + port: connection.port, + user: connection.username, + password: decryptedPassword, + database: connection.database_name, + }; + + // DB 커넥터 생성 + return await DatabaseConnectorFactory.createConnector( + connection.db_type || "mariadb", + config, + connectionId + ); +} + +// 창고 목록 조회 (사용자 지정 테이블) +export const getWarehouses = async (req: Request, res: Response): Promise => { + try { + const { externalDbConnectionId, tableName } = req.query; + + if (!externalDbConnectionId) { + return res.status(400).json({ + success: false, + message: "외부 DB 연결 ID가 필요합니다.", + }); + } + + if (!tableName) { + return res.status(400).json({ + success: false, + message: "테이블명이 필요합니다.", + }); + } + + const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + + // 테이블명을 사용하여 모든 컬럼 조회 + const query = `SELECT * FROM ${tableName} LIMIT 100`; + + const result = await connector.executeQuery(query); + + logger.info("창고 목록 조회", { + externalDbConnectionId, + tableName, + count: result.rows.length, + data: result.rows, // 실제 데이터 확인 + }); + + return res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("창고 목록 조회 실패", error); + return res.status(500).json({ + success: false, + message: "창고 목록 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +// Area 목록 조회 (사용자 지정 테이블) +export const getAreas = async (req: Request, res: Response): Promise => { + try { + const { externalDbConnectionId, tableName, warehouseKey } = req.query; + + if (!externalDbConnectionId || !tableName) { + return res.status(400).json({ + success: false, + message: "외부 DB 연결 ID와 테이블명이 필요합니다.", + }); + } + + const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + + // 테이블명을 사용하여 모든 컬럼 조회 + let query = `SELECT * FROM ${tableName}`; + + if (warehouseKey) { + query += ` WHERE WAREKEY = '${warehouseKey}'`; + } + + query += ` LIMIT 1000`; + + const result = await connector.executeQuery(query); + + logger.info("Area 목록 조회", { + externalDbConnectionId, + tableName, + warehouseKey, + count: result.rows.length, + }); + + return res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("Area 목록 조회 실패", error); + return res.status(500).json({ + success: false, + message: "Area 목록 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +// Location 목록 조회 (사용자 지정 테이블) +export const getLocations = async (req: Request, res: Response): Promise => { + try { + const { externalDbConnectionId, tableName, areaKey } = req.query; + + if (!externalDbConnectionId || !tableName) { + return res.status(400).json({ + success: false, + message: "외부 DB 연결 ID와 테이블명이 필요합니다.", + }); + } + + const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + + // 테이블명을 사용하여 모든 컬럼 조회 + let query = `SELECT * FROM ${tableName}`; + + if (areaKey) { + query += ` WHERE AREAKEY = '${areaKey}'`; + } + + query += ` LIMIT 1000`; + + const result = await connector.executeQuery(query); + + logger.info("Location 목록 조회", { + externalDbConnectionId, + tableName, + areaKey, + count: result.rows.length, + }); + + return res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("Location 목록 조회 실패", error); + return res.status(500).json({ + success: false, + message: "Location 목록 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +// 자재 목록 조회 (사용자 지정 테이블) +export const getMaterials = async (req: Request, res: Response): Promise => { + try { + const { externalDbConnectionId, tableName, locaKey } = req.query; + + if (!externalDbConnectionId || !tableName) { + return res.status(400).json({ + success: false, + message: "외부 DB 연결 ID와 테이블명이 필요합니다.", + }); + } + + const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + + // 테이블명을 사용하여 모든 컬럼 조회 + let query = `SELECT * FROM ${tableName}`; + + if (locaKey) { + query += ` WHERE LOCAKEY = '${locaKey}'`; + } + + query += ` LIMIT 1000`; + + const result = await connector.executeQuery(query); + + logger.info("자재 목록 조회", { + externalDbConnectionId, + tableName, + locaKey, + count: result.rows.length, + }); + + return res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("자재 목록 조회 실패", error); + return res.status(500).json({ + success: false, + message: "자재 목록 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +// Location별 자재 개수 조회 (배치 시 사용 - 사용자 지정 테이블) +export const getMaterialCounts = async (req: Request, res: Response): Promise => { + try { + const { externalDbConnectionId, tableName, locaKeys } = req.query; + + if (!externalDbConnectionId || !tableName || !locaKeys) { + return res.status(400).json({ + success: false, + message: "외부 DB 연결 ID, 테이블명, Location 키 목록이 필요합니다.", + }); + } + + const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + + // locaKeys는 쉼표로 구분된 문자열 + const locaKeyArray = (locaKeys as string).split(","); + const quotedKeys = locaKeyArray.map((key) => `'${key}'`).join(","); + + const query = ` + SELECT + LOCAKEY, + COUNT(*) as material_count, + MAX(LOLAYER) as max_layer + FROM ${tableName} + WHERE LOCAKEY IN (${quotedKeys}) + GROUP BY LOCAKEY + `; + + const result = await connector.executeQuery(query); + + logger.info("자재 개수 조회", { + externalDbConnectionId, + tableName, + locaKeyCount: locaKeyArray.length, + }); + + return res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("자재 개수 조회 실패", error); + return res.status(500).json({ + success: false, + message: "자재 개수 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + diff --git a/backend-node/src/controllers/digitalTwinLayoutController.ts b/backend-node/src/controllers/digitalTwinLayoutController.ts new file mode 100644 index 00000000..9e66ecf2 --- /dev/null +++ b/backend-node/src/controllers/digitalTwinLayoutController.ts @@ -0,0 +1,386 @@ +import { Request, Response } from "express"; +import { pool } from "../database/db"; +import logger from "../utils/logger"; + +// 레이아웃 목록 조회 +export const getLayouts = async ( + req: Request, + res: Response +): Promise => { + try { + const companyCode = req.user?.companyCode; + const { externalDbConnectionId, warehouseKey } = req.query; + + let query = ` + SELECT + l.*, + u1.user_name as created_by_name, + u2.user_name as updated_by_name, + COUNT(o.id) as object_count + FROM digital_twin_layout l + LEFT JOIN user_info u1 ON l.created_by = u1.user_id + LEFT JOIN user_info u2 ON l.updated_by = u2.user_id + LEFT JOIN digital_twin_objects o ON l.id = o.layout_id + WHERE l.company_code = $1 + `; + + const params: any[] = [companyCode]; + let paramIndex = 2; + + if (externalDbConnectionId) { + query += ` AND l.external_db_connection_id = $${paramIndex}`; + params.push(externalDbConnectionId); + paramIndex++; + } + + if (warehouseKey) { + query += ` AND l.warehouse_key = $${paramIndex}`; + params.push(warehouseKey); + paramIndex++; + } + + query += ` + GROUP BY l.id, u1.user_name, u2.user_name + ORDER BY l.updated_at DESC + `; + + const result = await pool.query(query, params); + + logger.info("레이아웃 목록 조회", { + companyCode, + count: result.rowCount, + }); + + return res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("레이아웃 목록 조회 실패", error); + return res.status(500).json({ + success: false, + message: "레이아웃 목록 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +// 레이아웃 상세 조회 (객체 포함) +export const getLayoutById = async ( + req: Request, + res: Response +): Promise => { + try { + const companyCode = req.user?.companyCode; + const { id } = req.params; + + // 레이아웃 기본 정보 + const layoutQuery = ` + SELECT l.* + FROM digital_twin_layout l + WHERE l.id = $1 AND l.company_code = $2 + `; + + const layoutResult = await pool.query(layoutQuery, [id, companyCode]); + + if (layoutResult.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "레이아웃을 찾을 수 없습니다.", + }); + } + + // 배치된 객체들 조회 + const objectsQuery = ` + SELECT * + FROM digital_twin_objects + WHERE layout_id = $1 + ORDER BY display_order, created_at + `; + + const objectsResult = await pool.query(objectsQuery, [id]); + + logger.info("레이아웃 상세 조회", { + companyCode, + layoutId: id, + objectCount: objectsResult.rowCount, + }); + + return res.json({ + success: true, + data: { + layout: layoutResult.rows[0], + objects: objectsResult.rows, + }, + }); + } catch (error: any) { + logger.error("레이아웃 상세 조회 실패", error); + return res.status(500).json({ + success: false, + message: "레이아웃 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +// 레이아웃 생성 +export const createLayout = async ( + req: Request, + res: Response +): Promise => { + const client = await pool.connect(); + + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + const { + externalDbConnectionId, + warehouseKey, + layoutName, + description, + objects, + } = req.body; + + await client.query("BEGIN"); + + // 레이아웃 생성 + const layoutQuery = ` + INSERT INTO digital_twin_layout ( + company_code, external_db_connection_id, warehouse_key, + layout_name, description, created_by, updated_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $6) + RETURNING * + `; + + const layoutResult = await client.query(layoutQuery, [ + companyCode, + externalDbConnectionId, + warehouseKey, + layoutName, + description, + userId, + ]); + + const layoutId = layoutResult.rows[0].id; + + // 객체들 저장 + if (objects && objects.length > 0) { + const objectQuery = ` + INSERT INTO digital_twin_objects ( + layout_id, object_type, object_name, + position_x, position_y, position_z, + size_x, size_y, size_z, + rotation, color, + area_key, loca_key, loc_type, + material_count, material_preview_height, + parent_id, display_order, locked + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + `; + + for (const obj of objects) { + await client.query(objectQuery, [ + layoutId, + obj.type, + obj.name, + obj.position.x, + obj.position.y, + obj.position.z, + obj.size.x, + obj.size.y, + obj.size.z, + obj.rotation || 0, + obj.color, + obj.areaKey || null, + obj.locaKey || null, + obj.locType || null, + obj.materialCount || 0, + obj.materialPreview?.height || null, + obj.parentId || null, + obj.displayOrder || 0, + obj.locked || false, + ]); + } + } + + await client.query("COMMIT"); + + logger.info("레이아웃 생성", { + companyCode, + layoutId, + objectCount: objects?.length || 0, + }); + + return res.status(201).json({ + success: true, + data: layoutResult.rows[0], + }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("레이아웃 생성 실패", error); + return res.status(500).json({ + success: false, + message: "레이아웃 생성 중 오류가 발생했습니다.", + error: error.message, + }); + } finally { + client.release(); + } +}; + +// 레이아웃 수정 +export const updateLayout = async ( + req: Request, + res: Response +): Promise => { + const client = await pool.connect(); + + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + const { id } = req.params; + const { layoutName, description, objects } = req.body; + + await client.query("BEGIN"); + + // 레이아웃 기본 정보 수정 + const updateLayoutQuery = ` + UPDATE digital_twin_layout + SET layout_name = $1, + description = $2, + updated_by = $3, + updated_at = NOW() + WHERE id = $4 AND company_code = $5 + RETURNING * + `; + + const layoutResult = await client.query(updateLayoutQuery, [ + layoutName, + description, + userId, + id, + companyCode, + ]); + + if (layoutResult.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ + success: false, + message: "레이아웃을 찾을 수 없습니다.", + }); + } + + // 기존 객체 삭제 + await client.query( + "DELETE FROM digital_twin_objects WHERE layout_id = $1", + [id] + ); + + // 새 객체 저장 + if (objects && objects.length > 0) { + const objectQuery = ` + INSERT INTO digital_twin_objects ( + layout_id, object_type, object_name, + position_x, position_y, position_z, + size_x, size_y, size_z, + rotation, color, + area_key, loca_key, loc_type, + material_count, material_preview_height, + parent_id, display_order, locked + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + `; + + for (const obj of objects) { + await client.query(objectQuery, [ + id, + obj.type, + obj.name, + obj.position.x, + obj.position.y, + obj.position.z, + obj.size.x, + obj.size.y, + obj.size.z, + obj.rotation || 0, + obj.color, + obj.areaKey || null, + obj.locaKey || null, + obj.locType || null, + obj.materialCount || 0, + obj.materialPreview?.height || null, + obj.parentId || null, + obj.displayOrder || 0, + obj.locked || false, + ]); + } + } + + await client.query("COMMIT"); + + logger.info("레이아웃 수정", { + companyCode, + layoutId: id, + objectCount: objects?.length || 0, + }); + + return res.json({ + success: true, + data: layoutResult.rows[0], + }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("레이아웃 수정 실패", error); + return res.status(500).json({ + success: false, + message: "레이아웃 수정 중 오류가 발생했습니다.", + error: error.message, + }); + } finally { + client.release(); + } +}; + +// 레이아웃 삭제 +export const deleteLayout = async ( + req: Request, + res: Response +): Promise => { + try { + const companyCode = req.user?.companyCode; + const { id } = req.params; + + const query = ` + DELETE FROM digital_twin_layout + WHERE id = $1 AND company_code = $2 + RETURNING id + `; + + const result = await pool.query(query, [id, companyCode]); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "레이아웃을 찾을 수 없습니다.", + }); + } + + logger.info("레이아웃 삭제", { + companyCode, + layoutId: id, + }); + + return res.json({ + success: true, + message: "레이아웃이 삭제되었습니다.", + }); + } catch (error: any) { + logger.error("레이아웃 삭제 실패", error); + return res.status(500).json({ + success: false, + message: "레이아웃 삭제 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; diff --git a/backend-node/src/routes/digitalTwinRoutes.ts b/backend-node/src/routes/digitalTwinRoutes.ts new file mode 100644 index 00000000..3130b470 --- /dev/null +++ b/backend-node/src/routes/digitalTwinRoutes.ts @@ -0,0 +1,66 @@ +import express from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; + +// 레이아웃 관리 +import { + getLayouts, + getLayoutById, + createLayout, + updateLayout, + deleteLayout, +} from "../controllers/digitalTwinLayoutController"; + +// 외부 DB 데이터 조회 +import { + getWarehouses, + getAreas, + getLocations, + getMaterials, + getMaterialCounts, +} from "../controllers/digitalTwinDataController"; + +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); // 레이아웃 삭제 + +// ========== 외부 DB 데이터 조회 API ========== +router.get("/data/tables/:connectionId", async (req, res) => { + // 테이블 목록 조회 + try { + const { ExternalDbConnectionService } = await import("../services/externalDbConnectionService"); + const result = await ExternalDbConnectionService.getTablesFromConnection(Number(req.params.connectionId)); + return res.json(result); + } catch (error: any) { + return res.status(500).json({ success: false, error: error.message }); + } +}); + +router.get("/data/table-preview/:connectionId/:tableName", async (req, res) => { + // 테이블 미리보기 (10개 레코드) + try { + const { connectionId, tableName } = req.params; + const { getExternalDbConnector } = await import("../controllers/digitalTwinDataController"); + const connector = await getExternalDbConnector(Number(connectionId)); + const result = await connector.executeQuery(`SELECT * FROM ${tableName} LIMIT 10`); + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + return res.status(500).json({ success: false, error: error.message }); + } +}); + +router.get("/data/warehouses", getWarehouses); // 창고 목록 +router.get("/data/areas", getAreas); // Area 목록 +router.get("/data/locations", getLocations); // Location 목록 +router.get("/data/materials", getMaterials); // 자재 목록 (특정 Location) +router.get("/data/material-counts", getMaterialCounts); // 자재 개수 (여러 Location) + +export default router; + diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index c6429121..2bb85051 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -277,7 +277,7 @@ export function CanvasElement({ const autoScrollDirectionRef = useRef<"up" | "down" | null>(null); // 🔥 자동 스크롤 방향 const autoScrollFrameRef = useRef(null); // 🔥 requestAnimationFrame ID const lastMouseYRef = useRef(window.innerHeight / 2); // 🔥 마지막 마우스 Y 위치 (초기값: 화면 중간) - const [resizeStart, setResizeStart] = useState({ + const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, width: 0, @@ -302,26 +302,26 @@ export function CanvasElement({ return; } - // 닫기 버튼이나 리사이즈 핸들 클릭 시 무시 + // 닫기 버튼이나 리사이즈 핸들 클릭 시 무시 if ((e.target as HTMLElement).closest(".element-close, .resize-handle")) { return; } // 위젯 내부 (헤더 제외) 클릭 시 드래그 무시 - 인터랙티브 사용 가능 if ((e.target as HTMLElement).closest(".widget-interactive-area")) { - return; - } + return; + } // 선택되지 않은 경우에만 선택 처리 if (!isSelected) { - onSelect(element.id); + onSelect(element.id); } - setIsDragging(true); + setIsDragging(true); const startPos = { - x: e.clientX, - y: e.clientY, - elementX: element.position.x, + x: e.clientX, + y: e.clientY, + elementX: element.position.x, elementY: element.position.y, initialScrollY: window.pageYOffset, // 🔥 드래그 시작 시점의 스크롤 위치 }; @@ -348,7 +348,7 @@ export function CanvasElement({ onMultiDragStart(element.id, offsets); } - e.preventDefault(); + e.preventDefault(); }, [ element.id, @@ -370,17 +370,17 @@ export function CanvasElement({ return; } - e.stopPropagation(); - setIsResizing(true); - setResizeStart({ - x: e.clientX, - y: e.clientY, - width: element.size.width, - height: element.size.height, - elementX: element.position.x, - elementY: element.position.y, + e.stopPropagation(); + setIsResizing(true); + setResizeStart({ + x: e.clientX, + y: e.clientY, + width: element.size.width, + height: element.size.height, + elementX: element.position.x, + elementY: element.position.y, handle, - }); + }); }, [element.size.width, element.size.height, element.position.x, element.position.y], ); @@ -388,7 +388,7 @@ export function CanvasElement({ // 마우스 이동 처리 (그리드 스냅 적용) const handleMouseMove = useCallback( (e: MouseEvent) => { - if (isDragging) { + if (isDragging) { // 🔥 자동 스크롤: 다중 선택 시 첫 번째 위젯에서만 처리 const isFirstSelectedElement = !selectedElements || selectedElements.length === 0 || selectedElements[0] === element.id; @@ -425,14 +425,14 @@ export function CanvasElement({ if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragMove) { onMultiDragMove(element, { x: rawX, y: rawY }); } - } else if (isResizing) { - const deltaX = e.clientX - resizeStart.x; - const deltaY = e.clientY - resizeStart.y; - - let newWidth = resizeStart.width; - let newHeight = resizeStart.height; - let newX = resizeStart.elementX; - let newY = resizeStart.elementY; + } else if (isResizing) { + const deltaX = e.clientX - resizeStart.x; + const deltaY = e.clientY - resizeStart.y; + + let newWidth = resizeStart.width; + let newHeight = resizeStart.height; + let newX = resizeStart.elementX; + let newY = resizeStart.elementY; // 최소 크기 설정: 모든 위젯 1x1 const minWidthCells = 1; @@ -440,28 +440,28 @@ export function CanvasElement({ const minWidth = cellSize * minWidthCells; const minHeight = cellSize * minHeightCells; - switch (resizeStart.handle) { + switch (resizeStart.handle) { case "se": // 오른쪽 아래 newWidth = Math.max(minWidth, resizeStart.width + deltaX); newHeight = Math.max(minHeight, resizeStart.height + deltaY); - break; + break; case "sw": // 왼쪽 아래 newWidth = Math.max(minWidth, resizeStart.width - deltaX); newHeight = Math.max(minHeight, resizeStart.height + deltaY); - newX = resizeStart.elementX + deltaX; - break; + newX = resizeStart.elementX + deltaX; + break; case "ne": // 오른쪽 위 newWidth = Math.max(minWidth, resizeStart.width + deltaX); newHeight = Math.max(minHeight, resizeStart.height - deltaY); - newY = resizeStart.elementY + deltaY; - break; + newY = resizeStart.elementY + deltaY; + break; case "nw": // 왼쪽 위 newWidth = Math.max(minWidth, resizeStart.width - deltaX); newHeight = Math.max(minHeight, resizeStart.height - deltaY); - newX = resizeStart.elementX + deltaX; - newY = resizeStart.elementY + deltaY; - break; - } + newX = resizeStart.elementX + deltaX; + newY = resizeStart.elementY + deltaY; + break; + } // 가로 너비가 캔버스를 벗어나지 않도록 제한 const maxWidth = canvasWidth - newX; @@ -664,7 +664,7 @@ export function CanvasElement({ if (isDragging || isResizing) { document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); - + return () => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); @@ -685,7 +685,7 @@ export function CanvasElement({ // 필터 적용 (날짜 필터 등) const { applyQueryFilters } = await import("./utils/queryHelpers"); const filteredQuery = applyQueryFilters(element.dataSource.query, element.chartConfig); - + // 외부 DB vs 현재 DB 분기 if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) { // 외부 DB @@ -709,13 +709,13 @@ export function CanvasElement({ // 현재 DB const { dashboardApi } = await import("@/lib/api/dashboard"); result = await dashboardApi.executeQuery(filteredQuery); - - setChartData({ - columns: result.columns || [], - rows: result.rows || [], - totalRows: result.rowCount || 0, + + setChartData({ + columns: result.columns || [], + rows: result.rows || [], + totalRows: result.rowCount || 0, executionTime: 0, - }); + }); } } catch (error) { // console.error("Chart data loading error:", error); @@ -818,55 +818,55 @@ export function CanvasElement({
{/* 차트 타입 전환 드롭다운 (차트일 경우만) */} {element.type === "chart" && ( - { + onUpdate(element.id, { subtype: newSubtype as ElementSubtype }); + }} > - - - e.stopPropagation()}> - {getChartCategory(element.subtype) === "axis-based" ? ( - - 축 기반 차트 - 바 차트 - 수평 바 차트 - 누적 바 차트 - 꺾은선 차트 - 영역 차트 - 콤보 차트 - - ) : ( - - 원형 차트 - 원형 차트 - 도넛 차트 - - )} - - - )} - {/* 제목 */} - {!element.type || element.type !== "chart" ? ( - element.subtype === "map-summary-v2" && !element.customTitle ? null : ( - {element.customTitle || element.title} - ) - ) : null} + e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > + + + e.stopPropagation()}> + {getChartCategory(element.subtype) === "axis-based" ? ( + + 축 기반 차트 + 바 차트 + 수평 바 차트 + 누적 바 차트 + 꺾은선 차트 + 영역 차트 + 콤보 차트 + + ) : ( + + 원형 차트 + 원형 차트 + 도넛 차트 + + )} + + + )} + {/* 제목 */} + {!element.type || element.type !== "chart" ? ( + element.subtype === "map-summary-v2" && !element.customTitle ? null : ( + {element.customTitle || element.title} + ) + ) : null} +
- )} {/* 삭제 버튼 - 항상 표시 (우측 상단 절대 위치) */} + + + {loadingMaterials ? ( +
+ +
+ ) : materials.length === 0 ? ( +
+ 자재가 없습니다 +
+ ) : ( +
+ {materials.map((material, index) => ( +
+
+
+

{material.STKKEY}

+

+ 층: {material.LOLAYER} | Area: {material.AREAKEY} +

+
+
+
+ {material.STKWIDT && ( +
+ 폭: {material.STKWIDT} +
+ )} + {material.STKLENG && ( +
+ 길이: {material.STKLENG} +
+ )} + {material.STKHEIG && ( +
+ 높이: {material.STKHEIG} +
+ )} + {material.STKWEIG && ( +
+ 무게: {material.STKWEIG} +
+ )} +
+ {material.STKRMKS && ( +

{material.STKRMKS}

+ )} +
+ ))} +
+ )} + + ) : selectedObject ? (

객체 속성

diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx index 6520ea29..d8162e31 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx @@ -1,16 +1,21 @@ "use client"; import { useState, useEffect, useMemo } from "react"; -import { Search } from "lucide-react"; +import { Loader2, Search, Filter, X } from "lucide-react"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import dynamic from "next/dynamic"; -import { Loader2 } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import type { PlacedObject, MaterialData } from "@/types/digitalTwin"; +import { getLayoutById, getMaterials } from "@/lib/api/digitalTwin"; const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), { ssr: false, loading: () => ( -
- +
+
), }); @@ -19,292 +24,478 @@ interface DigitalTwinViewerProps { layoutId: number; } -// 임시 타입 정의 -interface Material { - id: number; - plate_no: string; // 후판번호 - steel_grade: string; // 강종 - thickness: number; // 두께 - width: number; // 폭 - length: number; // 길이 - weight: number; // 중량 - location: string; // 위치 - status: string; // 상태 - arrival_date: string; // 입고일자 -} - export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) { - const [searchTerm, setSearchTerm] = useState(""); - const [selectedYard, setSelectedYard] = useState("all"); - const [selectedStatus, setSelectedStatus] = useState("all"); - const [dateRange, setDateRange] = useState({ from: "", to: "" }); - const [selectedMaterial, setSelectedMaterial] = useState(null); - const [materials, setMaterials] = useState([]); + const { toast } = useToast(); + const [placedObjects, setPlacedObjects] = useState([]); + const [selectedObject, setSelectedObject] = useState(null); const [isLoading, setIsLoading] = useState(true); + const [materials, setMaterials] = useState([]); + const [loadingMaterials, setLoadingMaterials] = useState(false); + const [showInfoPanel, setShowInfoPanel] = useState(false); + const [externalDbConnectionId, setExternalDbConnectionId] = useState(null); + const [layoutName, setLayoutName] = useState(""); + + // 검색 및 필터 + const [searchQuery, setSearchQuery] = useState(""); + const [filterType, setFilterType] = useState("all"); // 레이아웃 데이터 로드 useEffect(() => { - const loadData = async () => { + const loadLayout = async () => { try { setIsLoading(true); - // TODO: 실제 API 호출 - // const response = await digitalTwinApi.getLayoutData(layoutId); - - // 임시 데이터 - setMaterials([ - { - id: 1, - plate_no: "P-2024-001", - steel_grade: "SM490A", - thickness: 25, - width: 2000, - length: 6000, - weight: 2355, - location: "A동-101", - status: "입고", - arrival_date: "2024-11-15", - }, - { - id: 2, - plate_no: "P-2024-002", - steel_grade: "SS400", - thickness: 30, - width: 2500, - length: 8000, - weight: 4710, - location: "B동-205", - status: "가공중", - arrival_date: "2024-11-16", - }, - ]); + const response = await getLayoutById(layoutId); + + if (response.success && response.data) { + const { layout, objects } = response.data; + + // 레이아웃 정보 저장 + setLayoutName(layout.layoutName); + setExternalDbConnectionId(layout.externalDbConnectionId); + + // 객체 데이터 변환 + const loadedObjects: PlacedObject[] = objects.map((obj: any) => ({ + id: obj.id, + type: obj.object_type, + name: obj.object_name, + position: { + x: parseFloat(obj.position_x), + y: parseFloat(obj.position_y), + z: parseFloat(obj.position_z), + }, + size: { + x: parseFloat(obj.size_x), + y: parseFloat(obj.size_y), + z: parseFloat(obj.size_z), + }, + rotation: obj.rotation ? parseFloat(obj.rotation) : 0, + color: obj.color, + areaKey: obj.area_key, + locaKey: obj.loca_key, + locType: obj.loc_type, + materialCount: obj.material_count, + materialPreview: obj.material_preview_height + ? { height: parseFloat(obj.material_preview_height) } + : undefined, + parentId: obj.parent_id, + displayOrder: obj.display_order, + locked: obj.locked, + visible: obj.visible !== false, + })); + + setPlacedObjects(loadedObjects); + } else { + throw new Error(response.error || "레이아웃 조회 실패"); + } } catch (error) { - console.error("디지털 트윈 데이터 로드 실패:", error); + console.error("레이아웃 로드 실패:", error); + const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다."; + toast({ + variant: "destructive", + title: "오류", + description: errorMessage, + }); } finally { setIsLoading(false); } }; - loadData(); - }, [layoutId]); + loadLayout(); + }, [layoutId, toast]); - // 필터링된 자재 목록 - const filteredMaterials = useMemo(() => { - return materials.filter((material) => { - // 검색어 필터 - if (searchTerm) { - const searchLower = searchTerm.toLowerCase(); - const matchSearch = - material.plate_no.toLowerCase().includes(searchLower) || - material.steel_grade.toLowerCase().includes(searchLower) || - material.location.toLowerCase().includes(searchLower); - if (!matchSearch) return false; + // Location의 자재 목록 로드 + const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => { + try { + setLoadingMaterials(true); + setShowInfoPanel(true); + const response = await getMaterials(externalDbConnectionId, locaKey); + if (response.success && response.data) { + const sortedMaterials = response.data.sort((a, b) => a.LOLAYER - b.LOLAYER); + setMaterials(sortedMaterials); + } else { + setMaterials([]); } + } catch (error) { + console.error("자재 로드 실패:", error); + setMaterials([]); + } finally { + setLoadingMaterials(false); + } + }; - // 야드 필터 - if (selectedYard !== "all" && !material.location.startsWith(selectedYard)) { + // 객체 클릭 + const handleObjectClick = (objectId: number | null) => { + if (objectId === null) { + setSelectedObject(null); + setShowInfoPanel(false); + return; + } + + const obj = placedObjects.find((o) => o.id === objectId); + setSelectedObject(obj || null); + + // Location을 클릭한 경우, 자재 정보 표시 + if ( + obj && + (obj.type === "location-bed" || + obj.type === "location-stp" || + obj.type === "location-temp" || + obj.type === "location-dest") && + obj.locaKey && + externalDbConnectionId + ) { + setShowInfoPanel(true); + loadMaterialsForLocation(obj.locaKey, externalDbConnectionId); + } else { + setShowInfoPanel(true); + setMaterials([]); + } + }; + + // 타입별 개수 계산 (useMemo로 최적화) + const typeCounts = useMemo(() => { + const counts: Record = { + all: placedObjects.length, + area: 0, + "location-bed": 0, + "location-stp": 0, + "location-temp": 0, + "location-dest": 0, + "crane-mobile": 0, + rack: 0, + }; + + placedObjects.forEach((obj) => { + if (counts[obj.type] !== undefined) { + counts[obj.type]++; + } + }); + + return counts; + }, [placedObjects]); + + // 필터링된 객체 목록 (useMemo로 최적화) + const filteredObjects = useMemo(() => { + return placedObjects.filter((obj) => { + // 타입 필터 + if (filterType !== "all" && obj.type !== filterType) { return false; } - // 상태 필터 - if (selectedStatus !== "all" && material.status !== selectedStatus) { - return false; - } - - // 날짜 필터 - if (dateRange.from && material.arrival_date < dateRange.from) { - return false; - } - if (dateRange.to && material.arrival_date > dateRange.to) { - return false; + // 검색 쿼리 + if (searchQuery) { + const query = searchQuery.toLowerCase(); + return ( + obj.name.toLowerCase().includes(query) || + obj.areaKey?.toLowerCase().includes(query) || + obj.locaKey?.toLowerCase().includes(query) + ); } return true; }); - }, [materials, searchTerm, selectedYard, selectedStatus, dateRange]); + }, [placedObjects, filterType, searchQuery]); - // 3D 객체 클릭 핸들러 - const handleObjectClick = (objectId: number) => { - const material = materials.find((m) => m.id === objectId); - setSelectedMaterial(material || null); - }; + if (isLoading) { + return ( +
+ +
+ ); + } return ( -
- {/* 좌측: 필터 패널 */} -
- {/* 검색바 */} -
-
- - setSearchTerm(e.target.value)} - placeholder="후판번호, 강종, 위치 검색..." - className="h-10 pl-10 text-sm" - /> -
+
+ {/* 상단 헤더 */} +
+
+

{layoutName || "디지털 트윈 야드"}

+

읽기 전용 뷰

+
- {/* 필터 옵션 */} -
-
- {/* 야드 선택 */} + {/* 메인 영역 */} +
+ {/* 좌측: 검색/필터 */} +
+
+ {/* 검색 */}
-

야드

-
- {["all", "A동", "B동", "C동", "겐트리"].map((yard) => ( - - ))} + + + )}
- {/* 상태 필터 */} + {/* 타입 필터 */}
-

상태

-
- {["all", "입고", "가공중", "출고대기", "출고완료"].map((status) => ( - - ))} -
+ +
- {/* 기간 필터 */} -
-

입고 기간

+ {/* 필터 초기화 */} + {(searchQuery || filterType !== "all") && ( + + )} +
+ + {/* 객체 목록 */} +
+ + {filteredObjects.length === 0 ? ( +
+ {searchQuery ? "검색 결과가 없습니다" : "객체가 없습니다"} +
+ ) : (
- setDateRange((prev) => ({ ...prev, from: e.target.value }))} - className="h-9 text-sm" - placeholder="시작일" - /> - setDateRange((prev) => ({ ...prev, to: e.target.value }))} - className="h-9 text-sm" - placeholder="종료일" - /> -
-
-
-
-
+ {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 = "랙"; - {/* 중앙: 3D 캔버스 */} -
- {isLoading ? ( -
- -
- ) : ( - { - if (placement) { - handleObjectClick(placement.id); - } else { - setSelectedMaterial(null); - } - }} - onPlacementDrag={() => {}} // 뷰어 모드에서는 드래그 비활성화 - focusOnPlacementId={null} - onCollisionDetected={() => {}} - /> - )} -
- - {/* 우측: 상세정보 패널 (후판 목록 테이블) */} -
-
-

후판 목록

- - {filteredMaterials.length === 0 ? ( -
-

조건에 맞는 후판이 없습니다.

-
- ) : ( -
- {filteredMaterials.map((material) => ( -
setSelectedMaterial(material)} - className={`cursor-pointer rounded-lg border p-3 transition-all ${ - selectedMaterial?.id === material.id - ? "border-primary bg-primary/10" - : "border-border hover:border-primary/50" - }`} - > -
- {material.plate_no} - 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" }`} > - {material.status} - -
+
+
+

{obj.name}

+
+ + {typeLabel} +
+
+
+ + {/* 추가 정보 */} +
+ {obj.areaKey && ( +

+ Area: {obj.areaKey} +

+ )} + {obj.locaKey && ( +

+ Location: {obj.locaKey} +

+ )} + {obj.materialCount !== undefined && obj.materialCount > 0 && ( +

+ 자재: {obj.materialCount}개 +

+ )} +
+
+ ); + })} +
+ )} +
+
-
-
- 강종: - {material.steel_grade} -
-
- 규격: - - {material.thickness}×{material.width}×{material.length} - -
-
- 중량: - {material.weight.toLocaleString()} kg -
-
- 위치: - {material.location} -
-
- 입고일: - {material.arrival_date} -
-
-
- ))} -
+ {/* 중앙: 3D 캔버스 */} +
+ {!isLoading && ( + + placedObjects.map((obj) => ({ + id: obj.id, + name: obj.name, + position_x: obj.position.x, + position_y: obj.position.y, + position_z: obj.position.z, + size_x: obj.size.x, + size_y: obj.size.y, + size_z: obj.size.z, + color: obj.color, + data_source_type: obj.type, + material_count: obj.materialCount, + material_preview_height: obj.materialPreview?.height, + yard_layout_id: undefined, + material_code: null, + material_name: null, + quantity: null, + unit: null, + data_source_config: undefined, + data_binding: undefined, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + })), + [placedObjects], + )} + selectedPlacementId={selectedObject?.id || null} + onPlacementClick={(placement) => handleObjectClick(placement?.id || null)} + focusOnPlacementId={null} + onCollisionDetected={() => {}} + /> )}
+ + {/* 우측: 정보 패널 */} + {showInfoPanel && selectedObject && ( +
+
+
+
+

상세 정보

+

{selectedObject.name}

+
+ +
+ + {/* 기본 정보 */} +
+
+ +

{selectedObject.type}

+
+ {selectedObject.areaKey && ( +
+ +

{selectedObject.areaKey}

+
+ )} + {selectedObject.locaKey && ( +
+ +

{selectedObject.locaKey}

+
+ )} + {selectedObject.materialCount !== undefined && selectedObject.materialCount > 0 && ( +
+ +

{selectedObject.materialCount}개

+
+ )} +
+ + {/* 자재 목록 (Location인 경우) */} + {(selectedObject.type === "location-bed" || + selectedObject.type === "location-stp" || + selectedObject.type === "location-temp" || + selectedObject.type === "location-dest") && ( +
+ + {loadingMaterials ? ( +
+ +
+ ) : materials.length === 0 ? ( +
+ {externalDbConnectionId + ? "자재가 없습니다" + : "외부 DB 연결이 설정되지 않았습니다"} +
+ ) : ( +
+ {materials.map((material, index) => ( +
+
+
+

{material.STKKEY}

+

+ 층: {material.LOLAYER} | Area: {material.AREAKEY} +

+
+
+
+ {material.STKWIDT && ( +
+ 폭: {material.STKWIDT} +
+ )} + {material.STKLENG && ( +
+ 길이: {material.STKLENG} +
+ )} + {material.STKHEIG && ( +
+ 높이: {material.STKHEIG} +
+ )} + {material.STKWEIG && ( +
+ 무게: {material.STKWEIG} +
+ )} +
+ {material.STKRMKS && ( +

{material.STKRMKS}

+ )} +
+ ))} +
+ )} +
+ )} +
+
+ )}
); } - diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx index 911afcb9..3de44b02 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx @@ -2,7 +2,7 @@ import { Canvas, useThree } from "@react-three/fiber"; import { OrbitControls, Grid, Box, Text } from "@react-three/drei"; -import { Suspense, useRef, useState, useEffect } from "react"; +import { Suspense, useRef, useState, useEffect, useMemo } from "react"; import * as THREE from "three"; interface YardPlacement { @@ -23,6 +23,8 @@ interface YardPlacement { data_source_type?: string | null; data_source_config?: any; data_binding?: any; + material_count?: number; // Location의 자재 개수 + material_preview_height?: number; // 자재 스택 높이 (시각적) } interface Yard3DCanvasProps { @@ -103,7 +105,7 @@ function MaterialBox({ if (!allPlacements || allPlacements.length === 0) { // 다른 객체가 없으면 기본 높이 const objectType = placement.data_source_type as string | null; - const defaultY = objectType === "yard" ? 0.05 : (placement.size_y || gridSize) / 2; + const defaultY = objectType === "area" ? 0.05 : (placement.size_y || gridSize) / 2; return { hasCollision: false, adjustedY: defaultY, @@ -122,11 +124,11 @@ function MaterialBox({ const myMaxZ = z + mySizeZ / 2; const objectType = placement.data_source_type as string | null; - const defaultY = objectType === "yard" ? 0.05 : mySizeY / 2; + const defaultY = objectType === "area" ? 0.05 : mySizeY / 2; let maxYBelow = defaultY; - // 야드는 스택되지 않음 (항상 바닥에 배치) - if (objectType === "yard") { + // Area는 스택되지 않음 (항상 바닥에 배치) + if (objectType === "area") { return { hasCollision: false, adjustedY: defaultY, @@ -385,8 +387,8 @@ function MaterialBox({ // 타입별 렌더링 const renderObjectByType = () => { switch (objectType) { - case "yard": - // 야드: 투명한 바닥 + 두꺼운 외곽선 (mesh) + 이름 텍스트 + case "area": + // Area: 투명한 바닥 + 두꺼운 외곽선 (mesh) + 이름 텍스트 const borderThickness = 0.3; // 외곽선 두께 return ( <> @@ -440,7 +442,7 @@ function MaterialBox({ )} - {/* 야드 이름 텍스트 */} + {/* Area 이름 텍스트 */} {placement.name && ( ); + case "location-bed": + case "location-temp": + case "location-dest": + // 베드 타입 Location: 초록색 상자 + return ( + <> + + + + + {/* 대표 자재 스택 (자재가 있을 때만) */} + {placement.material_count !== undefined && + placement.material_count > 0 && + placement.material_preview_height && ( + + + + )} + + {/* Location 이름 */} + {placement.name && ( + + {placement.name} + + )} + + {/* 자재 개수 */} + {placement.material_count !== undefined && placement.material_count > 0 && ( + + {`자재: ${placement.material_count}개`} + + )} + + ); + + case "location-stp": + // 정차포인트(STP): 주황색 낮은 플랫폼 + return ( + <> + + + + + {/* Location 이름 */} + {placement.name && ( + + {placement.name} + + )} + + {/* 자재 개수 (STP는 정차포인트라 자재가 없을 수 있음) */} + {placement.material_count !== undefined && placement.material_count > 0 && ( + + {`자재: ${placement.material_count}개`} + + )} + + ); + // case "gantry-crane": // // 겐트리 크레인: 기둥 2개 + 상단 빔 // return ( @@ -505,7 +625,7 @@ function MaterialBox({ // // ); - case "mobile-crane": + case "crane-mobile": // 이동식 크레인: 하부(트랙) + 회전대 + 캐빈 + 붐대 + 카운터웨이트 + 후크 return ( diff --git a/frontend/lib/api/digitalTwin.ts b/frontend/lib/api/digitalTwin.ts new file mode 100644 index 00000000..b58b0206 --- /dev/null +++ b/frontend/lib/api/digitalTwin.ts @@ -0,0 +1,215 @@ +import { apiClient } from "./client"; +import type { + DigitalTwinLayout, + DigitalTwinLayoutDetail, + CreateLayoutRequest, + UpdateLayoutRequest, + Warehouse, + Area, + Location, + MaterialData, + MaterialCount, +} from "@/types/digitalTwin"; + +// API 응답 타입 +interface ApiResponse { + success: boolean; + data?: T; + message?: string; + error?: string; +} + +// ========== 레이아웃 관리 API ========== + +// 레이아웃 목록 조회 +export const getLayouts = async (params?: { + externalDbConnectionId?: number; + warehouseKey?: string; +}): Promise> => { + try { + const response = await apiClient.get("/digital-twin/layouts", { params }); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +}; + +// 레이아웃 상세 조회 +export const getLayoutById = async (id: number): Promise> => { + try { + const response = await apiClient.get(`/digital-twin/layouts/${id}`); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +}; + +// 레이아웃 생성 +export const createLayout = async (data: CreateLayoutRequest): Promise> => { + try { + const response = await apiClient.post("/digital-twin/layouts", data); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +}; + +// 레이아웃 수정 +export const updateLayout = async (id: number, data: UpdateLayoutRequest): Promise> => { + try { + const response = await apiClient.put(`/digital-twin/layouts/${id}`, data); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +}; + +// 레이아웃 삭제 +export const deleteLayout = async (id: number): Promise> => { + try { + const response = await apiClient.delete(`/digital-twin/layouts/${id}`); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +}; + +// ========== 외부 DB 테이블 조회 API ========== + +export const getTables = async ( + connectionId: number +): Promise>> => { + try { + const response = await apiClient.get(`/digital-twin/data/tables/${connectionId}`); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +}; + +export const getTablePreview = async ( + connectionId: number, + tableName: string +): Promise> => { + try { + const response = await apiClient.get(`/digital-twin/data/table-preview/${connectionId}/${tableName}`); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +}; + +// ========== 외부 DB 데이터 조회 API ========== + +// 창고 목록 조회 +export const getWarehouses = async (externalDbConnectionId: number, tableName: string): Promise> => { + try { + const response = await apiClient.get("/digital-twin/data/warehouses", { + params: { externalDbConnectionId, tableName }, + }); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +}; + +// Area 목록 조회 +export const getAreas = async (externalDbConnectionId: number, tableName: string, warehouseKey: string): Promise> => { + try { + const response = await apiClient.get("/digital-twin/data/areas", { + params: { externalDbConnectionId, tableName, warehouseKey }, + }); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +}; + +// Location 목록 조회 +export const getLocations = async ( + externalDbConnectionId: number, + tableName: string, + areaKey: string, +): Promise> => { + try { + const response = await apiClient.get("/digital-twin/data/locations", { + params: { externalDbConnectionId, tableName, areaKey }, + }); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +}; + +// 자재 목록 조회 (특정 Location) +export const getMaterials = async ( + externalDbConnectionId: number, + tableName: string, + locaKey: string, +): Promise> => { + try { + const response = await apiClient.get("/digital-twin/data/materials", { + params: { externalDbConnectionId, tableName, locaKey }, + }); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +}; + +// 자재 개수 조회 (여러 Location) +export const getMaterialCounts = async ( + externalDbConnectionId: number, + tableName: string, + locaKeys: string[], +): Promise> => { + try { + const response = await apiClient.get("/digital-twin/data/material-counts", { + params: { + externalDbConnectionId, + tableName, + locaKeys: locaKeys.join(","), + }, + }); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +}; + diff --git a/frontend/types/digitalTwin.ts b/frontend/types/digitalTwin.ts new file mode 100644 index 00000000..b8a3bc1e --- /dev/null +++ b/frontend/types/digitalTwin.ts @@ -0,0 +1,155 @@ +// Digital Twin 관련 타입 정의 + +// 객체 타입 +export type ObjectType = + | "area" // Area (A동, B동, C동, 겐트리) + | "location-bed" // BED (사각형, 파란색, 상자) + | "location-stp" // STP (원형, 회색, 주차) + | "location-temp" // 임시베드 (BED와 동일) + | "location-dest" // 지정착지 (BED와 동일) + | "crane-mobile" // 모바일 크레인 (참고용) + | "rack"; // 랙 (참고용) + +// 3D 위치 +export interface Position3D { + x: number; + y: number; + z: number; +} + +// 3D 크기 +export interface Size3D { + x: number; + y: number; + z: number; +} + +// 자재 미리보기 정보 +export interface MaterialPreview { + height: number; // 스택 높이 (LOLAYER 기반) + topMaterial?: MaterialData; // 최상단 자재 정보 (선택사항) +} + +// 자재 데이터 (WSTKKY) +export interface MaterialData { + STKKEY: string; // 자재 키 + LOCAKEY: string; // Location 키 + AREAKEY: string; // Area 키 + LOLAYER: number; // 층 (스택 순서) + STKWIDT?: number; // 폭 + STKLENG?: number; // 길이 + STKHEIG?: number; // 높이 + STKWEIG?: number; // 무게 + STKQTY?: number; // 수량 + STKSTAT?: string; // 상태 + STKREDT?: string; // 등록일 + STKRMKS?: string; // 비고 +} + +// 배치된 객체 +export interface PlacedObject { + id: number; // 로컬 ID (음수: 임시, 양수: DB 저장됨) + type: ObjectType; + name: string; + position: Position3D; + size: Size3D; + rotation?: number; // 회전 각도 (라디안) + color: string; + + // 외부 DB 연동 + areaKey?: string; // MAREMA.AREAKEY + locaKey?: string; // MLOCMA.LOCAKEY + locType?: string; // MLOCMA.LOCTYPE (BED, STP, TMP, DES) + + // 자재 정보 + materialCount?: number; // 자재 개수 + materialPreview?: MaterialPreview; // 자재 미리보기 + + // 계층 구조 + parentId?: number; + displayOrder?: number; + + // 편집 제한 + locked?: boolean; // true면 이동/크기조절 불가 + visible?: boolean; +} + +// 레이아웃 +export interface DigitalTwinLayout { + id: number; + companyCode: string; + externalDbConnectionId: number; + warehouseKey: string; // WAREKEY (예: DY99) + layoutName: string; + description?: string; + isActive: boolean; + createdBy?: number; + updatedBy?: number; + createdAt: string; + updatedAt: string; + + // 통계 (조회 시만) + objectCount?: number; +} + +// 레이아웃 상세 (객체 포함) +export interface DigitalTwinLayoutDetail { + layout: DigitalTwinLayout; + objects: PlacedObject[]; +} + +// 창고 (MWAREMA) +export interface Warehouse { + WAREKEY: string; + WARENAME: string; + WARETYPE?: string; + WARESTAT?: string; +} + +// Area (MAREMA) +export interface Area { + AREAKEY: string; + AREANAME: string; + AREATYP?: string; // 내부/외부 + WAREKEY: string; + AREASTAT?: string; +} + +// Location (MLOCMA) +export interface Location { + LOCAKEY: string; + LOCANAME: string; + LOCTYPE: string; // BED, STP, TMP, DES + AREAKEY: string; + LOCWIDT?: number; // 폭 (현재 데이터는 0) + LOCLENG?: number; // 길이 (현재 데이터는 0) + LOCHEIG?: number; // 높이 (현재 데이터는 0) + LOCCUBI?: number; // 용적 (현재 데이터는 0) + LOCSTAT?: string; +} + +// 자재 개수 (배치 시 사용) +export interface MaterialCount { + LOCAKEY: string; + material_count: number; + max_layer: number; +} + +// API 요청/응답 타입 +export interface CreateLayoutRequest { + externalDbConnectionId: number; + warehouseKey: string; + layoutName: string; + description?: string; + objects: PlacedObject[]; +} + +export interface UpdateLayoutRequest { + layoutName: string; + description?: string; + objects: PlacedObject[]; +} + +// 도구 타입 (UI용) +export type ToolType = ObjectType; +