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 07389374..be51e70e 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"; // 작업 이력 관리 @@ -223,6 +224,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/app/(main)/admin/dashboard/DashboardListClient.tsx b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx index d8eeae61..082e8661 100644 --- a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx +++ b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx @@ -300,7 +300,14 @@ export default function DashboardListClient({ initialDashboards, initialPaginati {dashboards.map((dashboard) => ( - {dashboard.title} + + + {dashboard.description || "-"} @@ -355,7 +362,12 @@ export default function DashboardListClient({ initialDashboards, initialPaginati {/* 헤더 */}
-

{dashboard.title}

+

{dashboard.id}

diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index ce08c522..2bb85051 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -812,69 +812,70 @@ export function CanvasElement({ }} onMouseDown={handleMouseDown} > - {/* 헤더 */} -
-
- {/* 차트 타입 전환 드롭다운 (차트일 경우만) */} - {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} +
-
- {/* 삭제 버튼 */} - -
- + )} + + {/* 삭제 버튼 - 항상 표시 (우측 상단 절대 위치) */} + {/* 내용 */} -
+
{element.type === "chart" ? ( // 차트 렌더링
diff --git a/frontend/components/admin/dashboard/QueryEditor.tsx b/frontend/components/admin/dashboard/QueryEditor.tsx index 8f0b65e0..50e57689 100644 --- a/frontend/components/admin/dashboard/QueryEditor.tsx +++ b/frontend/components/admin/dashboard/QueryEditor.tsx @@ -1,12 +1,11 @@ "use client"; import React, { useState, useCallback } from "react"; -import { ChartDataSource, QueryResult, ChartConfig } from "./types"; +import { ChartDataSource, QueryResult } from "./types"; import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection"; import { dashboardApi } from "@/lib/api/dashboard"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Label } from "@/components/ui/label"; import { Card } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; @@ -14,7 +13,6 @@ import { Alert, AlertDescription } from "@/components/ui/alert"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Play, Loader2, Database, Code, ChevronDown, ChevronRight } from "lucide-react"; -import { applyQueryFilters } from "./utils/queryHelpers"; interface QueryEditorProps { dataSource?: ChartDataSource; @@ -106,7 +104,6 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que ...dataSource, type: "database", query: query.trim(), - refreshInterval: dataSource?.refreshInterval ?? 0, lastExecuted: new Date().toISOString(), }); } catch (err) { @@ -168,8 +165,8 @@ ORDER BY 하위부서수 DESC`, {/* 쿼리 에디터 헤더 */}
- -

SQL 쿼리 에디터

+ +

SQL 쿼리 에디터

@@ -247,46 +244,6 @@ ORDER BY 하위부서수 DESC`,
- {/* 새로고침 간격 설정 */} -
- - -
- {/* 오류 메시지 */} {error && ( @@ -300,15 +257,15 @@ ORDER BY 하위부서수 DESC`, {/* 쿼리 결과 미리보기 */} {queryResult && ( -
+
- 쿼리 결과 + 쿼리 결과 {queryResult.rows.length}행
- 실행 시간: {queryResult.executionTime}ms + 실행 시간: {queryResult.executionTime}ms
@@ -339,13 +296,13 @@ ORDER BY 하위부서수 DESC`, {queryResult.rows.length > 10 && ( -
+
... 및 {queryResult.rows.length - 10}개 더 (미리보기는 10행까지만 표시)
)}
) : ( -
결과가 없습니다.
+
결과가 없습니다.
)}
@@ -353,169 +310,3 @@ ORDER BY 하위부서수 DESC`,
); } - -/** - * 샘플 쿼리 결과 생성 함수 - */ -function generateSampleQueryResult(query: string): QueryResult { - // 쿼리에서 키워드 추출하여 적절한 샘플 데이터 생성 - const queryLower = query.toLowerCase(); - - // 디버깅용 로그 - // console.log('generateSampleQueryResult called with query:', query.substring(0, 100)); - - // 가장 구체적인 조건부터 먼저 체크 (순서 중요!) - const isComparison = - queryLower.includes("galaxy") || - queryLower.includes("갤럭시") || - queryLower.includes("아이폰") || - queryLower.includes("iphone"); - const isRegional = queryLower.includes("region") || queryLower.includes("지역"); - const isMonthly = queryLower.includes("month"); - const isSales = queryLower.includes("sales") || queryLower.includes("매출"); - const isUsers = queryLower.includes("users") || queryLower.includes("사용자"); - const isProducts = queryLower.includes("product") || queryLower.includes("상품"); - const isWeekly = queryLower.includes("week"); - - // console.log('Sample data type detection:', { - // isComparison, - // isRegional, - // isWeekly, - // isProducts, - // isMonthly, - // isSales, - // isUsers, - // querySnippet: query.substring(0, 200) - // }); - - let columns: string[]; - let rows: Record[]; - - // 더 구체적인 조건부터 먼저 체크 (순서 중요!) - if (isComparison) { - // console.log('✅ Using COMPARISON data'); - // 제품 비교 데이터 (다중 시리즈) - columns = ["month", "galaxy_sales", "iphone_sales", "other_sales"]; - rows = [ - { month: "2024-01", galaxy_sales: 450000, iphone_sales: 620000, other_sales: 130000 }, - { month: "2024-02", galaxy_sales: 520000, iphone_sales: 680000, other_sales: 150000 }, - { month: "2024-03", galaxy_sales: 480000, iphone_sales: 590000, other_sales: 110000 }, - { month: "2024-04", galaxy_sales: 610000, iphone_sales: 650000, other_sales: 160000 }, - { month: "2024-05", galaxy_sales: 720000, iphone_sales: 780000, other_sales: 180000 }, - { month: "2024-06", galaxy_sales: 680000, iphone_sales: 690000, other_sales: 170000 }, - { month: "2024-07", galaxy_sales: 750000, iphone_sales: 800000, other_sales: 170000 }, - { month: "2024-08", galaxy_sales: 690000, iphone_sales: 720000, other_sales: 170000 }, - { month: "2024-09", galaxy_sales: 730000, iphone_sales: 750000, other_sales: 170000 }, - { month: "2024-10", galaxy_sales: 800000, iphone_sales: 810000, other_sales: 170000 }, - { month: "2024-11", galaxy_sales: 870000, iphone_sales: 880000, other_sales: 170000 }, - { month: "2024-12", galaxy_sales: 950000, iphone_sales: 990000, other_sales: 160000 }, - ]; - // COMPARISON 데이터를 반환하고 함수 종료 - // console.log('COMPARISON data generated:', { - // columns, - // rowCount: rows.length, - // sampleRow: rows[0], - // allRows: rows, - // fieldTypes: { - // month: typeof rows[0].month, - // galaxy_sales: typeof rows[0].galaxy_sales, - // iphone_sales: typeof rows[0].iphone_sales, - // other_sales: typeof rows[0].other_sales - // }, - // firstFewRows: rows.slice(0, 3), - // lastFewRows: rows.slice(-3) - // }); - return { - columns, - rows, - totalRows: rows.length, - executionTime: Math.floor(Math.random() * 200) + 100, - }; - } else if (isRegional) { - // console.log('✅ Using REGIONAL data'); - // 지역별 분기별 매출 - columns = ["지역", "Q1", "Q2", "Q3", "Q4"]; - rows = [ - { 지역: "서울", Q1: 1200000, Q2: 1350000, Q3: 1420000, Q4: 1580000 }, - { 지역: "경기", Q1: 980000, Q2: 1120000, Q3: 1180000, Q4: 1290000 }, - { 지역: "부산", Q1: 650000, Q2: 720000, Q3: 780000, Q4: 850000 }, - { 지역: "대구", Q1: 450000, Q2: 490000, Q3: 520000, Q4: 580000 }, - { 지역: "인천", Q1: 520000, Q2: 580000, Q3: 620000, Q4: 690000 }, - { 지역: "광주", Q1: 380000, Q2: 420000, Q3: 450000, Q4: 490000 }, - { 지역: "대전", Q1: 410000, Q2: 460000, Q3: 490000, Q4: 530000 }, - ]; - } else if (isWeekly && isUsers) { - // console.log('✅ Using USERS data'); - // 사용자 가입 추이 - columns = ["week", "new_users"]; - rows = [ - { week: "2024-W10", new_users: 23 }, - { week: "2024-W11", new_users: 31 }, - { week: "2024-W12", new_users: 28 }, - { week: "2024-W13", new_users: 35 }, - { week: "2024-W14", new_users: 42 }, - { week: "2024-W15", new_users: 38 }, - { week: "2024-W16", new_users: 45 }, - { week: "2024-W17", new_users: 52 }, - { week: "2024-W18", new_users: 48 }, - { week: "2024-W19", new_users: 55 }, - { week: "2024-W20", new_users: 61 }, - { week: "2024-W21", new_users: 58 }, - ]; - } else if (isProducts && !isComparison) { - // console.log('✅ Using PRODUCTS data'); - // 상품별 판매량 - columns = ["product_name", "total_sold", "revenue"]; - rows = [ - { product_name: "스마트폰", total_sold: 156, revenue: 234000000 }, - { product_name: "노트북", total_sold: 89, revenue: 178000000 }, - { product_name: "태블릿", total_sold: 134, revenue: 67000000 }, - { product_name: "이어폰", total_sold: 267, revenue: 26700000 }, - { product_name: "스마트워치", total_sold: 98, revenue: 49000000 }, - { product_name: "키보드", total_sold: 78, revenue: 15600000 }, - { product_name: "마우스", total_sold: 145, revenue: 8700000 }, - { product_name: "모니터", total_sold: 67, revenue: 134000000 }, - { product_name: "프린터", total_sold: 34, revenue: 17000000 }, - { product_name: "웹캠", total_sold: 89, revenue: 8900000 }, - ]; - } else if (isMonthly && isSales && !isComparison) { - // console.log('✅ Using MONTHLY SALES data'); - // 월별 매출 데이터 - columns = ["month", "sales", "order_count"]; - rows = [ - { month: "2024-01", sales: 1200000, order_count: 45 }, - { month: "2024-02", sales: 1350000, order_count: 52 }, - { month: "2024-03", sales: 1180000, order_count: 41 }, - { month: "2024-04", sales: 1420000, order_count: 58 }, - { month: "2024-05", sales: 1680000, order_count: 67 }, - { month: "2024-06", sales: 1540000, order_count: 61 }, - { month: "2024-07", sales: 1720000, order_count: 71 }, - { month: "2024-08", sales: 1580000, order_count: 63 }, - { month: "2024-09", sales: 1650000, order_count: 68 }, - { month: "2024-10", sales: 1780000, order_count: 75 }, - { month: "2024-11", sales: 1920000, order_count: 82 }, - { month: "2024-12", sales: 2100000, order_count: 89 }, - ]; - } else { - // console.log('⚠️ Using DEFAULT data'); - // 기본 샘플 데이터 - columns = ["category", "value", "count"]; - rows = [ - { category: "A", value: 100, count: 10 }, - { category: "B", value: 150, count: 15 }, - { category: "C", value: 120, count: 12 }, - { category: "D", value: 180, count: 18 }, - { category: "E", value: 90, count: 9 }, - { category: "F", value: 200, count: 20 }, - { category: "G", value: 110, count: 11 }, - { category: "H", value: 160, count: 16 }, - ]; - } - - return { - columns, - rows, - totalRows: rows.length, - executionTime: Math.floor(Math.random() * 200) + 100, // 100-300ms - }; -} diff --git a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx index a18abf9a..db608645 100644 --- a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx +++ b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx @@ -46,7 +46,7 @@ const needsDataSource = (subtype: ElementSubtype): boolean => { "chart", "map-summary-v2", "risk-alert-v2", - "yard-management-3d", + // "yard-management-3d", // 데이터 탭 불필요 (레이아웃 선택만 사용) "todo", "document", "work-history", @@ -449,13 +449,30 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
+ {/* 레이아웃 선택 (야드 관리 3D 위젯 전용) */} + {element.subtype === "yard-management-3d" && ( +
+ +

표시할 디지털 트윈 레이아웃을 선택하세요

+
+

위젯 내부에서 레이아웃을 선택할 수 있습니다.

+

편집 모드에서 레이아웃 목록을 확인하고 선택하세요.

+
+
+ )} + {/* 자동 새로고침 설정 (지도 위젯 전용) */} {element.subtype === "map-summary-v2" && (
- setRefreshInterval(parseInt(value))} + > @@ -579,30 +596,16 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge refreshInterval={element.chartConfig?.refreshInterval || 5} markerType={element.chartConfig?.markerType || "circle"} onRefreshIntervalChange={(interval) => { - setElement((prev) => - prev - ? { - ...prev, - chartConfig: { - ...prev.chartConfig, - refreshInterval: interval, - }, - } - : prev - ); + setChartConfig((prev) => ({ + ...prev, + refreshInterval: interval, + })); }} onMarkerTypeChange={(type) => { - setElement((prev) => - prev - ? { - ...prev, - chartConfig: { - ...prev.chartConfig, - markerType: type, - }, - } - : prev - ); + setChartConfig((prev) => ({ + ...prev, + markerType: type, + })); }} /> )} @@ -619,20 +622,20 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge -
{config.filters && config.filters.length > 0 ? ( -
+
{config.filters.map((filter, index) => ( -
- {/* 컬럼 선택 */} - +
+ {/* 첫 번째 줄: 컬럼 선택 */} +
+ + + {/* 삭제 버튼 */} + +
- {/* 연산자 선택 */} + {/* 두 번째 줄: 연산자 선택 */} - {/* 값 입력 */} + {/* 세 번째 줄: 값 입력 */} updateFilter(index, "value", e.target.value)} - placeholder="값" - className="h-8 flex-1 text-xs" + placeholder="값을 입력하세요" + className="h-9 w-full text-sm" /> - - {/* 삭제 버튼 */} -
))}
@@ -231,6 +233,29 @@ export function CustomMetricSection({ queryResult, config, onConfigChange }: Cus />
+ {/* 6. 자동 새로고침 간격 */} +
+ + +

+ 통계 데이터를 자동으로 갱신하는 주기 +

+
+ {/* 미리보기 */} {config.valueColumn && config.aggregation && (
@@ -241,7 +266,7 @@ export function CustomMetricSection({ queryResult, config, onConfigChange }: Cus

필터:

{config.filters.map((filter, idx) => ( -

+

· {filter.column} {filter.operator} "{filter.value}"

))} diff --git a/frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx b/frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx index 8459716e..2e84f123 100644 --- a/frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx +++ b/frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx @@ -32,7 +32,7 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW {config.columns.length > 0 && (
- +
)}
diff --git a/frontend/components/admin/dashboard/widget-sections/MapConfigSection.tsx b/frontend/components/admin/dashboard/widget-sections/MapConfigSection.tsx index 3ed5fe24..b47bc0a2 100644 --- a/frontend/components/admin/dashboard/widget-sections/MapConfigSection.tsx +++ b/frontend/components/admin/dashboard/widget-sections/MapConfigSection.tsx @@ -20,32 +20,30 @@ interface MapConfigSectionProps { * - 자동 새로고침 간격 설정 * - 마커 종류 선택 */ -export function MapConfigSection({ - queryResult, +export function MapConfigSection({ + queryResult, refreshInterval = 5, markerType = "circle", onRefreshIntervalChange, - onMarkerTypeChange + onMarkerTypeChange, }: MapConfigSectionProps) { // 쿼리 결과가 없으면 안내 메시지 표시 if (!queryResult || !queryResult.columns || queryResult.columns.length === 0) { return ( -
+
- - 먼저 데이터 소스를 설정하고 쿼리를 테스트해주세요. - + 먼저 데이터 소스를 설정하고 쿼리를 테스트해주세요.
); } return ( -
+
- +
{/* 자동 새로고침 간격 */}
@@ -60,16 +58,24 @@ export function MapConfigSection({ - 없음 - 5초 - 10초 - 30초 - 1분 + + 없음 + + + 5초 + + + 10초 + + + 30초 + + + 1분 + -

- 마커 데이터를 자동으로 갱신하는 주기를 설정합니다 -

+

마커 데이터를 자동으로 갱신하는 주기를 설정합니다

{/* 마커 종류 선택 */} @@ -77,24 +83,25 @@ export function MapConfigSection({ - onMarkerTypeChange?.(value)}> - 동그라미 - 화살표 + + 동그라미 + + + 화살표 + + + 트럭 + -

- 지도에 표시할 마커의 모양을 선택합니다 -

+

지도에 표시할 마커의 모양을 선택합니다

); } - diff --git a/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx b/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx index 41e30b96..91f58650 100644 --- a/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx @@ -2,12 +2,12 @@ import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; -import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Plus, Check, Trash2 } from "lucide-react"; import YardLayoutCreateModal from "./yard-3d/YardLayoutCreateModal"; -import YardEditor from "./yard-3d/YardEditor"; -import Yard3DViewer from "./yard-3d/Yard3DViewer"; -import { yardLayoutApi } from "@/lib/api/yardLayoutApi"; +import DigitalTwinEditor from "./yard-3d/DigitalTwinEditor"; +import DigitalTwinViewer from "./yard-3d/DigitalTwinViewer"; +import { getLayouts, createLayout, deleteLayout } from "@/lib/api/digitalTwin"; import type { YardManagementConfig } from "../types"; interface YardLayout { @@ -40,9 +40,16 @@ export default function YardManagement3DWidget({ const loadLayouts = async () => { try { setIsLoading(true); - const response = await yardLayoutApi.getAllLayouts(); - if (response.success) { - setLayouts(response.data as YardLayout[]); + const response = await getLayouts(); + if (response.success && response.data) { + setLayouts(response.data.map((layout: any) => ({ + id: layout.id, + name: layout.layout_name, + description: layout.description || "", + placement_count: layout.object_count || 0, + created_at: layout.created_at, + updated_at: layout.updated_at, + }))); } } catch (error) { console.error("야드 레이아웃 목록 조회 실패:", error); @@ -81,11 +88,21 @@ export default function YardManagement3DWidget({ // 새 레이아웃 생성 const handleCreateLayout = async (name: string) => { try { - const response = await yardLayoutApi.createLayout({ name }); - if (response.success) { + const response = await createLayout({ + layoutName: name, + description: "", + }); + if (response.success && response.data) { await loadLayouts(); setIsCreateModalOpen(false); - setEditingLayout(response.data as YardLayout); + setEditingLayout({ + id: response.data.id, + name: response.data.layout_name, + description: response.data.description || "", + placement_count: 0, + created_at: response.data.created_at, + updated_at: response.data.updated_at, + }); } } catch (error) { console.error("야드 레이아웃 생성 실패:", error); @@ -110,7 +127,7 @@ export default function YardManagement3DWidget({ if (!deleteLayoutId) return; try { - const response = await yardLayoutApi.deleteLayout(deleteLayoutId); + const response = await deleteLayout(deleteLayoutId); if (response.success) { // 삭제된 레이아웃이 현재 선택된 레이아웃이면 설정 초기화 if (config?.layoutId === deleteLayoutId && onConfigChange) { @@ -125,11 +142,15 @@ export default function YardManagement3DWidget({ } }; - // 편집 모드: 편집 중인 경우 YardEditor 표시 + // 편집 모드: 편집 중인 경우 DigitalTwinEditor 표시 if (isEditMode && editingLayout) { return (
- +
); } @@ -269,10 +290,10 @@ export default function YardManagement3DWidget({ ); } - // 선택된 레이아웃의 3D 뷰어 표시 + // 선택된 레이아웃의 디지털 트윈 뷰어 표시 return (
- +
); } diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx new file mode 100644 index 00000000..83c90e70 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -0,0 +1,1538 @@ +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import dynamic from "next/dynamic"; +import { useToast } from "@/hooks/use-toast"; +import type { PlacedObject, ToolType, Warehouse, Area, Location, ObjectType } from "@/types/digitalTwin"; +import { + getWarehouses, + getAreas, + getLocations, + getLayoutById, + updateLayout, + getMaterialCounts, + getMaterials, +} from "@/lib/api/digitalTwin"; +import type { MaterialData } from "@/types/digitalTwin"; +import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection"; + +// 백엔드 DB 객체 타입 (snake_case) +interface DbObject { + id: number; + object_type: ObjectType; + object_name: string; + position_x: string; + position_y: string; + position_z: string; + size_x: string; + size_y: string; + size_z: string; + rotation?: string; + color: string; + area_key?: string; + loca_key?: string; + loc_type?: string; + material_count?: number; + material_preview_height?: string; + parent_id?: number; + display_order?: number; + locked?: boolean; + visible?: boolean; +} + +const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), { + ssr: false, + loading: () => ( +
+ +
+ ), +}); + +interface DigitalTwinEditorProps { + layoutId: number; + layoutName: string; + onBack: () => void; +} + +export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: DigitalTwinEditorProps) { + const { toast } = useToast(); + const [placedObjects, setPlacedObjects] = useState([]); + const [selectedObject, setSelectedObject] = useState(null); + const [draggedTool, setDraggedTool] = useState(null); + const [draggedAreaData, setDraggedAreaData] = useState(null); // 드래그 중인 Area 정보 + const [draggedLocationData, setDraggedLocationData] = useState(null); // 드래그 중인 Location 정보 + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [externalDbConnections, setExternalDbConnections] = useState<{ id: number; name: string; db_type: string }[]>( + [], + ); + const [selectedDbConnection, setSelectedDbConnection] = useState(null); + const [selectedWarehouse, setSelectedWarehouse] = useState(null); + const [warehouses, setWarehouses] = useState([]); + const [availableAreas, setAvailableAreas] = useState([]); + const [availableLocations, setAvailableLocations] = useState([]); + const [nextObjectId, setNextObjectId] = useState(-1); + const [loadingWarehouses, setLoadingWarehouses] = useState(false); + const [loadingAreas, setLoadingAreas] = useState(false); + const [loadingLocations, setLoadingLocations] = useState(false); + const [materials, setMaterials] = useState([]); + const [loadingMaterials, setLoadingMaterials] = useState(false); + const [showMaterialPanel, setShowMaterialPanel] = useState(false); + + // 테이블 매핑 관련 상태 + const [availableTables, setAvailableTables] = useState([]); + const [loadingTables, setLoadingTables] = useState(false); + const [selectedTables, setSelectedTables] = useState({ + warehouse: "", + area: "", + location: "", + material: "", + }); + const [tableColumns, setTableColumns] = useState<{ [key: string]: string[] }>({}); + const [selectedColumns, setSelectedColumns] = useState({ + warehouseKey: "WAREKEY", + warehouseName: "WARENAME", + areaKey: "AREAKEY", + areaName: "AREANAME", + locationKey: "LOCAKEY", + locationName: "LOCANAME", + materialKey: "STKKEY", + }); + + // placedObjects를 YardPlacement 형식으로 변환 (useMemo로 최적화) + const placements = useMemo(() => { + const now = new Date().toISOString(); // 한 번만 생성 + return placedObjects.map((obj) => ({ + id: obj.id, + yard_layout_id: layoutId, + material_code: null, + material_name: obj.name, + name: obj.name, // 객체 이름 (야드 이름 표시용) + quantity: null, + unit: null, + 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, + data_source_config: null, + data_binding: null, + created_at: now, // 고정된 값 사용 + updated_at: now, // 고정된 값 사용 + material_count: obj.materialCount, + material_preview_height: obj.materialPreview?.height, + })); + }, [placedObjects, layoutId]); + + // 외부 DB 연결 목록 로드 + useEffect(() => { + const loadExternalDbConnections = async () => { + try { + const connections = await ExternalDbConnectionAPI.getConnections({ is_active: "Y" }); + console.log("🔍 외부 DB 연결 목록 (is_active=Y):", connections); + console.log("🔍 연결 ID들:", connections.map(c => c.id)); + setExternalDbConnections( + connections.map((conn) => ({ + id: conn.id!, + name: conn.connection_name, + db_type: conn.db_type, + })), + ); + } catch (error) { + console.error("외부 DB 연결 목록 조회 실패:", error); + toast({ + variant: "destructive", + title: "오류", + description: "외부 DB 연결 목록을 불러오는데 실패했습니다.", + }); + } + }; + + loadExternalDbConnections(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // 컴포넌트 마운트 시 한 번만 실행 + + // 외부 DB 선택 시 테이블 목록 로드 + useEffect(() => { + if (!selectedDbConnection) { + setAvailableTables([]); + setSelectedTables({ warehouse: "", area: "", location: "", material: "" }); + return; + } + + const loadTables = async () => { + try { + setLoadingTables(true); + 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); + } + } catch (error) { + console.error("테이블 목록 조회 실패:", error); + toast({ + variant: "destructive", + title: "오류", + description: "테이블 목록을 불러오는데 실패했습니다.", + }); + } finally { + setLoadingTables(false); + } + }; + + loadTables(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedDbConnection]); + + // 테이블 컬럼 로드 + const loadColumnsForTable = async (tableName: string, type: "warehouse" | "area" | "location" | "material") => { + if (!selectedDbConnection || !tableName) return; + + try { + const { getTablePreview } = await import("@/lib/api/digitalTwin"); + const response = await getTablePreview(selectedDbConnection, tableName); + + console.log(`📊 ${type} 테이블 미리보기:`, response); + + if (response.success && response.data && response.data.length > 0) { + const columns = Object.keys(response.data[0]); + setTableColumns(prev => ({ ...prev, [type]: columns })); + + // 자동 매핑 시도 (기본값 설정) + if (type === "warehouse") { + const keyCol = columns.find(c => c.includes("KEY") || c.includes("ID")) || columns[0]; + const nameCol = columns.find(c => c.includes("NAME") || c.includes("NAM")) || columns[1] || columns[0]; + setSelectedColumns(prev => ({ ...prev, warehouseKey: keyCol, warehouseName: nameCol })); + } + } else { + console.warn(`⚠️ ${tableName} 테이블에 데이터가 없습니다.`); + toast({ + variant: "default", // destructive 대신 default로 변경 (단순 알림) + title: "데이터 없음", + description: `${tableName} 테이블에 데이터가 없습니다.`, + }); + } + } catch (error) { + console.error(`컬럼 로드 실패 (${tableName}):`, error); + } + }; + + // 외부 DB 선택 시 창고 목록 로드 (테이블이 선택되어 있을 때만) + useEffect(() => { + if (!selectedDbConnection || !selectedTables.warehouse) { + setWarehouses([]); + setSelectedWarehouse(null); + return; + } + + const loadWarehouses = async () => { + try { + setLoadingWarehouses(true); + const response = await getWarehouses(selectedDbConnection, selectedTables.warehouse); + console.log("📦 창고 API 응답:", response); + if (response.success && response.data) { + console.log("📦 창고 데이터:", response.data); + setWarehouses(response.data); + } else { + // 외부 DB 연결이 유효하지 않으면 선택 초기화 + console.warn("외부 DB 연결이 유효하지 않습니다:", selectedDbConnection); + setSelectedDbConnection(null); + toast({ + variant: "destructive", + title: "외부 DB 연결 오류", + description: "저장된 외부 DB 연결을 찾을 수 없습니다. 다시 선택해주세요.", + }); + } + } catch (error: any) { + console.error("창고 목록 조회 실패:", error); + // 외부 DB 연결이 존재하지 않으면 선택 초기화 + if (error.response?.status === 500 && error.response?.data?.error?.includes("연결 정보를 찾을 수 없습니다")) { + setSelectedDbConnection(null); + toast({ + variant: "destructive", + title: "외부 DB 연결 오류", + description: "저장된 외부 DB 연결을 찾을 수 없습니다. 다시 선택해주세요.", + }); + } else { + toast({ + variant: "destructive", + title: "오류", + description: "창고 목록을 불러오는데 실패했습니다.", + }); + } + } finally { + setLoadingWarehouses(false); + } + }; + + loadWarehouses(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedDbConnection, selectedTables.warehouse]); // toast 제거, warehouse 테이블 추가 + + // 창고 선택 시 Area 목록 로드 + useEffect(() => { + if (!selectedDbConnection || !selectedWarehouse) { + setAvailableAreas([]); + return; + } + + const loadAreas = async () => { + try { + setLoadingAreas(true); + const response = await getAreas(selectedDbConnection, selectedTables.area, selectedWarehouse); + if (response.success && response.data) { + setAvailableAreas(response.data); + } + } catch (error) { + console.error("Area 목록 조회 실패:", error); + toast({ + variant: "destructive", + title: "오류", + description: "Area 목록을 불러오는데 실패했습니다.", + }); + } finally { + setLoadingAreas(false); + } + }; + + loadAreas(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedDbConnection, selectedWarehouse, selectedTables.area]); // toast 제거, area 테이블 추가 + + // 레이아웃 데이터 로드 + const [layoutData, setLayoutData] = useState<{ layout: any; objects: any[] } | null>(null); + + useEffect(() => { + const loadLayout = async () => { + try { + setIsLoading(true); + const response = await getLayoutById(layoutId); + + if (response.success && response.data) { + const { layout, objects } = response.data; + setLayoutData({ layout, objects }); // 레이아웃 데이터 저장 + + // 객체 데이터 변환 (DB -> PlacedObject) + const loadedObjects: PlacedObject[] = (objects as unknown as DbObject[]).map((obj) => ({ + 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); + + // 다음 임시 ID 설정 (기존 ID 중 최소값 - 1) + const minId = Math.min(...loadedObjects.map((o) => o.id), 0); + setNextObjectId(minId - 1); + + setHasUnsavedChanges(false); + + toast({ + title: "레이아웃 불러오기 완료", + description: `${loadedObjects.length}개의 객체를 불러왔습니다.`, + }); + + // Location 객체들의 자재 개수 로드 + const locationObjects = loadedObjects.filter( + (obj) => + (obj.type === "location-bed" || + obj.type === "location-stp" || + obj.type === "location-temp" || + obj.type === "location-dest") && + obj.locaKey, + ); + if (locationObjects.length > 0) { + const locaKeys = locationObjects.map((obj) => obj.locaKey!); + setTimeout(() => { + loadMaterialCountsForLocations(locaKeys); + }, 100); + } + } else { + throw new Error(response.error || "레이아웃 조회 실패"); + } + } catch (error) { + console.error("레이아웃 로드 실패:", error); + const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다."; + toast({ + variant: "destructive", + title: "오류", + description: errorMessage, + }); + } finally { + setIsLoading(false); + } + }; + + loadLayout(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [layoutId]); // toast 제거 + + // 외부 DB 연결 자동 선택 (externalDbConnections 로드 완료 시) + useEffect(() => { + if (!layoutData || !layoutData.layout.externalDbConnectionId || externalDbConnections.length === 0) { + return; + } + + const layout = layoutData.layout; + console.log("🔍 외부 DB 연결 자동 선택 시도"); + console.log("🔍 레이아웃의 externalDbConnectionId:", layout.externalDbConnectionId); + console.log("🔍 사용 가능한 연결 목록:", externalDbConnections); + + const connectionExists = externalDbConnections.some( + (conn) => conn.id === layout.externalDbConnectionId, + ); + console.log("🔍 연결 존재 여부:", connectionExists); + + if (connectionExists) { + setSelectedDbConnection(layout.externalDbConnectionId); + if (layout.warehouseKey) { + setSelectedWarehouse(layout.warehouseKey); + } + console.log("✅ 외부 DB 연결 자동 선택 완료:", layout.externalDbConnectionId); + } else { + console.warn("⚠️ 저장된 외부 DB 연결을 찾을 수 없습니다:", layout.externalDbConnectionId); + console.warn("⚠️ 사용 가능한 연결 ID들:", externalDbConnections.map(c => c.id)); + toast({ + variant: "destructive", + title: "외부 DB 연결 오류", + description: "저장된 외부 DB 연결을 찾을 수 없습니다. 다시 선택해주세요.", + }); + } + }, [layoutData, externalDbConnections]); // layoutData와 externalDbConnections가 모두 준비되면 실행 + + // 도구 타입별 기본 설정 + const getToolDefaults = (type: ToolType): Partial => { + switch (type) { + case "area": + return { + name: "영역", + size: { x: 20, y: 0.1, z: 20 }, // 4x4 칸 + color: "#3b82f6", // 파란색 + }; + case "location-bed": + return { + name: "베드(BED)", + size: { x: 5, y: 2, z: 5 }, // 1x1 칸 + color: "#10b981", // 에메랄드 + }; + case "location-stp": + return { + name: "정차포인트(STP)", + size: { x: 5, y: 0.5, z: 5 }, // 1x1 칸, 낮은 높이 + color: "#f59e0b", // 주황색 + }; + case "location-temp": + return { + name: "임시베드(TMP)", + size: { x: 5, y: 2, z: 5 }, // 베드와 동일 + color: "#10b981", // 베드와 동일 + }; + case "location-dest": + return { + name: "지정착지(DES)", + size: { x: 5, y: 2, z: 5 }, // 베드와 동일 + color: "#10b981", // 베드와 동일 + }; + // case "crane-gantry": + // return { + // name: "겐트리 크레인", + // size: { x: 5, y: 8, z: 5 }, // 1x1 칸 + // color: "#22c55e", // 녹색 + // }; + case "crane-mobile": + return { + name: "크레인", + size: { x: 5, y: 6, z: 5 }, // 1x1 칸 + color: "#eab308", // 노란색 + }; + case "rack": + return { + name: "랙", + size: { x: 5, y: 3, z: 5 }, // 1x1 칸 + color: "#a855f7", // 보라색 + }; + // case "material": + // return { + // name: "자재", + // size: { x: 5, y: 2, z: 5 }, // 1x1 칸 + // color: "#ef4444", // 빨간색 + // }; + } + }; + + // 도구 드래그 시작 + const handleToolDragStart = (toolType: ToolType) => { + setDraggedTool(toolType); + }; + + // 캔버스에 드롭 + const handleCanvasDrop = (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; + + // 외부 DB 데이터에서 드래그한 경우 해당 정보 사용 + let objectName = defaults.name || "새 객체"; + let areaKey: string | undefined = undefined; + let locaKey: string | undefined = undefined; + let locType: string | undefined = undefined; + + if (draggedTool === "area" && draggedAreaData) { + objectName = draggedAreaData.AREANAME; + areaKey = draggedAreaData.AREAKEY; + } else if ( + (draggedTool === "location-bed" || + draggedTool === "location-stp" || + draggedTool === "location-temp" || + draggedTool === "location-dest") && + draggedLocationData + ) { + objectName = draggedLocationData.LOCANAME || draggedLocationData.LOCAKEY; + areaKey = draggedLocationData.AREAKEY; + locaKey = draggedLocationData.LOCAKEY; + locType = draggedLocationData.LOCTYPE; + } + + const newObject: PlacedObject = { + id: nextObjectId, + type: draggedTool, + name: objectName, + position: { x, y: yPosition, z }, + size: defaults.size || { x: 5, y: 5, z: 5 }, + color: defaults.color || "#9ca3af", + areaKey, + locaKey, + locType, + }; + + setPlacedObjects((prev) => [...prev, newObject]); + setSelectedObject(newObject); + setNextObjectId((prev) => prev - 1); + setHasUnsavedChanges(true); + setDraggedTool(null); + setDraggedAreaData(null); + setDraggedLocationData(null); + + // Location 배치 시 자재 개수 로드 + if ( + (draggedTool === "location-bed" || + draggedTool === "location-stp" || + draggedTool === "location-temp" || + draggedTool === "location-dest") && + locaKey + ) { + // 새 객체 추가 후 자재 개수 로드 (약간의 딜레이를 두어 state 업데이트 완료 후 실행) + setTimeout(() => { + loadMaterialCountsForLocations(); + }, 100); + } + }; + + // Location의 자재 목록 로드 + const loadMaterialsForLocation = async (locaKey: string) => { + if (!selectedDbConnection) return; + + try { + setLoadingMaterials(true); + setShowMaterialPanel(true); + const response = await getMaterials(selectedDbConnection, selectedTables.material, locaKey); + if (response.success && response.data) { + // LOLAYER 순으로 정렬 + const sortedMaterials = response.data.sort((a, b) => a.LOLAYER - b.LOLAYER); + setMaterials(sortedMaterials); + } else { + setMaterials([]); + toast({ + variant: "destructive", + title: "자재 조회 실패", + description: response.error || "자재 목록을 불러올 수 없습니다.", + }); + } + } catch (error) { + console.error("자재 로드 실패:", error); + setMaterials([]); + toast({ + variant: "destructive", + title: "오류", + description: "자재 목록을 불러오는데 실패했습니다.", + }); + } finally { + setLoadingMaterials(false); + } + }; + + // 객체 클릭 + const handleObjectClick = (objectId: number | null) => { + if (objectId === null) { + setSelectedObject(null); + setShowMaterialPanel(false); + return; + } + + const obj = placedObjects.find((o) => o.id === objectId); + setSelectedObject(obj || null); + + // Area를 클릭한 경우, 해당 Area의 Location 목록 로드 + if (obj && obj.type === "area" && obj.areaKey && selectedDbConnection) { + loadLocationsForArea(obj.areaKey); + setShowMaterialPanel(false); + } + // Location을 클릭한 경우, 해당 Location의 자재 목록 로드 + else if ( + obj && + (obj.type === "location-bed" || + obj.type === "location-stp" || + obj.type === "location-temp" || + obj.type === "location-dest") && + obj.locaKey && + selectedDbConnection + ) { + loadMaterialsForLocation(obj.locaKey); + } else { + setShowMaterialPanel(false); + } + }; + + // Location별 자재 개수 로드 (locaKeys를 직접 받음) + const loadMaterialCountsForLocations = async (locaKeys: string[]) => { + if (!selectedDbConnection || locaKeys.length === 0) return; + + try { + const response = await getMaterialCounts(selectedDbConnection, selectedTables.material, locaKeys); + if (response.success && response.data) { + // 각 Location 객체에 자재 개수 업데이트 + setPlacedObjects((prev) => + prev.map((obj) => { + const materialCount = response.data?.find((mc) => mc.LOCAKEY === obj.locaKey); + if (materialCount) { + return { + ...obj, + materialCount: materialCount.material_count, + materialPreview: { + height: materialCount.max_layer * 1.5, // 층당 1.5 높이 (시각적) + }, + }; + } + return obj; + }), + ); + } + } catch (error) { + console.error("자재 개수 로드 실패:", error); + } + }; + + // 특정 Area의 Location 목록 로드 + const loadLocationsForArea = async (areaKey: string) => { + if (!selectedDbConnection) return; + + try { + setLoadingLocations(true); + const response = await getLocations(selectedDbConnection, selectedTables.location, areaKey); + if (response.success && response.data) { + setAvailableLocations(response.data); + toast({ + title: "Location 로드 완료", + description: `${response.data.length}개 Location을 불러왔습니다.`, + }); + } + } catch (error) { + console.error("Location 목록 조회 실패:", error); + toast({ + variant: "destructive", + title: "오류", + description: "Location 목록을 불러오는데 실패했습니다.", + }); + } finally { + setLoadingLocations(false); + } + }; + + // 객체 이동 + const handleObjectMove = (objectId: number, newX: number, newZ: number, newY?: number) => { + // Yard3DCanvas에서 이미 스냅+오프셋이 완료된 좌표를 받음 + // 그대로 저장하면 됨 + setPlacedObjects((prev) => + prev.map((obj) => { + if (obj.id === objectId) { + const newPosition = { ...obj.position, x: newX, z: newZ }; + if (newY !== undefined) { + newPosition.y = newY; + } + return { ...obj, position: newPosition }; + } + return obj; + }), + ); + + if (selectedObject?.id === objectId) { + setSelectedObject((prev) => { + if (!prev) return null; + const newPosition = { ...prev.position, x: newX, z: newZ }; + if (newY !== undefined) { + newPosition.y = newY; + } + return { ...prev, position: newPosition }; + }); + } + + setHasUnsavedChanges(true); + }; + + // 객체 속성 업데이트 + const handleObjectUpdate = (updates: Partial) => { + if (!selectedObject) return; + + let finalUpdates = { ...updates }; + + // 크기 변경 시에만 5 단위로 스냅하고 위치 조정 (position 변경은 제외) + if (updates.size && !updates.position) { + // placedObjects 배열에서 실제 저장된 객체를 가져옴 (selectedObject 상태가 아닌) + const actualObject = placedObjects.find((obj) => obj.id === selectedObject.id); + if (!actualObject) return; + + const oldSize = actualObject.size; + const newSize = { ...oldSize, ...updates.size }; + + // W, D를 5 단위로 스냅 + newSize.x = Math.max(5, Math.round(newSize.x / 5) * 5); + newSize.z = Math.max(5, Math.round(newSize.z / 5) * 5); + + // H는 자유롭게 (Area 제외) + if (actualObject.type !== "area") { + newSize.y = Math.max(0.1, newSize.y); + } + + // 크기 차이 계산 + const deltaX = newSize.x - oldSize.x; + const deltaZ = newSize.z - oldSize.z; + const deltaY = newSize.y - oldSize.y; + + // 위치 조정: 왼쪽/뒤쪽/바닥 모서리 고정, 오른쪽/앞쪽/위쪽으로만 늘어남 + // Three.js는 중심점 기준이므로 크기 차이의 절반만큼 위치 이동 + // actualObject.position (실제 배열의 position)을 기준으로 계산 + const newPosition = { + ...actualObject.position, + x: actualObject.position.x + deltaX / 2, // 오른쪽으로 늘어남 + y: actualObject.position.y + deltaY / 2, // 위쪽으로 늘어남 (바닥 고정) + z: actualObject.position.z + deltaZ / 2, // 앞쪽으로 늘어남 + }; + + finalUpdates = { + ...finalUpdates, + size: newSize, + position: newPosition, + }; + } + + setPlacedObjects((prev) => prev.map((obj) => (obj.id === selectedObject.id ? { ...obj, ...finalUpdates } : obj))); + + setSelectedObject((prev) => (prev ? { ...prev, ...finalUpdates } : null)); + setHasUnsavedChanges(true); + }; + + // 객체 삭제 + const handleObjectDelete = () => { + if (!selectedObject) return; + + setPlacedObjects((prev) => prev.filter((obj) => obj.id !== selectedObject.id)); + setSelectedObject(null); + setHasUnsavedChanges(true); + }; + + // 저장 + const handleSave = async () => { + if (!selectedDbConnection) { + toast({ + title: "외부 DB 선택 필요", + description: "외부 데이터베이스 연결을 선택하세요.", + variant: "destructive", + }); + return; + } + + if (!selectedWarehouse) { + toast({ + title: "창고 선택 필요", + description: "창고를 선택하세요.", + variant: "destructive", + }); + return; + } + + setIsSaving(true); + try { + const response = await updateLayout(layoutId, { + layoutName: layoutName, + description: undefined, + objects: placedObjects.map((obj, index) => ({ + ...obj, + displayOrder: index, // 현재 순서대로 저장 + })), + }); + + if (response.success) { + toast({ + title: "저장 완료", + description: `${placedObjects.length}개의 객체가 저장되었습니다.`, + }); + + setHasUnsavedChanges(false); + + // 저장 후 DB에서 할당된 ID로 객체 업데이트 (필요 시) + // 현재는 updateLayout이 기존 객체 삭제 후 재생성하므로 + // 레이아웃 다시 불러오기 + const reloadResponse = await getLayoutById(layoutId); + if (reloadResponse.success && reloadResponse.data) { + const { objects } = reloadResponse.data; + const reloadedObjects: PlacedObject[] = (objects as unknown as DbObject[]).map((obj) => ({ + 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(reloadedObjects); + } + } else { + throw new Error(response.error || "레이아웃 저장 실패"); + } + } catch (error) { + console.error("저장 실패:", error); + const errorMessage = error instanceof Error ? error.message : "레이아웃 저장에 실패했습니다."; + toast({ + title: "저장 실패", + description: errorMessage, + variant: "destructive", + }); + } finally { + setIsSaving(false); + } + }; + + return ( +
+ {/* 상단 툴바 */} +
+
+ +
+

{layoutName}

+

디지털 트윈 야드 편집

+
+
+ +
+ {hasUnsavedChanges && 미저장 변경사항 있음} + +
+
+ + {/* 도구 팔레트 */} +
+ 도구: + {[ + { 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: "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" }, + ].map((tool) => { + const Icon = tool.icon; + return ( +
handleToolDragStart(tool.type)} + className="bg-background hover:bg-accent flex cursor-grab items-center gap-1 rounded-md border px-3 py-2 transition-colors active:cursor-grabbing" + title={`${tool.label} 드래그하여 배치`} + > + + {tool.label} +
+ ); + })} +
+ + {/* 메인 영역 */} +
+ {/* 좌측: 외부 DB 선택 + 객체 목록 */} +
+ {/* 스크롤 영역 */} +
+ {/* 외부 DB 선택 */} +
+ + +
+ + {/* 테이블 매핑 선택 */} + {selectedDbConnection && ( +
+ + {loadingTables ? ( +
+ +
+ ) : ( + <> +
+ + +
+ + {/* 창고 컬럼 매핑 */} + {selectedTables.warehouse && tableColumns.warehouse && ( +
+
+ + +
+
+ + +
+
+ )} + +
+ + +
+ +
+ + +
+ +
+ + +
+ + )} +
+ )} + + {/* 창고 선택 */} + {selectedDbConnection && selectedTables.warehouse && ( +
+ + {loadingWarehouses ? ( +
+ +
+ ) : ( + + )} +
+ )} + + {/* Area 목록 */} + {selectedDbConnection && selectedWarehouse && ( +
+
+

사용 가능한 Area

+ {loadingAreas && } +
+ + {availableAreas.length === 0 ? ( +

Area가 없습니다

+ ) : ( +
+ {availableAreas.map((area) => ( +
{ + // Area 정보를 임시 저장 + setDraggedTool("area"); + setDraggedAreaData(area); + }} + onDragEnd={() => { + setDraggedAreaData(null); + }} + className="bg-background hover:bg-accent cursor-grab rounded-lg border p-3 transition-all active:cursor-grabbing" + > +
+
+

{area.AREANAME}

+

{area.AREAKEY}

+
+ +
+
+ ))} +
+ )} +
+ )} + + {/* Location 목록 (Area 클릭 시 표시) */} + {availableLocations.length > 0 && ( +
+
+

사용 가능한 Location

+ {loadingLocations && } +
+ +
+ {availableLocations.map((location) => { + // Location 타입에 따라 ObjectType 결정 + let locationType: ToolType = "location-bed"; + if (location.LOCTYPE === "STP") { + locationType = "location-stp"; + } else if (location.LOCTYPE === "TMP") { + locationType = "location-temp"; + } else if (location.LOCTYPE === "DES") { + locationType = "location-dest"; + } + + return ( +
{ + // Location 정보를 임시 저장 + setDraggedTool(locationType); + setDraggedLocationData(location); + }} + onDragEnd={() => { + setDraggedLocationData(null); + }} + className="bg-background hover:bg-accent cursor-grab rounded-lg border p-3 transition-all active:cursor-grabbing" + > +
+
+

{location.LOCANAME || location.LOCAKEY}

+
+ {location.LOCAKEY} + {location.LOCTYPE} +
+
+ +
+
+ ); + })} +
+
+ )} +
+ + {/* 배치된 객체 목록 */} +
+

배치된 객체 ({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}

} +
+ ))} +
+ )} +
+
+ + {/* 중앙: 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; + + // 그리드 크기 (5 단위) + const gridSize = 5; + + // 그리드에 스냅 + // Area(20x20)는 그리드 교차점에, 다른 객체(5x5)는 타일 중앙에 + 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; + } + + handleCanvasDrop(snappedX, snappedZ); + }} + > + {isLoading ? ( +
+ +
+ ) : ( + handleObjectClick(placement?.id || null)} + onPlacementDrag={(id, position) => handleObjectMove(id, position.x, position.z, position.y)} + focusOnPlacementId={null} + onCollisionDetected={() => {}} + /> + )} +
+ + {/* 우측: 객체 속성 편집 or 자재 목록 */} +
+ {showMaterialPanel && selectedObject ? ( + /* 자재 목록 패널 */ +
+
+
+

자재 목록

+

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

+
+ +
+ + {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 ? ( +
+

객체 속성

+ +
+ {/* 이름 */} +
+ + handleObjectUpdate({ name: e.target.value })} + className="mt-1.5 h-9 text-sm" + /> +
+ + {/* 위치 */} +
+ +
+
+ + + handleObjectUpdate({ + position: { + ...selectedObject.position, + x: parseFloat(e.target.value), + }, + }) + } + className="h-9 text-sm" + /> +
+
+ + + handleObjectUpdate({ + position: { + ...selectedObject.position, + z: parseFloat(e.target.value), + }, + }) + } + className="h-9 text-sm" + /> +
+
+
+ + {/* 크기 */} +
+ +
+
+ + + handleObjectUpdate({ + size: { + ...selectedObject.size, + x: parseFloat(e.target.value), + }, + }) + } + className="h-9 text-sm" + /> +
+
+ + + handleObjectUpdate({ + size: { + ...selectedObject.size, + y: parseFloat(e.target.value), + }, + }) + } + className="h-9 text-sm" + /> +
+
+ + + handleObjectUpdate({ + size: { + ...selectedObject.size, + z: parseFloat(e.target.value), + }, + }) + } + className="h-9 text-sm" + /> +
+
+
+ + {/* 색상 */} +
+ + 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 new file mode 100644 index 00000000..d8162e31 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx @@ -0,0 +1,501 @@ +"use client"; + +import { useState, useEffect, useMemo } from "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 { 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: () => ( +
+ +
+ ), +}); + +interface DigitalTwinViewerProps { + layoutId: number; +} + +export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) { + 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 loadLayout = async () => { + try { + setIsLoading(true); + 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); + const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다."; + toast({ + variant: "destructive", + title: "오류", + description: errorMessage, + }); + } finally { + setIsLoading(false); + } + }; + + loadLayout(); + }, [layoutId, toast]); + + // 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); + } + }; + + // 객체 클릭 + 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 (searchQuery) { + const query = searchQuery.toLowerCase(); + return ( + obj.name.toLowerCase().includes(query) || + obj.areaKey?.toLowerCase().includes(query) || + obj.locaKey?.toLowerCase().includes(query) + ); + } + + return true; + }); + }, [placedObjects, filterType, searchQuery]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* 상단 헤더 */} +
+
+

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

+

읽기 전용 뷰

+
+
+ + {/* 메인 영역 */} +
+ {/* 좌측: 검색/필터 */} +
+
+ {/* 검색 */} +
+ +
+ + setSearchQuery(e.target.value)} + placeholder="이름, Area, Location 검색..." + className="h-10 pl-9 text-sm" + /> + {searchQuery && ( + + )} +
+
+ + {/* 타입 필터 */} +
+ + +
+ + {/* 필터 초기화 */} + {(searchQuery || filterType !== "all") && ( + + )} +
+ + {/* 객체 목록 */} +
+ + {filteredObjects.length === 0 ? ( +
+ {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 = "랙"; + + 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}개 +

+ )} +
+
+ ); + })} +
+ )} +
+
+ + {/* 중앙: 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 eba640cf..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 { @@ -10,6 +10,7 @@ interface YardPlacement { yard_layout_id?: number; material_code?: string | null; material_name?: string | null; + name?: string | null; // 객체 이름 (야드 이름 등) quantity?: number | null; unit?: string | null; position_x: number; @@ -22,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 { @@ -37,12 +40,9 @@ interface Yard3DCanvasProps { // 좌표를 그리드 칸의 중심에 스냅 (마인크래프트 스타일) // Three.js Box의 position은 중심점이므로, 그리드 칸의 중심에 배치해야 칸에 딱 맞음 function snapToGrid(value: number, gridSize: number): number { - // 가장 가까운 그리드 칸 찾기 - const gridIndex = Math.round(value / gridSize); - // 그리드 칸의 중심점 반환 - // gridSize=5일 때: ..., -7.5, -2.5, 2.5, 7.5, 12.5, 17.5... - // 이렇게 하면 Box가 칸 안에 정확히 들어감 - return gridIndex * gridSize + gridSize / 2; + // 가장 가까운 그리드 교차점으로 스냅 (오프셋 없음) + // DigitalTwinEditor에서 오프셋 처리하므로 여기서는 순수 스냅만 + return Math.round(value / gridSize) * gridSize; } // 자재 박스 컴포넌트 (드래그 가능) @@ -55,7 +55,6 @@ function MaterialBox({ onDragEnd, gridSize = 5, allPlacements = [], - onCollisionDetected, }: { placement: YardPlacement; isSelected: boolean; @@ -71,19 +70,70 @@ function MaterialBox({ const [isDragging, setIsDragging] = useState(false); const dragStartPos = useRef<{ x: number; y: number; z: number }>({ x: 0, y: 0, z: 0 }); const mouseStartPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + const dragOffset = useRef<{ x: number; z: number }>({ x: 0, z: 0 }); // 마우스와 객체 중심 간 오프셋 const { camera, gl } = useThree(); + const [glowIntensity, setGlowIntensity] = useState(1); + + // 선택 시 빛나는 애니메이션 + useEffect(() => { + if (!isSelected) { + setGlowIntensity(1); + return; + } + + let animationId: number; + const startTime = Date.now(); + + const animate = () => { + const elapsed = Date.now() - startTime; + const intensity = 1 + Math.sin(elapsed * 0.003) * 0.5; // 0.5 ~ 1.5 사이 진동 + setGlowIntensity(intensity); + animationId = requestAnimationFrame(animate); + }; + + animate(); + + return () => { + if (animationId) { + cancelAnimationFrame(animationId); + } + }; + }, [isSelected]); // 특정 좌표에 요소를 배치할 수 있는지 확인하고, 필요하면 Y 위치를 조정 const checkCollisionAndAdjustY = (x: number, y: number, z: number): { hasCollision: boolean; adjustedY: number } => { - const palletHeight = 0.3; // 팔레트 높이 - const palletGap = 0.05; // 팔레트와 박스 사이 간격 + if (!allPlacements || allPlacements.length === 0) { + // 다른 객체가 없으면 기본 높이 + const objectType = placement.data_source_type as string | null; + const defaultY = objectType === "area" ? 0.05 : (placement.size_y || gridSize) / 2; + return { + hasCollision: false, + adjustedY: defaultY, + }; + } - const mySize = placement.size_x || gridSize; // 내 크기 (5) - const myHalfSize = mySize / 2; // 2.5 - const mySizeY = placement.size_y || gridSize; // 박스 높이 (5) - const myTotalHeight = mySizeY + palletHeight + palletGap; // 팔레트 포함한 전체 높이 + // 내 크기 정보 + const mySizeX = placement.size_x || gridSize; + const mySizeZ = placement.size_z || gridSize; + const mySizeY = placement.size_y || gridSize; - let maxYBelow = gridSize / 2; // 기본 바닥 높이 (2.5) + // 내 바운딩 박스 (좌측 하단 모서리 기준) + const myMinX = x - mySizeX / 2; + const myMaxX = x + mySizeX / 2; + const myMinZ = z - mySizeZ / 2; + const myMaxZ = z + mySizeZ / 2; + + const objectType = placement.data_source_type as string | null; + const defaultY = objectType === "area" ? 0.05 : mySizeY / 2; + let maxYBelow = defaultY; + + // Area는 스택되지 않음 (항상 바닥에 배치) + if (objectType === "area") { + return { + hasCollision: false, + adjustedY: defaultY, + }; + } for (const p of allPlacements) { // 자기 자신은 제외 @@ -91,39 +141,31 @@ function MaterialBox({ continue; } - const pSize = p.size_x || gridSize; // 상대방 크기 (5) - const pHalfSize = pSize / 2; // 2.5 - const pSizeY = p.size_y || gridSize; // 상대방 박스 높이 (5) - const pTotalHeight = pSizeY + palletHeight + palletGap; // 상대방 팔레트 포함 전체 높이 + // 상대방 크기 정보 + const pSizeX = p.size_x || gridSize; + const pSizeZ = p.size_z || gridSize; + const pSizeY = p.size_y || gridSize; - // 1단계: 넓은 범위로 겹침 감지 (살짝만 가까이 가도 감지) - const detectionMargin = 0.5; // 감지 범위 확장 (0.5 유닛) - const isNearby = - Math.abs(x - p.position_x) < myHalfSize + pHalfSize + detectionMargin && // X축 근접 - Math.abs(z - p.position_z) < myHalfSize + pHalfSize + detectionMargin; // Z축 근접 + // 상대방 바운딩 박스 + const pMinX = p.position_x - pSizeX / 2; + const pMaxX = p.position_x + pSizeX / 2; + const pMinZ = p.position_z - pSizeZ / 2; + const pMaxZ = p.position_z + pSizeZ / 2; - if (isNearby) { - // 2단계: 실제로 겹치는지 정확히 판단 (바닥에 둘지, 위에 둘지 결정) - const isActuallyOverlapping = - Math.abs(x - p.position_x) < myHalfSize + pHalfSize && // X축 실제 겹침 - Math.abs(z - p.position_z) < myHalfSize + pHalfSize; // Z축 실제 겹침 + // AABB 충돌 감지 (2D 평면에서) + const isOverlapping = myMinX < pMaxX && myMaxX > pMinX && myMinZ < pMaxZ && myMaxZ > pMinZ; - if (isActuallyOverlapping) { - // 실제로 겹침: 위에 배치 - // 상대방 전체 높이 (박스 + 팔레트)의 윗면 계산 - const topOfOtherElement = p.position_y + pTotalHeight / 2; - // 내 전체 높이의 절반을 더해서 내가 올라갈 Y 위치 계산 - const myYOnTop = topOfOtherElement + myTotalHeight / 2; + if (isOverlapping) { + // 겹침: 상대방 위에 배치 + const topOfOtherElement = p.position_y + pSizeY / 2; + const myYOnTop = topOfOtherElement + mySizeY / 2; - if (myYOnTop > maxYBelow) { - maxYBelow = myYOnTop; - } + if (myYOnTop > maxYBelow) { + maxYBelow = myYOnTop; } - // 근처에만 있고 실제로 안 겹침: 바닥에 배치 (maxYBelow 유지) } } - // 요청한 Y와 조정된 Y가 다르면 충돌로 간주 (위로 올려야 함) const needsAdjustment = Math.abs(y - maxYBelow) > 0.1; return { @@ -160,46 +202,60 @@ function MaterialBox({ e.preventDefault(); e.stopPropagation(); - // 마우스 이동 거리 계산 (픽셀) - const deltaX = e.clientX - mouseStartPos.current.x; - const deltaY = e.clientY - mouseStartPos.current.y; + // 마우스 좌표를 정규화 (-1 ~ 1) + const rect = gl.domElement.getBoundingClientRect(); + const mouseX = ((e.clientX - rect.left) / rect.width) * 2 - 1; + const mouseY = -((e.clientY - rect.top) / rect.height) * 2 + 1; - // 카메라 거리를 고려한 스케일 팩터 - const distance = camera.position.distanceTo(meshRef.current.position); - const scaleFactor = distance / 500; // 조정 가능한 값 + // Raycaster로 바닥 평면과의 교차점 계산 + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera(new THREE.Vector2(mouseX, mouseY), camera); - // 카메라 방향 벡터 - const cameraDirection = new THREE.Vector3(); - camera.getWorldDirection(cameraDirection); + // 바닥 평면 (y = 0) + const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); + const intersectPoint = new THREE.Vector3(); + const hasIntersection = raycaster.ray.intersectPlane(plane, intersectPoint); - // 카메라의 우측 벡터 (X축 이동용) - const right = new THREE.Vector3(); - right.crossVectors(camera.up, cameraDirection).normalize(); + if (!hasIntersection) { + return; + } - // 실제 3D 공간에서의 이동량 계산 - const moveRight = right.multiplyScalar(-deltaX * scaleFactor); - const moveForward = new THREE.Vector3(-cameraDirection.x, 0, -cameraDirection.z) - .normalize() - .multiplyScalar(deltaY * scaleFactor); - - // 최종 위치 계산 - const finalX = dragStartPos.current.x + moveRight.x + moveForward.x; - const finalZ = dragStartPos.current.z + moveRight.z + moveForward.z; + // 마우스 위치에 드래그 시작 시 저장한 오프셋 적용 + const finalX = intersectPoint.x + dragOffset.current.x; + const finalZ = intersectPoint.z + dragOffset.current.z; // NaN 검증 if (isNaN(finalX) || isNaN(finalZ)) { return; } - // 그리드에 스냅 - const snappedX = snapToGrid(finalX, gridSize); - const snappedZ = snapToGrid(finalZ, gridSize); + // 객체의 좌측 하단 모서리 좌표 계산 (크기 / 2를 빼서) + const sizeX = placement.size_x || 5; + const sizeZ = placement.size_z || 5; + + const cornerX = finalX - sizeX / 2; + const cornerZ = finalZ - sizeZ / 2; + + // 좌측 하단 모서리를 그리드에 스냅 + const snappedCornerX = snapToGrid(cornerX, gridSize); + const snappedCornerZ = snapToGrid(cornerZ, gridSize); + + // 스냅된 모서리로부터 중심 위치 계산 + const finalSnappedX = snappedCornerX + sizeX / 2; + const finalSnappedZ = snappedCornerZ + sizeZ / 2; + + console.log("🐛 드래그 중:", { + 마우스_화면: { x: e.clientX, y: e.clientY }, + 정규화_마우스: { x: mouseX, y: mouseY }, + 교차점: { x: finalX, z: finalZ }, + 스냅후: { x: finalSnappedX, z: finalSnappedZ }, + }); // 충돌 체크 및 Y 위치 조정 - const { adjustedY } = checkCollisionAndAdjustY(snappedX, dragStartPos.current.y, snappedZ); + const { adjustedY } = checkCollisionAndAdjustY(finalSnappedX, dragStartPos.current.y, finalSnappedZ); - // 즉시 mesh 위치 업데이트 (조정된 Y 위치로) - meshRef.current.position.set(finalX, adjustedY, finalZ); + // 즉시 mesh 위치 업데이트 (스냅된 위치로) + meshRef.current.position.set(finalSnappedX, adjustedY, finalSnappedZ); // ⚠️ 드래그 중에는 상태 업데이트 안 함 (미리보기만) // 실제 저장은 handleGlobalMouseUp에서만 수행 @@ -217,23 +273,21 @@ function MaterialBox({ const hasMoved = deltaX > minMovement || deltaZ > minMovement; if (hasMoved) { - // 실제로 드래그한 경우: 그리드에 스냅 - const snappedX = snapToGrid(currentPos.x, gridSize); - const snappedZ = snapToGrid(currentPos.z, gridSize); - - // Y 위치 조정 (마인크래프트처럼 쌓기) - const { adjustedY } = checkCollisionAndAdjustY(snappedX, currentPos.y, snappedZ); + // 실제로 드래그한 경우: 이미 handleGlobalMouseMove에서 스냅됨 + // currentPos는 이미 스냅+오프셋이 적용된 값이므로 그대로 사용 + const finalX = currentPos.x; + const finalY = currentPos.y; + const finalZ = currentPos.z; // ✅ 항상 배치 가능 (위로 올라가므로) - console.log("✅ 배치 완료! 저장:", { x: snappedX, y: adjustedY, z: snappedZ }); - meshRef.current.position.set(snappedX, adjustedY, snappedZ); + console.log("✅ 배치 완료! 저장:", { x: finalX, y: finalY, z: finalZ }); - // 최종 위치 저장 (조정된 Y 위치로) + // 최종 위치 저장 if (onDrag) { onDrag({ - x: snappedX, - y: adjustedY, - z: snappedZ, + x: finalX, + y: finalY, + z: finalZ, }); } } else { @@ -284,6 +338,29 @@ function MaterialBox({ y: e.clientY, }; + // 마우스 클릭 위치를 3D 좌표로 변환 + const rect = gl.domElement.getBoundingClientRect(); + const mouseX = ((e.clientX - rect.left) / rect.width) * 2 - 1; + const mouseY = -((e.clientY - rect.top) / rect.height) * 2 + 1; + + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera(new THREE.Vector2(mouseX, mouseY), camera); + + // 바닥 평면과의 교차점 계산 + const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); + const intersectPoint = new THREE.Vector3(); + const hasIntersection = raycaster.ray.intersectPlane(plane, intersectPoint); + + if (hasIntersection) { + // 마우스 클릭 위치와 객체 중심 간의 오프셋 저장 + dragOffset.current = { + x: currentPos.x - intersectPoint.x, + z: currentPos.z - intersectPoint.z, + }; + } else { + dragOffset.current = { x: 0, z: 0 }; + } + setIsDragging(true); gl.domElement.style.cursor = "grabbing"; if (onDragStart) { @@ -304,6 +381,525 @@ function MaterialBox({ // 팔레트 위치 계산: 박스 하단부터 시작 const palletYOffset = -(boxHeight / 2) - palletHeight / 2 - palletGap; + // 객체 타입 (data_source_type에 저장됨) + const objectType = placement.data_source_type as string | null; + + // 타입별 렌더링 + const renderObjectByType = () => { + switch (objectType) { + case "area": + // Area: 투명한 바닥 + 두꺼운 외곽선 (mesh) + 이름 텍스트 + const borderThickness = 0.3; // 외곽선 두께 + return ( + <> + {/* 투명한 메쉬 (클릭 영역) */} + + + + + + {/* 두꺼운 외곽선 - 4개의 막대로 구현 */} + {/* 상단 */} + + + + + {/* 하단 */} + + + + + {/* 좌측 */} + + + + + {/* 우측 */} + + + + + + {/* 선택 시 빛나는 효과 */} + {isSelected && ( + <> + + + + + + + + + + + + + + + + + + )} + + {/* Area 이름 텍스트 */} + {placement.name && ( + + {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 ( + // + // {/* 왼쪽 기둥 */} + // + // + // + // {/* 오른쪽 기둥 */} + // + // + // + // {/* 상단 빔 */} + // + // + // + // {/* 호이스트 (크레인 훅) */} + // + // + // + // + // ); + + case "crane-mobile": + // 이동식 크레인: 하부(트랙) + 회전대 + 캐빈 + 붐대 + 카운터웨이트 + 후크 + return ( + + {/* 하부 - 크롤러 트랙 (좌측) */} + + + + {/* 하부 - 크롤러 트랙 (우측) */} + + + + + {/* 회전 플랫폼 */} + + + + + {/* 엔진룸 (뒤쪽) */} + + + + + {/* 캐빈 (운전실) - 앞쪽 */} + + + + + {/* 붐대 베이스 (회전 지점) */} + + + + + {/* 메인 붐대 (하단 섹션) */} + + + + + {/* 메인 붐대 (상단 섹션 - 연장) */} + + + + + {/* 카운터웨이트 (뒤쪽 균형추) */} + + + + + {/* 후크 케이블 */} + + + + + {/* 후크 */} + + + + + {/* 지브 와이어 (지지 케이블) */} + + + + + ); + + case "rack": + // 랙: 프레임 구조 + return ( + + {/* 4개 기둥 */} + {[ + [-boxWidth * 0.4, -boxDepth * 0.4], + [boxWidth * 0.4, -boxDepth * 0.4], + [-boxWidth * 0.4, boxDepth * 0.4], + [boxWidth * 0.4, boxDepth * 0.4], + ].map(([x, z], idx) => ( + + + + ))} + {/* 선반 (3단) */} + {[-boxHeight * 0.3, 0, boxHeight * 0.3].map((y, idx) => ( + + + + ))} + + ); + + case "plate-stack": + default: + // 후판 스택: 팔레트 + 박스 (기존 렌더링) + return ( + <> + {/* 팔레트 그룹 - 박스 하단에 붙어있도록 */} + + {/* 상단 가로 판자들 (5개) */} + {[-boxDepth * 0.4, -boxDepth * 0.2, 0, boxDepth * 0.2, boxDepth * 0.4].map((zOffset, idx) => ( + + + + + + + + ))} + + {/* 중간 세로 받침대 (3개) */} + {[-boxWidth * 0.35, 0, boxWidth * 0.35].map((xOffset, idx) => ( + + + + + + + + ))} + + {/* 하단 가로 판자들 (3개) */} + {[-boxDepth * 0.3, 0, boxDepth * 0.3].map((zOffset, idx) => ( + + + + + + + + ))} + + + {/* 메인 박스 */} + + {/* 메인 재질 - 골판지 느낌 */} + + + {/* 외곽선 - 더 진하게 */} + + + + + + + ); + } + }; + return ( - {/* 팔레트 그룹 - 박스 하단에 붙어있도록 */} - - {/* 상단 가로 판자들 (5개) */} - {[-boxDepth * 0.4, -boxDepth * 0.2, 0, boxDepth * 0.2, boxDepth * 0.4].map((zOffset, idx) => ( - - - - - - - - ))} - - {/* 중간 세로 받침대 (3개) */} - {[-boxWidth * 0.35, 0, boxWidth * 0.35].map((xOffset, idx) => ( - - - - - - - - ))} - - {/* 하단 가로 판자들 (3개) */} - {[-boxDepth * 0.3, 0, boxDepth * 0.3].map((zOffset, idx) => ( - - - - - - - - ))} - - - {/* 메인 박스 */} - - {/* 메인 재질 - 골판지 느낌 */} - - - {/* 외곽선 - 더 진하게 */} - - - - - - - {/* 포장 테이프 (가로) - 윗면 */} - {isConfigured && ( - <> - {/* 테이프 세로 */} - - - - - )} - - {/* 자재명 라벨 스티커 (앞면) - 흰색 배경 */} - {isConfigured && placement.material_name && ( - - {/* 라벨 배경 (흰색 스티커) */} - - - - - - - - {/* 라벨 텍스트 */} - - {placement.material_name} - - - )} - - {/* 수량 라벨 (윗면) - 큰 글씨 */} - {isConfigured && placement.quantity && ( - - {placement.quantity} {placement.unit || ""} - - )} - - {/* 디테일 표시 */} - {isConfigured && ( - <> - {/* 화살표 표시 (이 쪽이 위) */} - - - ▲ - - - UP - - - - )} + {renderObjectByType()} ); } @@ -563,15 +1025,18 @@ function Scene({ + {/* 배경색 */} + + {/* 바닥 그리드 (타일을 4등분) */} +
+ {/* ========== 하부 크롤러 트랙 시스템 ========== */} + {/* 좌측 트랙 메인 */} + + + + {/* 좌측 트랙 상부 롤러 */} + + + + + + + + + + + {/* 우측 트랙 메인 */} + + + + {/* 우측 트랙 상부 롤러 */} + + + + + + + + + + + {/* 트랙 연결 프레임 */} + + + + + {/* ========== 회전 상부 구조 ========== */} + {/* 메인 회전 플랫폼 */} + + + + {/* 회전 베어링 하우징 */} + + + + + {/* ========== 엔진 및 유압 시스템 ========== */} + {/* 엔진룸 메인 */} + + + + {/* 유압 펌프 하우징 */} + + + + + + + {/* 배기 파이프 */} + + + + + {/* ========== 운전실 (캐빈) ========== */} + {/* 캐빈 메인 바디 */} + + + + {/* 캐빈 창문 */} + + + + {/* 캐빈 지붕 */} + + + + + {/* ========== 붐대 시스템 ========== */} + {/* 붐대 마운트 베이스 */} + + + + {/* 붐대 힌지 실린더 (유압) */} + + + + + {/* 메인 붐대 하단 섹션 */} + + + + {/* 붐대 상단 섹션 (텔레스코픽) */} + + + + {/* 붐대 최상단 섹션 */} + + + + + {/* 붐대 트러스 구조 (디테일) */} + {[-0.15, -0.05, 0.05, 0.15].map((offset, idx) => ( + + + + ))} + + {/* ========== 카운터웨이트 시스템 ========== */} + {/* 카운터웨이트 메인 블록 */} + + + + {/* 카운터웨이트 추가 블록 (상단) */} + + + + {/* 카운터웨이트 프레임 */} + + + + + {/* ========== 후크 및 케이블 시스템 ========== */} + {/* 붐대 끝단 풀리 */} + + + + + {/* 메인 호이스트 케이블 */} + + + + + {/* 후크 블록 상단 */} + + + + {/* 후크 메인 (빨간색 안전색) */} + + + + + {/* 지브 지지 케이블 (좌측) */} + + + + {/* 지브 지지 케이블 (우측) */} + + + + + {/* ========== 조명 및 안전 장치 ========== */} + {/* 작업등 (전방) */} + + + + {/* 경고등 (붐대 상단) */} + + + + + ); + diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx index 555820b6..5a0c5871 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx @@ -8,7 +8,7 @@ import dynamic from "next/dynamic"; import { YardLayout, YardPlacement } from "./types"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { AlertCircle, CheckCircle, XCircle } from "lucide-react"; -import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle, ResizableDialogDescription } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useToast } from "@/hooks/use-toast"; diff --git a/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx index 1b78801e..6a5f235c 100644 --- a/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx +++ b/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx @@ -70,23 +70,22 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg const [value, setValue] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [lastUpdateTime, setLastUpdateTime] = useState(null); const config = element?.customMetricConfig; - console.log("📊 [CustomMetricTestWidget] 렌더링:", { - element, - config, - dataSource: element?.dataSource, - }); - useEffect(() => { loadData(); - // 자동 새로고침 (30초마다) - const interval = setInterval(loadData, 30000); - return () => clearInterval(interval); + // 자동 새로고침 (설정된 간격마다, 0이면 비활성) + const refreshInterval = config?.refreshInterval ?? 30; // 기본값: 30초 + + if (refreshInterval > 0) { + const interval = setInterval(loadData, refreshInterval * 1000); + return () => clearInterval(interval); + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [element]); + }, [element, config?.refreshInterval]); const loadData = async () => { try { @@ -132,6 +131,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg if (config?.valueColumn && config?.aggregation) { const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation); setValue(calculatedValue); + setLastUpdateTime(new Date()); // 업데이트 시간 기록 } else { setValue(0); } @@ -192,6 +192,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg if (config?.valueColumn && config?.aggregation) { const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation); setValue(calculatedValue); + setLastUpdateTime(new Date()); // 업데이트 시간 기록 } else { setValue(0); } @@ -200,7 +201,6 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg } } } catch (err) { - console.error("데이터 로드 실패:", err); setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다"); } finally { setLoading(false); @@ -283,6 +283,16 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg {formattedValue} {config?.unit && {config.unit}}
+ + {/* 마지막 업데이트 시간 */} + {lastUpdateTime && ( +
+ {lastUpdateTime.toLocaleTimeString("ko-KR")} + {config?.refreshInterval && config.refreshInterval > 0 && ( + • {config.refreshInterval}초마다 갱신 + )} +
+ )}
); } diff --git a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx index fcd5593f..7c39c731 100644 --- a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx +++ b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx @@ -70,17 +70,22 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) const [value, setValue] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [lastUpdateTime, setLastUpdateTime] = useState(null); const config = element?.customMetricConfig; useEffect(() => { loadData(); - // 자동 새로고침 (30초마다) - const interval = setInterval(loadData, 30000); - return () => clearInterval(interval); + // 자동 새로고침 (설정된 간격마다, 0이면 비활성) + const refreshInterval = config?.refreshInterval ?? 30; // 기본값: 30초 + + if (refreshInterval > 0) { + const interval = setInterval(loadData, refreshInterval * 1000); + return () => clearInterval(interval); + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [element]); + }, [element, config?.refreshInterval]); const loadData = async () => { try { @@ -198,15 +203,16 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다"); } finally { setLoading(false); + setLastUpdateTime(new Date()); } }; if (loading) { return ( -
+
-

데이터 로딩 중...

+

데이터 로딩 중...

); @@ -214,12 +220,12 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) if (error) { return ( -
+
-

⚠️ {error}

+

⚠️ {error}

@@ -238,10 +244,10 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) // 설정이 없으면 안내 화면 if (!hasDataSource || !hasConfig) { return ( -
+
-

통계 카드

-
+

통계 카드

+

📊 단일 통계 위젯

  • • 데이터 소스에서 쿼리를 실행합니다
  • @@ -250,7 +256,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
  • • COUNT, SUM, AVG, MIN, MAX 지원
-
+

⚙️ 설정 방법

1. 데이터 탭에서 쿼리 실행

2. 필터 조건 추가 (선택사항)

@@ -268,7 +274,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) // 통계 카드 렌더링 return ( -
+
{/* 제목 */}
{config?.title || "통계"}
@@ -277,6 +283,16 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) {formattedValue} {config?.unit && {config.unit}}
+ + {/* 마지막 업데이트 시간 */} + {lastUpdateTime && ( +
+ {lastUpdateTime.toLocaleTimeString("ko-KR")} + {config?.refreshInterval && config.refreshInterval > 0 && ( + • {config.refreshInterval}초마다 갱신 + )} +
+ )}
); } diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index 5df1663a..694cba79 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-require-imports */ "use client"; -import React, { useEffect, useState, useCallback, useMemo } from "react"; +import React, { useEffect, useState, useCallback, useMemo, useRef } from "react"; import dynamic from "next/dynamic"; import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types"; import { Button } from "@/components/ui/button"; @@ -10,6 +10,20 @@ import { applyColumnMapping } from "@/lib/utils/columnMapping"; import { getApiUrl } from "@/lib/utils/apiUrl"; import "leaflet/dist/leaflet.css"; +// Popup 말풍선 꼬리 제거 스타일 +if (typeof document !== "undefined") { + const style = document.createElement("style"); + style.textContent = ` + .leaflet-popup-tip-container { + display: none !important; + } + .leaflet-popup-content-wrapper { + border-radius: 8px !important; + } + `; + document.head.appendChild(style); +} + // Leaflet 아이콘 경로 설정 (엑박 방지) if (typeof window !== "undefined") { import("leaflet").then((L) => { @@ -66,7 +80,7 @@ interface PolygonData { export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const [markers, setMarkers] = useState([]); - const [prevMarkers, setPrevMarkers] = useState([]); // 이전 마커 위치 저장 + const prevMarkersRef = useRef([]); // 이전 마커 위치 저장 (useRef 사용) const [polygons, setPolygons] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -96,11 +110,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 다중 데이터 소스 로딩 const loadMultipleDataSources = useCallback(async () => { if (!dataSources || dataSources.length === 0) { - // // console.log("⚠️ 데이터 소스가 없습니다."); return; } - - // // console.log(`🔄 ${dataSources.length}개의 데이터 소스 로딩 시작...`); setLoading(true); setError(null); @@ -109,8 +120,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const results = await Promise.allSettled( dataSources.map(async (source) => { try { - // // console.log(`📡 데이터 소스 "${source.name || source.id}" 로딩 중...`); - if (source.type === "api") { return await loadRestApiData(source); } else if (source.type === "database") { @@ -119,7 +128,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { return { markers: [], polygons: [] }; } catch (err: any) { - console.error(`❌ 데이터 소스 "${source.name || source.id}" 로딩 실패:`, err); return { markers: [], polygons: [] }; } }), @@ -130,35 +138,24 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const allPolygons: PolygonData[] = []; results.forEach((result, index) => { - // // console.log(`🔍 결과 ${index}:`, result); - if (result.status === "fulfilled" && result.value) { const value = result.value as { markers: MarkerData[]; polygons: PolygonData[] }; - // // console.log(`✅ 데이터 소스 ${index} 성공:`, value); // 마커 병합 if (value.markers && Array.isArray(value.markers)) { - // // console.log(` → 마커 ${value.markers.length}개 추가`); allMarkers.push(...value.markers); } // 폴리곤 병합 if (value.polygons && Array.isArray(value.polygons)) { - // // console.log(` → 폴리곤 ${value.polygons.length}개 추가`); allPolygons.push(...value.polygons); } - } else if (result.status === "rejected") { - console.error(`❌ 데이터 소스 ${index} 실패:`, result.reason); } }); - // // console.log(`✅ 총 ${allMarkers.length}개의 마커, ${allPolygons.length}개의 폴리곤 로딩 완료`); - // // console.log("📍 최종 마커 데이터:", allMarkers); - // // console.log("🔷 최종 폴리곤 데이터:", allPolygons); - // 이전 마커 위치와 비교하여 진행 방향 계산 const markersWithHeading = allMarkers.map((marker) => { - const prevMarker = prevMarkers.find((pm) => pm.id === marker.id); + const prevMarker = prevMarkersRef.current.find((pm) => pm.id === marker.id); if (prevMarker && (prevMarker.lat !== marker.lat || prevMarker.lng !== marker.lng)) { // 이동했으면 방향 계산 @@ -178,21 +175,19 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { }; }); - setPrevMarkers(markersWithHeading); // 다음 비교를 위해 현재 위치 저장 + prevMarkersRef.current = markersWithHeading; // 다음 비교를 위해 현재 위치 저장 (useRef 사용) setMarkers(markersWithHeading); setPolygons(allPolygons); setLastRefreshTime(new Date()); } catch (err: any) { - console.error("❌ 데이터 로딩 중 오류:", err); setError(err.message); } finally { setLoading(false); } - }, [dataSources, prevMarkers, calculateHeading]); + }, [dataSources, calculateHeading]); // prevMarkersRef는 의존성에 포함하지 않음 (useRef이므로) // 수동 새로고침 핸들러 const handleManualRefresh = useCallback(() => { - // // console.log("🔄 수동 새로고침 버튼 클릭"); loadMultipleDataSources(); }, [loadMultipleDataSources]); @@ -200,8 +195,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const loadRestApiData = async ( source: ChartDataSource, ): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => { - // // console.log(`🌐 REST API 데이터 로딩 시작:`, source.name, `mapDisplayType:`, source.mapDisplayType); - if (!source.endpoint) { throw new Error("API endpoint가 없습니다."); } @@ -256,13 +249,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 텍스트 형식 데이터 체크 (기상청 API 등) if (data && typeof data === "object" && data.text && typeof data.text === "string") { - // // console.log("📄 텍스트 형식 데이터 감지, CSV 파싱 시도"); const parsedData = parseTextData(data.text); if (parsedData.length > 0) { - // // console.log(`✅ CSV 파싱 성공: ${parsedData.length}개 행`); // 컬럼 매핑 적용 const mappedData = applyColumnMapping(parsedData, source.columnMapping); - return convertToMapData(mappedData, source.name || source.id || "API", source.mapDisplayType, source); + const result = convertToMapData(mappedData, source.name || source.id || "API", source.mapDisplayType, source); + return result; } } @@ -280,15 +272,14 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const mappedRows = applyColumnMapping(rows, source.columnMapping); // 마커와 폴리곤으로 변환 (mapDisplayType + dataSource 전달) - return convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source); + const finalResult = convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source); + return finalResult; }; // Database 데이터 로딩 const loadDatabaseData = async ( source: ChartDataSource, ): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => { - // // console.log(`💾 Database 데이터 로딩 시작:`, source.name, `mapDisplayType:`, source.mapDisplayType); - if (!source.query) { throw new Error("SQL 쿼리가 없습니다."); } @@ -330,7 +321,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // XML 데이터 파싱 (UTIC API 등) const parseXmlData = (xmlText: string): any[] => { try { - // // console.log(" 📄 XML 파싱 시작"); const parser = new DOMParser(); const xmlDoc = parser.parseFromString(xmlText, "text/xml"); @@ -349,10 +339,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { results.push(obj); } - // // console.log(` ✅ XML 파싱 완료: ${results.length}개 레코드`); return results; } catch (error) { - console.error(" ❌ XML 파싱 실패:", error); return []; } }; @@ -360,11 +348,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 텍스트 데이터 파싱 (CSV, 기상청 형식 등) const parseTextData = (text: string): any[] => { try { - // // console.log(" 🔍 원본 텍스트 (처음 500자):", text.substring(0, 500)); - // XML 형식 감지 if (text.trim().startsWith("")) { - // // console.log(" 📄 XML 형식 데이터 감지"); return parseXmlData(text); } @@ -373,8 +358,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { return trimmed && !trimmed.startsWith("#") && !trimmed.startsWith("=") && !trimmed.startsWith("---"); }); - // // console.log(` 📝 유효한 라인: ${lines.length}개`); - if (lines.length === 0) return []; // CSV 형식으로 파싱 @@ -384,8 +367,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const line = lines[i]; const values = line.split(",").map((v) => v.trim().replace(/,=$/g, "")); - // // console.log(` 라인 ${i}:`, values); - // 기상특보 형식: 지역코드, 지역명, 하위코드, 하위지역명, 발표시각, 특보종류, 등급, 발표상태, 설명 if (values.length >= 4) { const obj: any = { @@ -404,14 +385,11 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { obj.name = obj.subRegion || obj.region || obj.code; result.push(obj); - // console.log(` ✅ 파싱 성공:`, obj); } } - // // console.log(" 📊 최종 파싱 결과:", result.length, "개"); return result; } catch (error) { - console.error(" ❌ 텍스트 파싱 오류:", error); return []; } }; @@ -423,23 +401,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { mapDisplayType?: "auto" | "marker" | "polygon", dataSource?: ChartDataSource, ): { markers: MarkerData[]; polygons: PolygonData[] } => { - // // console.log(`🔄 ${sourceName} 데이터 변환 시작:`, rows.length, "개 행"); - // // console.log(` 📌 mapDisplayType:`, mapDisplayType, `(타입: ${typeof mapDisplayType})`); - // // console.log(` 🎨 마커 색상:`, dataSource?.markerColor, `폴리곤 색상:`, dataSource?.polygonColor); - if (rows.length === 0) return { markers: [], polygons: [] }; const markers: MarkerData[] = []; const polygons: PolygonData[] = []; rows.forEach((row, index) => { - // // console.log(` 행 ${index}:`, row); - // 텍스트 데이터 체크 (기상청 API 등) if (row && typeof row === "object" && row.text && typeof row.text === "string") { - // // console.log(" 📄 텍스트 형식 데이터 감지, CSV 파싱 시도"); const parsedData = parseTextData(row.text); - // // console.log(` ✅ CSV 파싱 결과: ${parsedData.length}개 행`); // 파싱된 데이터를 재귀적으로 변환 (색상 정보 전달) const result = convertToMapData(parsedData, sourceName, mapDisplayType, dataSource); @@ -450,17 +420,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 폴리곤 데이터 체크 (coordinates 필드가 배열인 경우 또는 강제 polygon 모드) if (row.coordinates && Array.isArray(row.coordinates) && row.coordinates.length > 0) { - // // console.log(` → coordinates 발견:`, row.coordinates.length, "개"); // coordinates가 [lat, lng] 배열의 배열인지 확인 const firstCoord = row.coordinates[0]; if (Array.isArray(firstCoord) && firstCoord.length === 2) { - // console.log(` → 폴리곤으로 처리:`, row.name); polygons.push({ id: row.id || row.code || `polygon-${index}`, name: row.name || row.title || `영역 ${index + 1}`, coordinates: row.coordinates as [number, number][], status: row.status || row.level, - description: row.description || JSON.stringify(row, null, 2), + description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장 source: sourceName, color: dataSource?.polygonColor || getColorByStatus(row.status || row.level), }); @@ -471,13 +439,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 지역명으로 해상 구역 확인 (auto 또는 polygon 모드일 때만) const regionName = row.name || row.area || row.region || row.location || row.subRegion; if (regionName && MARITIME_ZONES[regionName] && mapDisplayType !== "marker") { - // // console.log(` → 해상 구역 발견: ${regionName}, 폴리곤으로 처리`); polygons.push({ id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성 name: regionName, coordinates: MARITIME_ZONES[regionName] as [number, number][], status: row.status || row.level, - description: row.description || `${row.type || ""} ${row.level || ""}`.trim() || JSON.stringify(row, null, 2), + description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장 source: sourceName, color: dataSource?.polygonColor || getColorByStatus(row.status || row.level), }); @@ -494,24 +461,20 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { (row.code || row.areaCode || row.regionCode || row.tmFc || row.stnId) ) { const regionCode = row.code || row.areaCode || row.regionCode || row.tmFc || row.stnId; - // // console.log(` → 지역 코드 발견: ${regionCode}, 위도/경도 변환 시도`); const coords = getCoordinatesByRegionCode(regionCode); if (coords) { lat = coords.lat; lng = coords.lng; - // console.log(` → 변환 성공: (${lat}, ${lng})`); } } // 지역명으로도 시도 if ((lat === undefined || lng === undefined) && (row.name || row.area || row.region || row.location)) { const regionName = row.name || row.area || row.region || row.location; - // // console.log(` → 지역명 발견: ${regionName}, 위도/경도 변환 시도`); const coords = getCoordinatesByRegionName(regionName); if (coords) { lat = coords.lat; lng = coords.lng; - // console.log(` → 변환 성공: (${lat}, ${lng})`); } } @@ -519,34 +482,33 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { if (mapDisplayType === "polygon") { const regionName = row.name || row.subRegion || row.region || row.area; if (regionName) { - // console.log(` 🔷 강제 폴리곤 모드: ${regionName} → 폴리곤으로 추가 (GeoJSON 매칭)`); polygons.push({ - id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, + id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성 name: regionName, coordinates: [], // GeoJSON에서 좌표를 가져올 것 status: row.status || row.level, - description: row.description || JSON.stringify(row, null, 2), + description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장 source: sourceName, color: dataSource?.polygonColor || getColorByStatus(row.status || row.level), }); - } else { - // console.log(` ⚠️ 강제 폴리곤 모드지만 지역명 없음 - 스킵`); } return; // 폴리곤으로 처리했으므로 마커로는 추가하지 않음 } // 위도/경도가 있고 polygon 모드가 아니면 마커로 처리 if (lat !== undefined && lng !== undefined && (mapDisplayType as string) !== "polygon") { - // // console.log(` → 마커로 처리: (${lat}, ${lng})`); markers.push({ - id: `${sourceName}-marker-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성 + // 진행 방향(heading) 계산을 위해 ID는 새로고침마다 바뀌지 않도록 고정값 사용 + // - row.id / row.code가 있으면 그 값을 사용 + // - 없으면 sourceName과 index 조합으로 고정 ID 생성 + id: row.id || row.code || `${sourceName}-marker-${index}`, lat: Number(lat), lng: Number(lng), latitude: Number(lat), longitude: Number(lng), name: row.name || row.title || row.area || row.region || `위치 ${index + 1}`, status: row.status || row.level, - description: row.description || JSON.stringify(row, null, 2), + description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장 source: sourceName, color: dataSource?.markerColor || "#3b82f6", // 사용자 지정 색상 또는 기본 파랑 }); @@ -554,24 +516,19 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 위도/경도가 없는 육지 지역 → 폴리곤으로 추가 (GeoJSON 매칭용) const regionName = row.name || row.subRegion || row.region || row.area; if (regionName) { - // console.log(` 📍 위도/경도 없지만 지역명 있음: ${regionName} → 폴리곤으로 추가 (GeoJSON 매칭)`); polygons.push({ - id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, + id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성 name: regionName, coordinates: [], // GeoJSON에서 좌표를 가져올 것 status: row.status || row.level, - description: row.description || JSON.stringify(row, null, 2), + description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장 source: sourceName, color: dataSource?.polygonColor || getColorByStatus(row.status || row.level), }); - } else { - // console.log(` ⚠️ 위도/경도 없고 지역명도 없음 - 스킵`); - // console.log(` 데이터:`, row); } } }); - // // console.log(`✅ ${sourceName}: 마커 ${markers.length}개, 폴리곤 ${polygons.length}개 변환 완료`); return { markers, polygons }; }; @@ -627,6 +584,97 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 해상 구역 폴리곤 좌표 (기상청 특보 구역 기준) const MARITIME_ZONES: Record> = { + // 서해 해역 + "인천·경기북부앞바다": [ + [37.8, 125.8], + [37.8, 126.5], + [37.3, 126.5], + [37.3, 125.8], + ], + "인천·경기남부앞바다": [ + [37.3, 125.7], + [37.3, 126.4], + [36.8, 126.4], + [36.8, 125.7], + ], + 충남북부앞바다: [ + [36.8, 125.6], + [36.8, 126.3], + [36.3, 126.3], + [36.3, 125.6], + ], + 충남남부앞바다: [ + [36.3, 125.5], + [36.3, 126.2], + [35.8, 126.2], + [35.8, 125.5], + ], + 전북북부앞바다: [ + [35.8, 125.4], + [35.8, 126.1], + [35.3, 126.1], + [35.3, 125.4], + ], + 전북남부앞바다: [ + [35.3, 125.3], + [35.3, 126.0], + [34.8, 126.0], + [34.8, 125.3], + ], + 전남북부서해앞바다: [ + [35.5, 125.2], + [35.5, 125.9], + [35.0, 125.9], + [35.0, 125.2], + ], + 전남중부서해앞바다: [ + [35.0, 125.1], + [35.0, 125.8], + [34.5, 125.8], + [34.5, 125.1], + ], + 전남남부서해앞바다: [ + [34.5, 125.0], + [34.5, 125.7], + [34.0, 125.7], + [34.0, 125.0], + ], + 서해중부안쪽먼바다: [ + [37.5, 124.5], + [37.5, 126.0], + [36.0, 126.0], + [36.0, 124.5], + ], + 서해중부바깥먼바다: [ + [37.5, 123.5], + [37.5, 125.0], + [36.0, 125.0], + [36.0, 123.5], + ], + 서해남부북쪽안쪽먼바다: [ + [36.0, 124.5], + [36.0, 126.0], + [35.0, 126.0], + [35.0, 124.5], + ], + 서해남부북쪽바깥먼바다: [ + [36.0, 123.5], + [36.0, 125.0], + [35.0, 125.0], + [35.0, 123.5], + ], + 서해남부남쪽안쪽먼바다: [ + [35.0, 124.0], + [35.0, 125.5], + [34.0, 125.5], + [34.0, 124.0], + ], + 서해남부남쪽바깥먼바다: [ + [35.0, 123.0], + [35.0, 124.5], + [33.5, 124.5], + [33.5, 123.0], + ], // 제주도 해역 제주도남부앞바다: [ [33.25, 126.0], @@ -862,7 +910,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const nameColumn = columns.find((col) => /^(name|title|이름|명칭|location)$/i.test(col)); if (!latColumn || !lngColumn) { - console.warn("⚠️ 위도/경도 컬럼을 찾을 수 없습니다."); return []; } @@ -896,10 +943,9 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { try { const response = await fetch("/geojson/korea-municipalities.json"); const data = await response.json(); - // // console.log("🗺️ GeoJSON 로드 완료:", data.features?.length, "개 시/군/구"); setGeoJsonData(data); } catch (err) { - console.error("❌ GeoJSON 로드 실패:", err); + // GeoJSON 로드 실패 처리 } }; loadGeoJsonData(); @@ -934,9 +980,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [dataSources, element?.chartConfig?.refreshInterval]); - // 타일맵 URL (chartConfig에서 가져오기) - const tileMapUrl = - element?.chartConfig?.tileMapUrl || `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`; + // 타일맵 URL (VWorld 한국 지도) + const tileMapUrl = `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`; // 지도 중심점 계산 const center: [number, number] = @@ -945,7 +990,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { markers.reduce((sum, m) => sum + m.lat, 0) / markers.length, markers.reduce((sum, m) => sum + m.lng, 0) / markers.length, ] - : [37.5665, 126.978]; // 기본: 서울 + : [36.5, 127.5]; // 한국 중심 return (
@@ -982,19 +1027,28 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {

{error}

) : ( - - + + {/* 폴리곤 렌더링 */} {/* GeoJSON 렌더링 (육지 지역 경계선) */} - {(() => { - // console.log(`🗺️ GeoJSON 렌더링 조건 체크:`, { - // geoJsonData: !!geoJsonData, - // polygonsLength: polygons.length, - // polygonNames: polygons.map(p => p.name), - // }); - return null; - })()} {geoJsonData && polygons.length > 0 ? ( p.id))} // 폴리곤 변경 시 재렌더링 @@ -1009,31 +1063,25 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 정확한 매칭 if (p.name === sigName) { - // console.log(`✅ 정확 매칭: ${p.name} === ${sigName}`); return true; } if (p.name === ctpName) { - // console.log(`✅ 정확 매칭: ${p.name} === ${ctpName}`); return true; } // 부분 매칭 (GeoJSON 지역명에 폴리곤 이름이 포함되는지) if (sigName && sigName.includes(p.name)) { - // console.log(`✅ 부분 매칭: ${sigName} includes ${p.name}`); return true; } if (ctpName && ctpName.includes(p.name)) { - // console.log(`✅ 부분 매칭: ${ctpName} includes ${p.name}`); return true; } // 역방향 매칭 (폴리곤 이름에 GeoJSON 지역명이 포함되는지) if (sigName && p.name.includes(sigName)) { - // console.log(`✅ 역방향 매칭: ${p.name} includes ${sigName}`); return true; } if (ctpName && p.name.includes(ctpName)) { - // console.log(`✅ 역방향 매칭: ${p.name} includes ${ctpName}`); return true; } @@ -1069,53 +1117,150 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { }); if (matchingPolygon) { - layer.bindPopup(` -
-
${matchingPolygon.name}
- ${matchingPolygon.source ? `
출처: ${matchingPolygon.source}
` : ""} - ${matchingPolygon.status ? `
상태: ${matchingPolygon.status}
` : ""} - ${matchingPolygon.description ? `
${matchingPolygon.description}
` : ""} -
- `); + // 폴리곤의 데이터 소스 찾기 + const polygonDataSource = dataSources?.find((ds) => ds.name === matchingPolygon.source); + const popupFields = polygonDataSource?.popupFields; + + let popupContent = ""; + + // popupFields가 설정되어 있으면 설정된 필드만 표시 + if (popupFields && popupFields.length > 0 && matchingPolygon.description) { + try { + const parsed = JSON.parse(matchingPolygon.description); + popupContent = ` +
+ ${matchingPolygon.source ? `
📡 ${matchingPolygon.source}
` : ""} +
+
상세 정보
+
+ ${popupFields + .map((field) => { + const value = parsed[field.fieldName]; + if (value === undefined || value === null) return ""; + return `
${field.label}: ${value}
`; + }) + .join("")} +
+
+
+ `; + } catch (error) { + // JSON 파싱 실패 시 기본 표시 + popupContent = ` +
+
${matchingPolygon.name}
+ ${matchingPolygon.source ? `
출처: ${matchingPolygon.source}
` : ""} + ${matchingPolygon.status ? `
상태: ${matchingPolygon.status}
` : ""} + ${matchingPolygon.description ? `
${matchingPolygon.description}
` : ""} +
+ `; + } + } else { + // popupFields가 없으면 전체 데이터 표시 + popupContent = ` +
+
${matchingPolygon.name}
+ ${matchingPolygon.source ? `
출처: ${matchingPolygon.source}
` : ""} + ${matchingPolygon.status ? `
상태: ${matchingPolygon.status}
` : ""} + ${matchingPolygon.description ? `
${matchingPolygon.description}
` : ""} +
+ `; + } + + layer.bindPopup(popupContent); } }} /> - ) : ( - <> - {/* console.log(`⚠️ GeoJSON 렌더링 안 됨: geoJsonData=${!!geoJsonData}, polygons=${polygons.length}`) */} - - )} + ) : null} {/* 폴리곤 렌더링 (해상 구역만) */} {polygons .filter((p) => MARITIME_ZONES[p.name]) - .map((polygon) => ( - - -
-
{polygon.name}
- {polygon.source && ( -
출처: {polygon.source}
- )} - {polygon.status &&
상태: {polygon.status}
} - {polygon.description && ( -
-
{polygon.description}
-
- )} -
-
-
- ))} + .map((polygon) => { + // 폴리곤의 데이터 소스 찾기 + const polygonDataSource = dataSources?.find((ds) => ds.name === polygon.source); + const popupFields = polygonDataSource?.popupFields; + + return ( + + +
+ {/* popupFields가 설정되어 있으면 설정된 필드만 표시 */} + {popupFields && popupFields.length > 0 && polygon.description ? ( + (() => { + try { + const parsed = JSON.parse(polygon.description); + return ( + <> + {polygon.source && ( +
+
📡 {polygon.source}
+
+ )} +
+
상세 정보
+
+ {popupFields.map((field, idx) => { + const value = parsed[field.fieldName]; + if (value === undefined || value === null) return null; + return ( +
+ {field.label}:{" "} + {String(value)} +
+ ); + })} +
+
+ + ); + } catch (error) { + // JSON 파싱 실패 시 기본 표시 + return ( + <> +
{polygon.name}
+ {polygon.source && ( +
출처: {polygon.source}
+ )} + {polygon.status &&
상태: {polygon.status}
} + {polygon.description && ( +
+
{polygon.description}
+
+ )} + + ); + } + })() + ) : ( + // popupFields가 없으면 전체 데이터 표시 + <> +
{polygon.name}
+ {polygon.source && ( +
출처: {polygon.source}
+ )} + {polygon.status &&
상태: {polygon.status}
} + {polygon.description && ( +
+
{polygon.description}
+
+ )} + + )} +
+
+
+ ); + })} {/* 마커 렌더링 */} {markers.map((marker) => { @@ -1143,28 +1288,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); "> - + - - - -
@@ -1172,6 +1301,74 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { iconSize: [40, 40], iconAnchor: [20, 20], }); + } else if (markerType === "truck") { + // 트럭 마커 + markerIcon = L.divIcon({ + className: "custom-truck-marker", + html: ` +
+ + + + + + + + + + + + + + +
+ `, + iconSize: [48, 48], + iconAnchor: [24, 24], + }); } else { // 동그라미 마커 (기본) markerIcon = L.divIcon({ @@ -1227,8 +1424,9 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
{marker.description && (() => { - const firstDataSource = dataSources?.[0]; - const popupFields = firstDataSource?.popupFields; + // 마커의 소스에 해당하는 데이터 소스 찾기 + const sourceDataSource = dataSources?.find((ds) => ds.name === marker.source); + const popupFields = sourceDataSource?.popupFields; // popupFields가 설정되어 있으면 설정된 필드만 표시 if (popupFields && popupFields.length > 0) { diff --git a/frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx b/frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx index 8aa2e3e2..df8bf098 100644 --- a/frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx +++ b/frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx @@ -636,9 +636,10 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
{(() => { - const ts = String(alert.timestamp); + const original = String(alert.timestamp); + const ts = original.replace(/\s+/g, ""); // 공백 제거 - // yyyyMMddHHmm 형식 감지 (예: 20251114 1000) + // yyyyMMddHHmm 형식 감지 (12자리 숫자) if (/^\d{12}$/.test(ts)) { const year = ts.substring(0, 4); const month = ts.substring(4, 6); @@ -646,12 +647,20 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp const hour = ts.substring(8, 10); const minute = ts.substring(10, 12); const date = new Date(`${year}-${month}-${day}T${hour}:${minute}:00`); - return isNaN(date.getTime()) ? ts : date.toLocaleString("ko-KR"); + return isNaN(date.getTime()) ? original : date.toLocaleString("ko-KR"); + } + + // "2025년 11월 14일 13시 20분" 형식 + const koreanMatch = original.match(/(\d{4})년\s*(\d{1,2})월\s*(\d{1,2})일\s*(\d{1,2})시\s*(\d{1,2})분/); + if (koreanMatch) { + const [, year, month, day, hour, minute] = koreanMatch; + const date = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T${hour.padStart(2, '0')}:${minute.padStart(2, '0')}:00`); + return isNaN(date.getTime()) ? original : date.toLocaleString("ko-KR"); } // ISO 형식 또는 일반 날짜 형식 - const date = new Date(ts); - return isNaN(date.getTime()) ? ts : date.toLocaleString("ko-KR"); + const date = new Date(original); + return isNaN(date.getTime()) ? original : date.toLocaleString("ko-KR"); })()} {alert.source && · {alert.source}} 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; +