diff --git a/PLAN.MD b/PLAN.MD index 7c3b1007..787bef69 100644 --- a/PLAN.MD +++ b/PLAN.MD @@ -25,3 +25,4 @@ Digital Twin 에디터(`DigitalTwinEditor.tsx`)에서 발생한 런타임 에러 ## 진행 상태 - [진행중] 1단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중 + diff --git a/PROJECT_STATUS_2025_11_20.md b/PROJECT_STATUS_2025_11_20.md index 570dd789..1fe76c86 100644 --- a/PROJECT_STATUS_2025_11_20.md +++ b/PROJECT_STATUS_2025_11_20.md @@ -55,3 +55,4 @@ - `backend-node/src/routes/digitalTwinRoutes.ts` - `db/migrations/042_refactor_digital_twin_hierarchy.sql` + diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index be51e70e..fc69cdb1 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -57,7 +57,7 @@ import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관 import todoRoutes from "./routes/todoRoutes"; // To-Do 관리 import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리 import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리 -import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D +import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드 //import materialRoutes from "./routes/materialRoutes"; // 자재 관리 import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제) import flowRoutes from "./routes/flowRoutes"; // 플로우 관리 @@ -222,7 +222,7 @@ app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리 app.use("/api/todos", todoRoutes); // To-Do 관리 app.use("/api/bookings", bookingRoutes); // 예약 요청 관리 app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회 -app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D +app.use("/api/yard-layouts", yardLayoutRoutes); // 3D 필드 // app.use("/api/materials", materialRoutes); // 자재 관리 (임시 주석) app.use("/api/digital-twin", digitalTwinRoutes); // 디지털 트윈 (야드 관제) app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결 diff --git a/backend-node/src/controllers/digitalTwinTemplateController.ts b/backend-node/src/controllers/digitalTwinTemplateController.ts new file mode 100644 index 00000000..882d8e62 --- /dev/null +++ b/backend-node/src/controllers/digitalTwinTemplateController.ts @@ -0,0 +1,163 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { + DigitalTwinTemplateService, + DigitalTwinLayoutTemplate, +} from "../services/DigitalTwinTemplateService"; + +export const listMappingTemplates = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + } + + const externalDbConnectionId = req.query.externalDbConnectionId + ? Number(req.query.externalDbConnectionId) + : undefined; + const layoutType = + typeof req.query.layoutType === "string" + ? req.query.layoutType + : undefined; + + const result = await DigitalTwinTemplateService.listTemplates( + companyCode, + { + externalDbConnectionId, + layoutType, + }, + ); + + if (!result.success) { + return res.status(500).json({ + success: false, + message: result.message, + error: result.error, + }); + } + + return res.json({ + success: true, + data: result.data as DigitalTwinLayoutTemplate[], + }); + } catch (error: any) { + return res.status(500).json({ + success: false, + message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +export const getMappingTemplateById = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + const companyCode = req.user?.companyCode; + const { id } = req.params; + + if (!companyCode) { + return res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + } + + const result = await DigitalTwinTemplateService.getTemplateById( + companyCode, + id, + ); + + if (!result.success) { + return res.status(404).json({ + success: false, + message: result.message || "매핑 템플릿을 찾을 수 없습니다.", + error: result.error, + }); + } + + return res.json({ + success: true, + data: result.data, + }); + } catch (error: any) { + return res.status(500).json({ + success: false, + message: "매핑 템플릿 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +export const createMappingTemplate = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + + if (!companyCode || !userId) { + return res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + } + + const { + name, + description, + externalDbConnectionId, + layoutType, + config, + } = req.body; + + if (!name || !externalDbConnectionId || !config) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다.", + }); + } + + const result = await DigitalTwinTemplateService.createTemplate( + companyCode, + userId, + { + name, + description, + externalDbConnectionId, + layoutType, + config, + }, + ); + + if (!result.success || !result.data) { + return res.status(500).json({ + success: false, + message: result.message || "매핑 템플릿 생성 중 오류가 발생했습니다.", + error: result.error, + }); + } + + return res.status(201).json({ + success: true, + data: result.data, + }); + } catch (error: any) { + return res.status(500).json({ + success: false, + message: "매핑 템플릿 생성 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + + + diff --git a/backend-node/src/database/MariaDBConnector.ts b/backend-node/src/database/MariaDBConnector.ts index 3f469330..cde351dc 100644 --- a/backend-node/src/database/MariaDBConnector.ts +++ b/backend-node/src/database/MariaDBConnector.ts @@ -1,7 +1,11 @@ -import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector'; -import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; +import { + DatabaseConnector, + ConnectionConfig, + QueryResult, +} from "../interfaces/DatabaseConnector"; +import { ConnectionTestResult, TableInfo } from "../types/externalDbTypes"; // @ts-ignore -import * as mysql from 'mysql2/promise'; +import * as mysql from "mysql2/promise"; export class MariaDBConnector implements DatabaseConnector { private connection: mysql.Connection | null = null; @@ -20,7 +24,7 @@ export class MariaDBConnector implements DatabaseConnector { password: this.config.password, database: this.config.database, connectTimeout: this.config.connectionTimeoutMillis, - ssl: typeof this.config.ssl === 'boolean' ? undefined : this.config.ssl, + ssl: typeof this.config.ssl === "boolean" ? undefined : this.config.ssl, }); } } @@ -36,7 +40,9 @@ export class MariaDBConnector implements DatabaseConnector { const startTime = Date.now(); try { await this.connect(); - const [rows] = await this.connection!.query("SELECT VERSION() as version"); + const [rows] = await this.connection!.query( + "SELECT VERSION() as version" + ); const version = (rows as any[])[0]?.version || "Unknown"; const responseTime = Date.now() - startTime; await this.disconnect(); @@ -89,15 +95,13 @@ export class MariaDBConnector implements DatabaseConnector { ORDER BY TABLE_NAME; `); - const tables: TableInfo[] = []; - for (const row of rows as any[]) { - const columns = await this.getColumns(row.table_name); - tables.push({ - table_name: row.table_name, - description: row.description || null, - columns: columns, - }); - } + // 테이블 목록만 반환 (컬럼 정보는 getColumns에서 개별 조회) + const tables: TableInfo[] = (rows as any[]).map((row) => ({ + table_name: row.table_name, + description: row.description || null, + columns: [], + })); + await this.disconnect(); return tables; } catch (error: any) { @@ -111,21 +115,43 @@ export class MariaDBConnector implements DatabaseConnector { console.log(`[MariaDBConnector] getColumns 호출: tableName=${tableName}`); await this.connect(); console.log(`[MariaDBConnector] 연결 완료, 쿼리 실행 시작`); - - const [rows] = await this.connection!.query(` + + const [rows] = await this.connection!.query( + ` SELECT - COLUMN_NAME as column_name, - DATA_TYPE as data_type, - IS_NULLABLE as is_nullable, - COLUMN_DEFAULT as column_default - FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? - ORDER BY ORDINAL_POSITION; - `, [tableName]); - + c.COLUMN_NAME AS column_name, + c.DATA_TYPE AS data_type, + c.IS_NULLABLE AS is_nullable, + c.COLUMN_DEFAULT AS column_default, + c.COLUMN_COMMENT AS description, + CASE + WHEN tc.CONSTRAINT_TYPE = 'PRIMARY KEY' THEN 'YES' + ELSE 'NO' + END AS is_primary_key + FROM information_schema.COLUMNS c + LEFT JOIN information_schema.KEY_COLUMN_USAGE k + ON c.TABLE_SCHEMA = k.TABLE_SCHEMA + AND c.TABLE_NAME = k.TABLE_NAME + AND c.COLUMN_NAME = k.COLUMN_NAME + LEFT JOIN information_schema.TABLE_CONSTRAINTS tc + ON k.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA + AND k.CONSTRAINT_NAME = tc.CONSTRAINT_NAME + AND k.TABLE_SCHEMA = tc.TABLE_SCHEMA + AND k.TABLE_NAME = tc.TABLE_NAME + AND tc.CONSTRAINT_TYPE = 'PRIMARY KEY' + WHERE c.TABLE_SCHEMA = DATABASE() + AND c.TABLE_NAME = ? + ORDER BY c.ORDINAL_POSITION; + `, + [tableName] + ); + console.log(`[MariaDBConnector] 쿼리 결과:`, rows); - console.log(`[MariaDBConnector] 결과 개수:`, Array.isArray(rows) ? rows.length : 'not array'); - + console.log( + `[MariaDBConnector] 결과 개수:`, + Array.isArray(rows) ? rows.length : "not array" + ); + await this.disconnect(); return rows as any[]; } catch (error: any) { diff --git a/backend-node/src/database/PostgreSQLConnector.ts b/backend-node/src/database/PostgreSQLConnector.ts index 52f9ce19..2461cc53 100644 --- a/backend-node/src/database/PostgreSQLConnector.ts +++ b/backend-node/src/database/PostgreSQLConnector.ts @@ -210,15 +210,33 @@ export class PostgreSQLConnector implements DatabaseConnector { const result = await tempClient.query( ` SELECT - column_name, - data_type, - is_nullable, - column_default, - col_description(c.oid, a.attnum) as column_comment + isc.column_name, + isc.data_type, + isc.is_nullable, + isc.column_default, + col_description(c.oid, a.attnum) as column_comment, + CASE + WHEN tc.constraint_type = 'PRIMARY KEY' THEN 'YES' + ELSE 'NO' + END AS is_primary_key FROM information_schema.columns isc - LEFT JOIN pg_class c ON c.relname = isc.table_name - LEFT JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = isc.column_name - WHERE isc.table_schema = 'public' AND isc.table_name = $1 + LEFT JOIN pg_class c + ON c.relname = isc.table_name + AND c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = isc.table_schema) + LEFT JOIN pg_attribute a + ON a.attrelid = c.oid + AND a.attname = isc.column_name + LEFT JOIN information_schema.key_column_usage k + ON k.table_name = isc.table_name + AND k.table_schema = isc.table_schema + AND k.column_name = isc.column_name + LEFT JOIN information_schema.table_constraints tc + ON tc.constraint_name = k.constraint_name + AND tc.table_schema = k.table_schema + AND tc.table_name = k.table_name + AND tc.constraint_type = 'PRIMARY KEY' + WHERE isc.table_schema = 'public' + AND isc.table_name = $1 ORDER BY isc.ordinal_position; `, [tableName] diff --git a/backend-node/src/routes/digitalTwinRoutes.ts b/backend-node/src/routes/digitalTwinRoutes.ts index 904096f7..467813f0 100644 --- a/backend-node/src/routes/digitalTwinRoutes.ts +++ b/backend-node/src/routes/digitalTwinRoutes.ts @@ -9,6 +9,11 @@ import { updateLayout, deleteLayout, } from "../controllers/digitalTwinLayoutController"; +import { + listMappingTemplates, + getMappingTemplateById, + createMappingTemplate, +} from "../controllers/digitalTwinTemplateController"; // 외부 DB 데이터 조회 import { @@ -27,11 +32,16 @@ const router = express.Router(); router.use(authenticateToken); // ========== 레이아웃 관리 API ========== -router.get("/layouts", getLayouts); // 레이아웃 목록 -router.get("/layouts/:id", getLayoutById); // 레이아웃 상세 -router.post("/layouts", createLayout); // 레이아웃 생성 -router.put("/layouts/:id", updateLayout); // 레이아웃 수정 -router.delete("/layouts/:id", deleteLayout); // 레이아웃 삭제 +router.get("/layouts", getLayouts); // 레이아웃 목록 +router.get("/layouts/:id", getLayoutById); // 레이아웃 상세 +router.post("/layouts", createLayout); // 레이아웃 생성 +router.put("/layouts/:id", updateLayout); // 레이아웃 수정 +router.delete("/layouts/:id", deleteLayout); // 레이아웃 삭제 + +// ========== 매핑 템플릿 API ========== +router.get("/mapping-templates", listMappingTemplates); +router.get("/mapping-templates/:id", getMappingTemplateById); +router.post("/mapping-templates", createMappingTemplate); // ========== 외부 DB 데이터 조회 API ========== diff --git a/backend-node/src/services/DigitalTwinTemplateService.ts b/backend-node/src/services/DigitalTwinTemplateService.ts new file mode 100644 index 00000000..d4818b3a --- /dev/null +++ b/backend-node/src/services/DigitalTwinTemplateService.ts @@ -0,0 +1,172 @@ +import { pool } from "../database/db"; +import logger from "../utils/logger"; + +export interface DigitalTwinLayoutTemplate { + id: string; + company_code: string; + name: string; + description?: string | null; + external_db_connection_id: number; + layout_type: string; + config: any; + created_by: string; + created_at: Date; + updated_by: string; + updated_at: Date; +} + +interface ServiceResponse { + success: boolean; + data?: T; + message?: string; + error?: string; +} + +export class DigitalTwinTemplateService { + static async listTemplates( + companyCode: string, + options: { externalDbConnectionId?: number; layoutType?: string } = {}, + ): Promise> { + try { + const params: any[] = [companyCode]; + let paramIndex = 2; + + let query = ` + SELECT * + FROM digital_twin_layout_template + WHERE company_code = $1 + `; + + if (options.layoutType) { + query += ` AND layout_type = $${paramIndex++}`; + params.push(options.layoutType); + } + + if (options.externalDbConnectionId) { + query += ` AND external_db_connection_id = $${paramIndex++}`; + params.push(options.externalDbConnectionId); + } + + query += ` + ORDER BY updated_at DESC, name ASC + `; + + const result = await pool.query(query, params); + + logger.info("디지털 트윈 매핑 템플릿 목록 조회", { + companyCode, + count: result.rowCount, + }); + + return { + success: true, + data: result.rows as DigitalTwinLayoutTemplate[], + }; + } catch (error: any) { + logger.error("디지털 트윈 매핑 템플릿 목록 조회 실패", error); + return { + success: false, + error: error.message, + message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.", + }; + } + } + + static async getTemplateById( + companyCode: string, + id: string, + ): Promise> { + try { + const query = ` + SELECT * + FROM digital_twin_layout_template + WHERE id = $1 AND company_code = $2 + `; + + const result = await pool.query(query, [id, companyCode]); + + if (result.rowCount === 0) { + return { + success: false, + message: "매핑 템플릿을 찾을 수 없습니다.", + }; + } + + return { + success: true, + data: result.rows[0] as DigitalTwinLayoutTemplate, + }; + } catch (error: any) { + logger.error("디지털 트윈 매핑 템플릿 조회 실패", error); + return { + success: false, + error: error.message, + message: "매핑 템플릿 조회 중 오류가 발생했습니다.", + }; + } + } + + static async createTemplate( + companyCode: string, + userId: string, + payload: { + name: string; + description?: string; + externalDbConnectionId: number; + layoutType?: string; + config: any; + }, + ): Promise> { + try { + const query = ` + INSERT INTO digital_twin_layout_template ( + company_code, + name, + description, + external_db_connection_id, + layout_type, + config, + created_by, + created_at, + updated_by, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $7, NOW()) + RETURNING * + `; + + const values = [ + companyCode, + payload.name, + payload.description || null, + payload.externalDbConnectionId, + payload.layoutType || "yard-3d", + JSON.stringify(payload.config), + userId, + ]; + + const result = await pool.query(query, values); + + logger.info("디지털 트윈 매핑 템플릿 생성", { + companyCode, + templateId: result.rows[0].id, + externalDbConnectionId: payload.externalDbConnectionId, + }); + + return { + success: true, + data: result.rows[0] as DigitalTwinLayoutTemplate, + }; + } catch (error: any) { + logger.error("디지털 트윈 매핑 템플릿 생성 실패", error); + return { + success: false, + error: error.message, + message: "매핑 템플릿 생성 중 오류가 발생했습니다.", + }; + } + } +} + + + diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 2bb85051..090985ba 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -193,7 +193,7 @@ import { ListWidget } from "./widgets/ListWidget"; import { X } from "lucide-react"; import { Button } from "@/components/ui/button"; -// 야드 관리 3D 위젯 +// 3D 필드 위젯 const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DWidget"), { ssr: false, loading: () => ( @@ -312,6 +312,24 @@ export function CanvasElement({ return; } + // 위젯 테두리(바깥쪽 영역)를 클릭한 경우에만 선택/드래그 허용 + // - 내용 영역을 클릭해도 대시보드 설정 사이드바가 튀어나오지 않도록 하기 위함 + const container = elementRef.current; + if (container) { + const rect = container.getBoundingClientRect(); + const BORDER_HIT_WIDTH = 8; // px, 테두리로 인식할 범위 + const isOnBorder = + e.clientX <= rect.left + BORDER_HIT_WIDTH || + e.clientX >= rect.right - BORDER_HIT_WIDTH || + e.clientY <= rect.top + BORDER_HIT_WIDTH || + e.clientY >= rect.bottom - BORDER_HIT_WIDTH; + + if (!isOnBorder) { + // 테두리가 아닌 내부 클릭은 선택/드래그 처리하지 않음 + return; + } + } + // 선택되지 않은 경우에만 선택 처리 if (!isSelected) { onSelect(element.id); @@ -1067,7 +1085,7 @@ export function CanvasElement({ ) : element.type === "widget" && element.subtype === "yard-management-3d" ? ( - // 야드 관리 3D 위젯 렌더링 + // 3D 필드 위젯 렌더링
리스트 통계 카드 리스크/알림 - 야드 관리 3D + 3D 필드 {/* 커스텀 통계 카드 */} {/* 커스텀 상태 카드 */} diff --git a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx index db608645..10af48e8 100644 --- a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx +++ b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx @@ -93,7 +93,7 @@ const getWidgetTitle = (subtype: ElementSubtype): string => { chart: "차트", "map-summary-v2": "지도", "risk-alert-v2": "리스크 알림", - "yard-management-3d": "야드 관리 3D", + "yard-management-3d": "3D 필드", weather: "날씨 위젯", exchange: "환율 위젯", calculator: "계산기", @@ -449,7 +449,7 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
- {/* 레이아웃 선택 (야드 관리 3D 위젯 전용) */} + {/* 레이아웃 선택 (3D 필드 위젯 전용) */} {element.subtype === "yard-management-3d" && (