feat: Digital Twin Editor 테이블 매핑 UI 및 백엔드 API 구현
This commit is contained in:
parent
eeed671436
commit
33350a4d46
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -59,6 +59,7 @@ import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
|
||||||
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
|
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
|
||||||
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D
|
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D
|
||||||
//import materialRoutes from "./routes/materialRoutes"; // 자재 관리
|
//import materialRoutes from "./routes/materialRoutes"; // 자재 관리
|
||||||
|
import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제)
|
||||||
import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
|
import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
|
||||||
import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결
|
import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결
|
||||||
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
||||||
|
|
@ -221,6 +222,7 @@ app.use("/api/bookings", bookingRoutes); // 예약 요청 관리
|
||||||
app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회
|
app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회
|
||||||
app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D
|
app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D
|
||||||
// app.use("/api/materials", materialRoutes); // 자재 관리 (임시 주석)
|
// app.use("/api/materials", materialRoutes); // 자재 관리 (임시 주석)
|
||||||
|
app.use("/api/digital-twin", digitalTwinRoutes); // 디지털 트윈 (야드 관제)
|
||||||
app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결
|
app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결
|
||||||
app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지)
|
app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지)
|
||||||
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
||||||
|
|
|
||||||
|
|
@ -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<any>(
|
||||||
|
`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<Response> => {
|
||||||
|
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<Response> => {
|
||||||
|
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<Response> => {
|
||||||
|
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<Response> => {
|
||||||
|
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<Response> => {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -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<Response> => {
|
||||||
|
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<Response> => {
|
||||||
|
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<Response> => {
|
||||||
|
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<Response> => {
|
||||||
|
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<Response> => {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
@ -866,7 +866,7 @@ export function CanvasElement({
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="element-close hover:bg-destructive text-muted-foreground absolute right-1 top-1 z-10 h-5 w-5 hover:text-white"
|
className="element-close hover:bg-destructive text-muted-foreground absolute top-1 right-1 z-10 h-5 w-5 hover:text-white"
|
||||||
onClick={handleRemove}
|
onClick={handleRemove}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
title="삭제"
|
title="삭제"
|
||||||
|
|
|
||||||
|
|
@ -449,6 +449,20 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 레이아웃 선택 (야드 관리 3D 위젯 전용) */}
|
||||||
|
{element.subtype === "yard-management-3d" && (
|
||||||
|
<div className="bg-background rounded-lg p-3 shadow-sm">
|
||||||
|
<Label htmlFor="layout-id" className="mb-2 block text-xs font-semibold">
|
||||||
|
레이아웃 선택
|
||||||
|
</Label>
|
||||||
|
<p className="text-muted-foreground mb-2 text-xs">표시할 디지털 트윈 레이아웃을 선택하세요</p>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
<p>위젯 내부에서 레이아웃을 선택할 수 있습니다.</p>
|
||||||
|
<p className="mt-1">편집 모드에서 레이아웃 목록을 확인하고 선택하세요.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 자동 새로고침 설정 (지도 위젯 전용) */}
|
{/* 자동 새로고침 설정 (지도 위젯 전용) */}
|
||||||
{element.subtype === "map-summary-v2" && (
|
{element.subtype === "map-summary-v2" && (
|
||||||
<div className="bg-background rounded-lg p-3 shadow-sm">
|
<div className="bg-background rounded-lg p-3 shadow-sm">
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { Plus, Check, Trash2 } from "lucide-react";
|
||||||
import YardLayoutCreateModal from "./yard-3d/YardLayoutCreateModal";
|
import YardLayoutCreateModal from "./yard-3d/YardLayoutCreateModal";
|
||||||
import DigitalTwinEditor from "./yard-3d/DigitalTwinEditor";
|
import DigitalTwinEditor from "./yard-3d/DigitalTwinEditor";
|
||||||
import DigitalTwinViewer from "./yard-3d/DigitalTwinViewer";
|
import DigitalTwinViewer from "./yard-3d/DigitalTwinViewer";
|
||||||
import { yardLayoutApi } from "@/lib/api/yardLayoutApi";
|
import { getLayouts, createLayout, deleteLayout } from "@/lib/api/digitalTwin";
|
||||||
import type { YardManagementConfig } from "../types";
|
import type { YardManagementConfig } from "../types";
|
||||||
|
|
||||||
interface YardLayout {
|
interface YardLayout {
|
||||||
|
|
@ -40,9 +40,16 @@ export default function YardManagement3DWidget({
|
||||||
const loadLayouts = async () => {
|
const loadLayouts = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const response = await yardLayoutApi.getAllLayouts();
|
const response = await getLayouts();
|
||||||
if (response.success) {
|
if (response.success && response.data) {
|
||||||
setLayouts(response.data as YardLayout[]);
|
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) {
|
} catch (error) {
|
||||||
console.error("야드 레이아웃 목록 조회 실패:", error);
|
console.error("야드 레이아웃 목록 조회 실패:", error);
|
||||||
|
|
@ -81,11 +88,21 @@ export default function YardManagement3DWidget({
|
||||||
// 새 레이아웃 생성
|
// 새 레이아웃 생성
|
||||||
const handleCreateLayout = async (name: string) => {
|
const handleCreateLayout = async (name: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await yardLayoutApi.createLayout({ name });
|
const response = await createLayout({
|
||||||
if (response.success) {
|
layoutName: name,
|
||||||
|
description: "",
|
||||||
|
});
|
||||||
|
if (response.success && response.data) {
|
||||||
await loadLayouts();
|
await loadLayouts();
|
||||||
setIsCreateModalOpen(false);
|
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) {
|
} catch (error) {
|
||||||
console.error("야드 레이아웃 생성 실패:", error);
|
console.error("야드 레이아웃 생성 실패:", error);
|
||||||
|
|
@ -110,7 +127,7 @@ export default function YardManagement3DWidget({
|
||||||
if (!deleteLayoutId) return;
|
if (!deleteLayoutId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await yardLayoutApi.deleteLayout(deleteLayoutId);
|
const response = await deleteLayout(deleteLayoutId);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
// 삭제된 레이아웃이 현재 선택된 레이아웃이면 설정 초기화
|
// 삭제된 레이아웃이 현재 선택된 레이아웃이면 설정 초기화
|
||||||
if (config?.layoutId === deleteLayoutId && onConfigChange) {
|
if (config?.layoutId === deleteLayoutId && onConfigChange) {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,16 +1,21 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { Search } from "lucide-react";
|
import { Loader2, Search, Filter, X } from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { 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 dynamic from "next/dynamic";
|
||||||
import { Loader2 } from "lucide-react";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import type { PlacedObject, MaterialData } from "@/types/digitalTwin";
|
||||||
|
import { getLayoutById, getMaterials } from "@/lib/api/digitalTwin";
|
||||||
|
|
||||||
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
|
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => (
|
loading: () => (
|
||||||
<div className="flex h-full items-center justify-center bg-muted">
|
<div className="bg-muted flex h-full items-center justify-center">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
@ -19,292 +24,478 @@ interface DigitalTwinViewerProps {
|
||||||
layoutId: number;
|
layoutId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 임시 타입 정의
|
|
||||||
interface Material {
|
|
||||||
id: number;
|
|
||||||
plate_no: string; // 후판번호
|
|
||||||
steel_grade: string; // 강종
|
|
||||||
thickness: number; // 두께
|
|
||||||
width: number; // 폭
|
|
||||||
length: number; // 길이
|
|
||||||
weight: number; // 중량
|
|
||||||
location: string; // 위치
|
|
||||||
status: string; // 상태
|
|
||||||
arrival_date: string; // 입고일자
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) {
|
export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) {
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const { toast } = useToast();
|
||||||
const [selectedYard, setSelectedYard] = useState<string>("all");
|
const [placedObjects, setPlacedObjects] = useState<PlacedObject[]>([]);
|
||||||
const [selectedStatus, setSelectedStatus] = useState<string>("all");
|
const [selectedObject, setSelectedObject] = useState<PlacedObject | null>(null);
|
||||||
const [dateRange, setDateRange] = useState({ from: "", to: "" });
|
|
||||||
const [selectedMaterial, setSelectedMaterial] = useState<Material | null>(null);
|
|
||||||
const [materials, setMaterials] = useState<Material[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [materials, setMaterials] = useState<MaterialData[]>([]);
|
||||||
|
const [loadingMaterials, setLoadingMaterials] = useState(false);
|
||||||
|
const [showInfoPanel, setShowInfoPanel] = useState(false);
|
||||||
|
const [externalDbConnectionId, setExternalDbConnectionId] = useState<number | null>(null);
|
||||||
|
const [layoutName, setLayoutName] = useState<string>("");
|
||||||
|
|
||||||
|
// 검색 및 필터
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [filterType, setFilterType] = useState<string>("all");
|
||||||
|
|
||||||
// 레이아웃 데이터 로드
|
// 레이아웃 데이터 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadLayout = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
// TODO: 실제 API 호출
|
const response = await getLayoutById(layoutId);
|
||||||
// const response = await digitalTwinApi.getLayoutData(layoutId);
|
|
||||||
|
|
||||||
// 임시 데이터
|
if (response.success && response.data) {
|
||||||
setMaterials([
|
const { layout, objects } = response.data;
|
||||||
{
|
|
||||||
id: 1,
|
// 레이아웃 정보 저장
|
||||||
plate_no: "P-2024-001",
|
setLayoutName(layout.layoutName);
|
||||||
steel_grade: "SM490A",
|
setExternalDbConnectionId(layout.externalDbConnectionId);
|
||||||
thickness: 25,
|
|
||||||
width: 2000,
|
// 객체 데이터 변환
|
||||||
length: 6000,
|
const loadedObjects: PlacedObject[] = objects.map((obj: any) => ({
|
||||||
weight: 2355,
|
id: obj.id,
|
||||||
location: "A동-101",
|
type: obj.object_type,
|
||||||
status: "입고",
|
name: obj.object_name,
|
||||||
arrival_date: "2024-11-15",
|
position: {
|
||||||
|
x: parseFloat(obj.position_x),
|
||||||
|
y: parseFloat(obj.position_y),
|
||||||
|
z: parseFloat(obj.position_z),
|
||||||
},
|
},
|
||||||
{
|
size: {
|
||||||
id: 2,
|
x: parseFloat(obj.size_x),
|
||||||
plate_no: "P-2024-002",
|
y: parseFloat(obj.size_y),
|
||||||
steel_grade: "SS400",
|
z: parseFloat(obj.size_z),
|
||||||
thickness: 30,
|
|
||||||
width: 2500,
|
|
||||||
length: 8000,
|
|
||||||
weight: 4710,
|
|
||||||
location: "B동-205",
|
|
||||||
status: "가공중",
|
|
||||||
arrival_date: "2024-11-16",
|
|
||||||
},
|
},
|
||||||
]);
|
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) {
|
} catch (error) {
|
||||||
console.error("디지털 트윈 데이터 로드 실패:", error);
|
console.error("레이아웃 로드 실패:", error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다.";
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "오류",
|
||||||
|
description: errorMessage,
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadData();
|
loadLayout();
|
||||||
}, [layoutId]);
|
}, [layoutId, toast]);
|
||||||
|
|
||||||
// 필터링된 자재 목록
|
// Location의 자재 목록 로드
|
||||||
const filteredMaterials = useMemo(() => {
|
const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => {
|
||||||
return materials.filter((material) => {
|
try {
|
||||||
// 검색어 필터
|
setLoadingMaterials(true);
|
||||||
if (searchTerm) {
|
setShowInfoPanel(true);
|
||||||
const searchLower = searchTerm.toLowerCase();
|
const response = await getMaterials(externalDbConnectionId, locaKey);
|
||||||
const matchSearch =
|
if (response.success && response.data) {
|
||||||
material.plate_no.toLowerCase().includes(searchLower) ||
|
const sortedMaterials = response.data.sort((a, b) => a.LOLAYER - b.LOLAYER);
|
||||||
material.steel_grade.toLowerCase().includes(searchLower) ||
|
setMaterials(sortedMaterials);
|
||||||
material.location.toLowerCase().includes(searchLower);
|
} else {
|
||||||
if (!matchSearch) return false;
|
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);
|
||||||
if (selectedYard !== "all" && !material.location.startsWith(selectedYard)) {
|
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<string, number> = {
|
||||||
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 상태 필터
|
// 검색 쿼리
|
||||||
if (selectedStatus !== "all" && material.status !== selectedStatus) {
|
if (searchQuery) {
|
||||||
return false;
|
const query = searchQuery.toLowerCase();
|
||||||
}
|
return (
|
||||||
|
obj.name.toLowerCase().includes(query) ||
|
||||||
// 날짜 필터
|
obj.areaKey?.toLowerCase().includes(query) ||
|
||||||
if (dateRange.from && material.arrival_date < dateRange.from) {
|
obj.locaKey?.toLowerCase().includes(query)
|
||||||
return false;
|
);
|
||||||
}
|
|
||||||
if (dateRange.to && material.arrival_date > dateRange.to) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [materials, searchTerm, selectedYard, selectedStatus, dateRange]);
|
}, [placedObjects, filterType, searchQuery]);
|
||||||
|
|
||||||
// 3D 객체 클릭 핸들러
|
if (isLoading) {
|
||||||
const handleObjectClick = (objectId: number) => {
|
return (
|
||||||
const material = materials.find((m) => m.id === objectId);
|
<div className="flex h-full items-center justify-center">
|
||||||
setSelectedMaterial(material || null);
|
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||||
};
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full overflow-hidden">
|
<div className="bg-background flex h-full flex-col">
|
||||||
{/* 좌측: 필터 패널 */}
|
{/* 상단 헤더 */}
|
||||||
|
<div className="flex items-center justify-between border-b p-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">{layoutName || "디지털 트윈 야드"}</h2>
|
||||||
|
<p className="text-muted-foreground text-sm">읽기 전용 뷰</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 영역 */}
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* 좌측: 검색/필터 */}
|
||||||
<div className="flex h-full w-[20%] flex-col border-r">
|
<div className="flex h-full w-[20%] flex-col border-r">
|
||||||
{/* 검색바 */}
|
<div className="space-y-4 p-4">
|
||||||
<div className="border-b p-4">
|
{/* 검색 */}
|
||||||
|
<div>
|
||||||
|
<Label className="mb-2 block text-sm font-semibold">검색</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
value={searchTerm}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="후판번호, 강종, 위치 검색..."
|
placeholder="이름, Area, Location 검색..."
|
||||||
className="h-10 pl-10 text-sm"
|
className="h-10 pl-9 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
{searchQuery && (
|
||||||
</div>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
{/* 필터 옵션 */}
|
size="sm"
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 p-0"
|
||||||
<div className="space-y-4">
|
onClick={() => setSearchQuery("")}
|
||||||
{/* 야드 선택 */}
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-2 text-sm font-semibold">야드</h4>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{["all", "A동", "B동", "C동", "겐트리"].map((yard) => (
|
|
||||||
<button
|
|
||||||
key={yard}
|
|
||||||
onClick={() => setSelectedYard(yard)}
|
|
||||||
className={`block w-full rounded px-3 py-2 text-left text-sm transition-colors ${
|
|
||||||
selectedYard === yard
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "hover:bg-muted"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{yard === "all" ? "전체" : yard}
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</Button>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 상태 필터 */}
|
{/* 타입 필터 */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="mb-2 text-sm font-semibold">상태</h4>
|
<Label className="mb-2 block text-sm font-semibold">타입 필터</Label>
|
||||||
<div className="space-y-1">
|
<Select value={filterType} onValueChange={setFilterType}>
|
||||||
{["all", "입고", "가공중", "출고대기", "출고완료"].map((status) => (
|
<SelectTrigger className="h-10 text-sm">
|
||||||
<button
|
<SelectValue />
|
||||||
key={status}
|
</SelectTrigger>
|
||||||
onClick={() => setSelectedStatus(status)}
|
<SelectContent>
|
||||||
className={`block w-full rounded px-3 py-2 text-left text-sm transition-colors ${
|
<SelectItem value="all">전체 ({typeCounts.all})</SelectItem>
|
||||||
selectedStatus === status
|
<SelectItem value="area">Area ({typeCounts.area})</SelectItem>
|
||||||
? "bg-primary text-primary-foreground"
|
<SelectItem value="location-bed">베드(BED) ({typeCounts["location-bed"]})</SelectItem>
|
||||||
: "hover:bg-muted"
|
<SelectItem value="location-stp">정차포인트(STP) ({typeCounts["location-stp"]})</SelectItem>
|
||||||
}`}
|
<SelectItem value="location-temp">임시베드(TMP) ({typeCounts["location-temp"]})</SelectItem>
|
||||||
|
<SelectItem value="location-dest">지정착지(DES) ({typeCounts["location-dest"]})</SelectItem>
|
||||||
|
<SelectItem value="crane-mobile">크레인 ({typeCounts["crane-mobile"]})</SelectItem>
|
||||||
|
<SelectItem value="rack">랙 ({typeCounts.rack})</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 초기화 */}
|
||||||
|
{(searchQuery || filterType !== "all") && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 w-full text-sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSearchQuery("");
|
||||||
|
setFilterType("all");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{status === "all" ? "전체" : status}
|
필터 초기화
|
||||||
</button>
|
</Button>
|
||||||
))}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 기간 필터 */}
|
{/* 객체 목록 */}
|
||||||
<div>
|
<div className="flex-1 overflow-y-auto border-t p-4">
|
||||||
<h4 className="mb-2 text-sm font-semibold">입고 기간</h4>
|
<Label className="mb-2 block text-sm font-semibold">
|
||||||
|
객체 목록 ({filteredObjects.length})
|
||||||
|
</Label>
|
||||||
|
{filteredObjects.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-sm">
|
||||||
|
{searchQuery ? "검색 결과가 없습니다" : "객체가 없습니다"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Input
|
{filteredObjects.map((obj) => {
|
||||||
type="date"
|
// 타입별 레이블
|
||||||
value={dateRange.from}
|
let typeLabel = obj.type;
|
||||||
onChange={(e) => setDateRange((prev) => ({ ...prev, from: e.target.value }))}
|
if (obj.type === "location-bed") typeLabel = "베드(BED)";
|
||||||
className="h-9 text-sm"
|
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
|
||||||
placeholder="시작일"
|
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
|
||||||
/>
|
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
|
||||||
<Input
|
else if (obj.type === "crane-mobile") typeLabel = "크레인";
|
||||||
type="date"
|
else if (obj.type === "area") typeLabel = "Area";
|
||||||
value={dateRange.to}
|
else if (obj.type === "rack") typeLabel = "랙";
|
||||||
onChange={(e) => setDateRange((prev) => ({ ...prev, to: e.target.value }))}
|
|
||||||
className="h-9 text-sm"
|
return (
|
||||||
placeholder="종료일"
|
<div
|
||||||
|
key={obj.id}
|
||||||
|
onClick={() => 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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">{obj.name}</p>
|
||||||
|
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||||
|
<span
|
||||||
|
className="inline-block h-2 w-2 rounded-full"
|
||||||
|
style={{ backgroundColor: obj.color }}
|
||||||
/>
|
/>
|
||||||
|
<span>{typeLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 추가 정보 */}
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{obj.areaKey && (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Area: <span className="font-medium">{obj.areaKey}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{obj.locaKey && (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Location: <span className="font-medium">{obj.locaKey}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{obj.materialCount !== undefined && obj.materialCount > 0 && (
|
||||||
|
<p className="text-xs text-yellow-600">
|
||||||
|
자재: <span className="font-semibold">{obj.materialCount}개</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 중앙: 3D 캔버스 */}
|
{/* 중앙: 3D 캔버스 */}
|
||||||
<div className="h-full flex-1 bg-gray-100">
|
<div className="relative flex-1">
|
||||||
{isLoading ? (
|
{!isLoading && (
|
||||||
<div className="flex h-full items-center justify-center">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Yard3DCanvas
|
<Yard3DCanvas
|
||||||
placements={[]} // TODO: 실제 배치 데이터
|
placements={useMemo(
|
||||||
selectedPlacementId={selectedMaterial?.id || null}
|
() =>
|
||||||
onPlacementClick={(placement) => {
|
placedObjects.map((obj) => ({
|
||||||
if (placement) {
|
id: obj.id,
|
||||||
handleObjectClick(placement.id);
|
name: obj.name,
|
||||||
} else {
|
position_x: obj.position.x,
|
||||||
setSelectedMaterial(null);
|
position_y: obj.position.y,
|
||||||
}
|
position_z: obj.position.z,
|
||||||
}}
|
size_x: obj.size.x,
|
||||||
onPlacementDrag={() => {}} // 뷰어 모드에서는 드래그 비활성화
|
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}
|
focusOnPlacementId={null}
|
||||||
onCollisionDetected={() => {}}
|
onCollisionDetected={() => {}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 우측: 상세정보 패널 (후판 목록 테이블) */}
|
{/* 우측: 정보 패널 */}
|
||||||
<div className="h-full w-[30%] overflow-y-auto border-l">
|
{showInfoPanel && selectedObject && (
|
||||||
|
<div className="h-full w-[25%] overflow-y-auto border-l">
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<h3 className="mb-4 text-lg font-semibold">후판 목록</h3>
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">상세 정보</h3>
|
||||||
|
<p className="text-muted-foreground text-xs">{selectedObject.name}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setShowInfoPanel(false)}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{filteredMaterials.length === 0 ? (
|
{/* 기본 정보 */}
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
<div className="bg-muted space-y-3 rounded-lg p-3">
|
||||||
<p className="text-sm text-muted-foreground">조건에 맞는 후판이 없습니다.</p>
|
<div>
|
||||||
|
<Label className="text-muted-foreground text-xs">타입</Label>
|
||||||
|
<p className="text-sm font-medium">{selectedObject.type}</p>
|
||||||
|
</div>
|
||||||
|
{selectedObject.areaKey && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground text-xs">Area Key</Label>
|
||||||
|
<p className="text-sm font-medium">{selectedObject.areaKey}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedObject.locaKey && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground text-xs">Location Key</Label>
|
||||||
|
<p className="text-sm font-medium">{selectedObject.locaKey}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedObject.materialCount !== undefined && selectedObject.materialCount > 0 && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground text-xs">자재 개수</Label>
|
||||||
|
<p className="text-sm font-medium">{selectedObject.materialCount}개</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 자재 목록 (Location인 경우) */}
|
||||||
|
{(selectedObject.type === "location-bed" ||
|
||||||
|
selectedObject.type === "location-stp" ||
|
||||||
|
selectedObject.type === "location-temp" ||
|
||||||
|
selectedObject.type === "location-dest") && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Label className="mb-2 block text-sm font-semibold">자재 목록</Label>
|
||||||
|
{loadingMaterials ? (
|
||||||
|
<div className="flex h-32 items-center justify-center">
|
||||||
|
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : materials.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-sm">
|
||||||
|
{externalDbConnectionId
|
||||||
|
? "자재가 없습니다"
|
||||||
|
: "외부 DB 연결이 설정되지 않았습니다"}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{filteredMaterials.map((material) => (
|
{materials.map((material, index) => (
|
||||||
<div
|
<div
|
||||||
key={material.id}
|
key={`${material.STKKEY}-${index}`}
|
||||||
onClick={() => setSelectedMaterial(material)}
|
className="bg-muted hover:bg-accent rounded-lg border p-3 transition-colors"
|
||||||
className={`cursor-pointer rounded-lg border p-3 transition-all ${
|
|
||||||
selectedMaterial?.id === material.id
|
|
||||||
? "border-primary bg-primary/10"
|
|
||||||
: "border-border hover:border-primary/50"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-start justify-between">
|
||||||
<span className="text-sm font-semibold">{material.plate_no}</span>
|
<div className="flex-1">
|
||||||
<span
|
<p className="text-sm font-medium">{material.STKKEY}</p>
|
||||||
className={`rounded-full px-2 py-0.5 text-xs ${
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
material.status === "입고"
|
층: {material.LOLAYER} | Area: {material.AREAKEY}
|
||||||
? "bg-blue-100 text-blue-700"
|
</p>
|
||||||
: material.status === "가공중"
|
|
||||||
? "bg-yellow-100 text-yellow-700"
|
|
||||||
: material.status === "출고대기"
|
|
||||||
? "bg-orange-100 text-orange-700"
|
|
||||||
: "bg-green-100 text-green-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{material.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1 text-xs text-muted-foreground">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>강종:</span>
|
|
||||||
<span className="font-medium text-foreground">{material.steel_grade}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>규격:</span>
|
|
||||||
<span className="font-medium text-foreground">
|
|
||||||
{material.thickness}×{material.width}×{material.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>중량:</span>
|
|
||||||
<span className="font-medium text-foreground">{material.weight.toLocaleString()} kg</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>위치:</span>
|
|
||||||
<span className="font-medium text-foreground">{material.location}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>입고일:</span>
|
|
||||||
<span className="font-medium text-foreground">{material.arrival_date}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-muted-foreground grid grid-cols-2 gap-2 text-xs">
|
||||||
|
{material.STKWIDT && (
|
||||||
|
<div>
|
||||||
|
폭: <span className="font-medium">{material.STKWIDT}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{material.STKLENG && (
|
||||||
|
<div>
|
||||||
|
길이: <span className="font-medium">{material.STKLENG}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{material.STKHEIG && (
|
||||||
|
<div>
|
||||||
|
높이: <span className="font-medium">{material.STKHEIG}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{material.STKWEIG && (
|
||||||
|
<div>
|
||||||
|
무게: <span className="font-medium">{material.STKWEIG}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{material.STKRMKS && (
|
||||||
|
<p className="text-muted-foreground mt-2 text-xs italic">{material.STKRMKS}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { Canvas, useThree } from "@react-three/fiber";
|
import { Canvas, useThree } from "@react-three/fiber";
|
||||||
import { OrbitControls, Grid, Box, Text } from "@react-three/drei";
|
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";
|
import * as THREE from "three";
|
||||||
|
|
||||||
interface YardPlacement {
|
interface YardPlacement {
|
||||||
|
|
@ -23,6 +23,8 @@ interface YardPlacement {
|
||||||
data_source_type?: string | null;
|
data_source_type?: string | null;
|
||||||
data_source_config?: any;
|
data_source_config?: any;
|
||||||
data_binding?: any;
|
data_binding?: any;
|
||||||
|
material_count?: number; // Location의 자재 개수
|
||||||
|
material_preview_height?: number; // 자재 스택 높이 (시각적)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Yard3DCanvasProps {
|
interface Yard3DCanvasProps {
|
||||||
|
|
@ -103,7 +105,7 @@ function MaterialBox({
|
||||||
if (!allPlacements || allPlacements.length === 0) {
|
if (!allPlacements || allPlacements.length === 0) {
|
||||||
// 다른 객체가 없으면 기본 높이
|
// 다른 객체가 없으면 기본 높이
|
||||||
const objectType = placement.data_source_type as string | null;
|
const objectType = placement.data_source_type as string | null;
|
||||||
const defaultY = objectType === "yard" ? 0.05 : (placement.size_y || gridSize) / 2;
|
const defaultY = objectType === "area" ? 0.05 : (placement.size_y || gridSize) / 2;
|
||||||
return {
|
return {
|
||||||
hasCollision: false,
|
hasCollision: false,
|
||||||
adjustedY: defaultY,
|
adjustedY: defaultY,
|
||||||
|
|
@ -122,11 +124,11 @@ function MaterialBox({
|
||||||
const myMaxZ = z + mySizeZ / 2;
|
const myMaxZ = z + mySizeZ / 2;
|
||||||
|
|
||||||
const objectType = placement.data_source_type as string | null;
|
const objectType = placement.data_source_type as string | null;
|
||||||
const defaultY = objectType === "yard" ? 0.05 : mySizeY / 2;
|
const defaultY = objectType === "area" ? 0.05 : mySizeY / 2;
|
||||||
let maxYBelow = defaultY;
|
let maxYBelow = defaultY;
|
||||||
|
|
||||||
// 야드는 스택되지 않음 (항상 바닥에 배치)
|
// Area는 스택되지 않음 (항상 바닥에 배치)
|
||||||
if (objectType === "yard") {
|
if (objectType === "area") {
|
||||||
return {
|
return {
|
||||||
hasCollision: false,
|
hasCollision: false,
|
||||||
adjustedY: defaultY,
|
adjustedY: defaultY,
|
||||||
|
|
@ -385,8 +387,8 @@ function MaterialBox({
|
||||||
// 타입별 렌더링
|
// 타입별 렌더링
|
||||||
const renderObjectByType = () => {
|
const renderObjectByType = () => {
|
||||||
switch (objectType) {
|
switch (objectType) {
|
||||||
case "yard":
|
case "area":
|
||||||
// 야드: 투명한 바닥 + 두꺼운 외곽선 (mesh) + 이름 텍스트
|
// Area: 투명한 바닥 + 두꺼운 외곽선 (mesh) + 이름 텍스트
|
||||||
const borderThickness = 0.3; // 외곽선 두께
|
const borderThickness = 0.3; // 외곽선 두께
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -440,7 +442,7 @@ function MaterialBox({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 야드 이름 텍스트 */}
|
{/* Area 이름 텍스트 */}
|
||||||
{placement.name && (
|
{placement.name && (
|
||||||
<Text
|
<Text
|
||||||
position={[0, 0.15, 0]}
|
position={[0, 0.15, 0]}
|
||||||
|
|
@ -458,6 +460,124 @@ function MaterialBox({
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case "location-bed":
|
||||||
|
case "location-temp":
|
||||||
|
case "location-dest":
|
||||||
|
// 베드 타입 Location: 초록색 상자
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box args={[boxWidth, boxHeight, boxDepth]}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={placement.color}
|
||||||
|
roughness={0.5}
|
||||||
|
metalness={0.3}
|
||||||
|
emissive={isSelected ? placement.color : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 대표 자재 스택 (자재가 있을 때만) */}
|
||||||
|
{placement.material_count !== undefined &&
|
||||||
|
placement.material_count > 0 &&
|
||||||
|
placement.material_preview_height && (
|
||||||
|
<Box
|
||||||
|
args={[boxWidth * 0.7, placement.material_preview_height, boxDepth * 0.7]}
|
||||||
|
position={[0, boxHeight / 2 + placement.material_preview_height / 2, 0]}
|
||||||
|
>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#ef4444"
|
||||||
|
roughness={0.6}
|
||||||
|
metalness={0.2}
|
||||||
|
emissive={isSelected ? "#ef4444" : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.3 : 0}
|
||||||
|
transparent
|
||||||
|
opacity={0.7}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Location 이름 */}
|
||||||
|
{placement.name && (
|
||||||
|
<Text
|
||||||
|
position={[0, boxHeight / 2 + 0.3, 0]}
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
|
||||||
|
color="#ffffff"
|
||||||
|
anchorX="center"
|
||||||
|
anchorY="middle"
|
||||||
|
outlineWidth={0.03}
|
||||||
|
outlineColor="#000000"
|
||||||
|
>
|
||||||
|
{placement.name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 자재 개수 */}
|
||||||
|
{placement.material_count !== undefined && placement.material_count > 0 && (
|
||||||
|
<Text
|
||||||
|
position={[0, boxHeight / 2 + 0.6, 0]}
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
fontSize={Math.min(boxWidth, boxDepth) * 0.12}
|
||||||
|
color="#fbbf24"
|
||||||
|
anchorX="center"
|
||||||
|
anchorY="middle"
|
||||||
|
outlineWidth={0.03}
|
||||||
|
outlineColor="#000000"
|
||||||
|
>
|
||||||
|
{`자재: ${placement.material_count}개`}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "location-stp":
|
||||||
|
// 정차포인트(STP): 주황색 낮은 플랫폼
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box args={[boxWidth, boxHeight, boxDepth]}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={placement.color}
|
||||||
|
roughness={0.6}
|
||||||
|
metalness={0.2}
|
||||||
|
emissive={isSelected ? placement.color : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Location 이름 */}
|
||||||
|
{placement.name && (
|
||||||
|
<Text
|
||||||
|
position={[0, boxHeight / 2 + 0.3, 0]}
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
|
||||||
|
color="#ffffff"
|
||||||
|
anchorX="center"
|
||||||
|
anchorY="middle"
|
||||||
|
outlineWidth={0.03}
|
||||||
|
outlineColor="#000000"
|
||||||
|
>
|
||||||
|
{placement.name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 자재 개수 (STP는 정차포인트라 자재가 없을 수 있음) */}
|
||||||
|
{placement.material_count !== undefined && placement.material_count > 0 && (
|
||||||
|
<Text
|
||||||
|
position={[0, boxHeight / 2 + 0.6, 0]}
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
fontSize={Math.min(boxWidth, boxDepth) * 0.12}
|
||||||
|
color="#fbbf24"
|
||||||
|
anchorX="center"
|
||||||
|
anchorY="middle"
|
||||||
|
outlineWidth={0.03}
|
||||||
|
outlineColor="#000000"
|
||||||
|
>
|
||||||
|
{`자재: ${placement.material_count}개`}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
// case "gantry-crane":
|
// case "gantry-crane":
|
||||||
// // 겐트리 크레인: 기둥 2개 + 상단 빔
|
// // 겐트리 크레인: 기둥 2개 + 상단 빔
|
||||||
// return (
|
// return (
|
||||||
|
|
@ -505,7 +625,7 @@ function MaterialBox({
|
||||||
// </group>
|
// </group>
|
||||||
// );
|
// );
|
||||||
|
|
||||||
case "mobile-crane":
|
case "crane-mobile":
|
||||||
// 이동식 크레인: 하부(트랙) + 회전대 + 캐빈 + 붐대 + 카운터웨이트 + 후크
|
// 이동식 크레인: 하부(트랙) + 회전대 + 캐빈 + 붐대 + 카운터웨이트 + 후크
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
|
|
|
||||||
|
|
@ -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<T> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 레이아웃 관리 API ==========
|
||||||
|
|
||||||
|
// 레이아웃 목록 조회
|
||||||
|
export const getLayouts = async (params?: {
|
||||||
|
externalDbConnectionId?: number;
|
||||||
|
warehouseKey?: string;
|
||||||
|
}): Promise<ApiResponse<DigitalTwinLayout[]>> => {
|
||||||
|
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<ApiResponse<DigitalTwinLayoutDetail>> => {
|
||||||
|
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<ApiResponse<DigitalTwinLayout>> => {
|
||||||
|
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<ApiResponse<DigitalTwinLayout>> => {
|
||||||
|
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<ApiResponse<void>> => {
|
||||||
|
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<ApiResponse<Array<{ table_name: string }>>> => {
|
||||||
|
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<ApiResponse<any[]>> => {
|
||||||
|
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<ApiResponse<Warehouse[]>> => {
|
||||||
|
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<ApiResponse<Area[]>> => {
|
||||||
|
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<ApiResponse<Location[]>> => {
|
||||||
|
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<ApiResponse<MaterialData[]>> => {
|
||||||
|
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<ApiResponse<MaterialCount[]>> => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
Loading…
Reference in New Issue