diff --git a/PLAN.MD b/PLAN.MD new file mode 100644 index 00000000..7c3b1007 --- /dev/null +++ b/PLAN.MD @@ -0,0 +1,27 @@ +# 프로젝트: Digital Twin 에디터 안정화 + +## 개요 + +Digital Twin 에디터(`DigitalTwinEditor.tsx`)에서 발생한 런타임 에러(`TypeError: Cannot read properties of undefined`)를 수정하고, 전반적인 안정성을 확보합니다. + +## 핵심 기능 + +1. `DigitalTwinEditor` 버그 수정 +2. 비동기 함수 입력값 유효성 검증 강화 +3. 외부 DB 연결 상태에 따른 방어 코드 추가 + +## 테스트 계획 + +### 1단계: 긴급 버그 수정 + +- [x] `loadMaterialCountsForLocations` 함수에서 `locaKeys` undefined 체크 추가 (완료) +- [ ] 에디터 로드 및 객체 조작 시 에러 발생 여부 확인 + +### 2단계: 잠재적 문제 점검 + +- [ ] `loadLayout` 등 주요 로딩 함수의 데이터 유효성 검사 +- [ ] `handleToolDragStart`, `handleCanvasDrop` 등 인터랙션 함수의 예외 처리 + +## 진행 상태 + +- [진행중] 1단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중 diff --git a/PROJECT_STATUS_2025_11_20.md b/PROJECT_STATUS_2025_11_20.md new file mode 100644 index 00000000..570dd789 --- /dev/null +++ b/PROJECT_STATUS_2025_11_20.md @@ -0,0 +1,57 @@ +# 프로젝트 진행 상황 (2025-11-20) + +## 작업 개요: 디지털 트윈 3D 야드 고도화 (동적 계층 구조) + +### 1. 핵심 변경 사항 +기존의 고정된 `Area` -> `Location` 2단계 구조를 유연한 **N-Level 동적 계층 구조**로 변경하고, 공간적 제약을 강화했습니다. + +### 2. 완료된 작업 + +#### 데이터베이스 +- **마이그레이션 실행**: `db/migrations/042_refactor_digital_twin_hierarchy.sql` +- **스키마 변경**: + - `digital_twin_layout` 테이블에 `hierarchy_config` (JSONB) 컬럼 추가 + - `digital_twin_objects` 테이블에 `hierarchy_level`, `parent_key`, `external_key` 컬럼 추가 + - 기존 하드코딩된 테이블 매핑 컬럼 제거 + +#### 백엔드 (Node.js) +- **API 추가/수정**: + - `POST /api/digital-twin/data/hierarchy`: 계층 설정에 따른 전체 데이터 조회 + - `POST /api/digital-twin/data/children`: 특정 부모의 하위 데이터 조회 + - 기존 레거시 API (`getWarehouses` 등) 호환성 유지 +- **컨트롤러 수정**: + - `digitalTwinDataController.ts`: 동적 쿼리 생성 로직 구현 + - `digitalTwinLayoutController.ts`: 레이아웃 저장/수정 시 `hierarchy_config` 및 객체 계층 정보 처리 + +#### 프론트엔드 (React) +- **신규 컴포넌트**: `HierarchyConfigPanel.tsx` + - 레벨 추가/삭제, 테이블 및 컬럼 매핑 설정 UI +- **유틸리티**: `spatialContainment.ts` + - `validateSpatialContainment`: 자식 객체가 부모 객체 내부에 있는지 검증 (AABB) + - `updateChildrenPositions`: 부모 이동 시 자식 객체 자동 이동 (그룹 이동) +- **에디터 통합 (`DigitalTwinEditor.tsx`)**: + - `HierarchyConfigPanel` 적용 + - 동적 데이터 로드 로직 구현 + - 3D 캔버스 드래그앤드롭 시 공간적 종속성 검증 적용 + - 객체 이동 시 그룹 이동 적용 + +### 3. 현재 상태 +- **백엔드 서버**: 재시작 완료, 정상 동작 중 (PostgreSQL 연결 이슈 해결됨) +- **DB**: 마이그레이션 스크립트 실행 완료 + +### 4. 다음 단계 (테스트 필요) +새로운 세션에서 다음 시나리오를 테스트해야 합니다: +1. **계층 설정**: 에디터에서 창고 -> 구역(Lv1) -> 위치(Lv2) 설정 및 매핑 저장 +2. **배치 검증**: + - 구역 배치 후, 위치를 구역 **내부**에 배치 (성공해야 함) + - 위치를 구역 **외부**에 배치 (실패해야 함) +3. **이동 검증**: 구역 이동 시 내부의 위치들도 같이 따라오는지 확인 + +### 5. 관련 파일 +- `frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx` +- `frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx` +- `frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts` +- `backend-node/src/controllers/digitalTwinDataController.ts` +- `backend-node/src/routes/digitalTwinRoutes.ts` +- `db/migrations/042_refactor_digital_twin_hierarchy.sql` + diff --git a/backend-node/src/controllers/digitalTwinDataController.ts b/backend-node/src/controllers/digitalTwinDataController.ts index 51dd85d8..80cb8ccd 100644 --- a/backend-node/src/controllers/digitalTwinDataController.ts +++ b/backend-node/src/controllers/digitalTwinDataController.ts @@ -36,7 +36,138 @@ export async function getExternalDbConnector(connectionId: number) { ); } -// 창고 목록 조회 (사용자 지정 테이블) +// 동적 계층 구조 데이터 조회 (범용) +export const getHierarchyData = async (req: Request, res: Response): Promise => { + try { + const { externalDbConnectionId, hierarchyConfig } = req.body; + + if (!externalDbConnectionId || !hierarchyConfig) { + return res.status(400).json({ + success: false, + message: "외부 DB 연결 ID와 계층 구조 설정이 필요합니다.", + }); + } + + const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + const config = JSON.parse(hierarchyConfig); + + const result: any = { + warehouse: null, + levels: [], + materials: [], + }; + + // 창고 데이터 조회 + if (config.warehouse) { + const warehouseQuery = `SELECT * FROM ${config.warehouse.tableName} LIMIT 100`; + const warehouseResult = await connector.executeQuery(warehouseQuery); + result.warehouse = warehouseResult.rows; + } + + // 각 레벨 데이터 조회 + if (config.levels && Array.isArray(config.levels)) { + for (const level of config.levels) { + const levelQuery = `SELECT * FROM ${level.tableName} LIMIT 1000`; + const levelResult = await connector.executeQuery(levelQuery); + + result.levels.push({ + level: level.level, + name: level.name, + data: levelResult.rows, + }); + } + } + + // 자재 데이터 조회 (개수만) + if (config.material) { + const materialQuery = ` + SELECT + ${config.material.locationKeyColumn} as location_key, + COUNT(*) as count + FROM ${config.material.tableName} + GROUP BY ${config.material.locationKeyColumn} + `; + const materialResult = await connector.executeQuery(materialQuery); + result.materials = materialResult.rows; + } + + logger.info("동적 계층 구조 데이터 조회", { + externalDbConnectionId, + warehouseCount: result.warehouse?.length || 0, + levelCounts: result.levels.map((l: any) => ({ level: l.level, count: l.data.length })), + }); + + return res.json({ + success: true, + data: result, + }); + } catch (error: any) { + logger.error("동적 계층 구조 데이터 조회 실패", error); + return res.status(500).json({ + success: false, + message: "데이터 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +// 특정 레벨의 하위 데이터 조회 +export const getChildrenData = async (req: Request, res: Response): Promise => { + try { + const { externalDbConnectionId, hierarchyConfig, parentLevel, parentKey } = req.body; + + if (!externalDbConnectionId || !hierarchyConfig || !parentLevel || !parentKey) { + return res.status(400).json({ + success: false, + message: "필수 파라미터가 누락되었습니다.", + }); + } + + const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + const config = JSON.parse(hierarchyConfig); + + // 다음 레벨 찾기 + const nextLevel = config.levels?.find((l: any) => l.level === parentLevel + 1); + + if (!nextLevel) { + return res.json({ + success: true, + data: [], + message: "하위 레벨이 없습니다.", + }); + } + + // 하위 데이터 조회 + const query = ` + SELECT * FROM ${nextLevel.tableName} + WHERE ${nextLevel.parentKeyColumn} = '${parentKey}' + LIMIT 1000 + `; + + const result = await connector.executeQuery(query); + + logger.info("하위 데이터 조회", { + externalDbConnectionId, + parentLevel, + parentKey, + 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, + }); + } +}; + +// 창고 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지 export const getWarehouses = async (req: Request, res: Response): Promise => { try { const { externalDbConnectionId, tableName } = req.query; @@ -83,32 +214,29 @@ export const getWarehouses = async (req: Request, res: Response): Promise => { try { - const { externalDbConnectionId, tableName, warehouseKey } = req.query; + const { externalDbConnectionId, warehouseKey, tableName } = req.query; - if (!externalDbConnectionId || !tableName) { + if (!externalDbConnectionId || !warehouseKey || !tableName) { return res.status(400).json({ success: false, - message: "외부 DB 연결 ID와 테이블명이 필요합니다.", + message: "필수 파라미터가 누락되었습니다.", }); } const connector = await getExternalDbConnector(Number(externalDbConnectionId)); - // 테이블명을 사용하여 모든 컬럼 조회 - let query = `SELECT * FROM ${tableName}`; - - if (warehouseKey) { - query += ` WHERE WAREKEY = '${warehouseKey}'`; - } - - query += ` LIMIT 1000`; + const query = ` + SELECT * FROM ${tableName} + WHERE WAREKEY = '${warehouseKey}' + LIMIT 1000 + `; const result = await connector.executeQuery(query); - logger.info("Area 목록 조회", { + logger.info("구역 목록 조회", { externalDbConnectionId, tableName, warehouseKey, @@ -120,41 +248,38 @@ export const getAreas = async (req: Request, res: Response): Promise = data: result.rows, }); } catch (error: any) { - logger.error("Area 목록 조회 실패", error); + logger.error("구역 목록 조회 실패", error); return res.status(500).json({ success: false, - message: "Area 목록 조회 중 오류가 발생했습니다.", + message: "구역 목록 조회 중 오류가 발생했습니다.", error: error.message, }); } }; -// Location 목록 조회 (사용자 지정 테이블) +// 위치 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지 export const getLocations = async (req: Request, res: Response): Promise => { try { - const { externalDbConnectionId, tableName, areaKey } = req.query; + const { externalDbConnectionId, areaKey, tableName } = req.query; - if (!externalDbConnectionId || !tableName) { + if (!externalDbConnectionId || !areaKey || !tableName) { return res.status(400).json({ success: false, - message: "외부 DB 연결 ID와 테이블명이 필요합니다.", + message: "필수 파라미터가 누락되었습니다.", }); } const connector = await getExternalDbConnector(Number(externalDbConnectionId)); - // 테이블명을 사용하여 모든 컬럼 조회 - let query = `SELECT * FROM ${tableName}`; - - if (areaKey) { - query += ` WHERE AREAKEY = '${areaKey}'`; - } - - query += ` LIMIT 1000`; + const query = ` + SELECT * FROM ${tableName} + WHERE AREAKEY = '${areaKey}' + LIMIT 1000 + `; const result = await connector.executeQuery(query); - logger.info("Location 목록 조회", { + logger.info("위치 목록 조회", { externalDbConnectionId, tableName, areaKey, @@ -166,37 +291,46 @@ export const getLocations = async (req: Request, res: Response): Promise => { try { - const { externalDbConnectionId, tableName, locaKey } = req.query; + const { + externalDbConnectionId, + locaKey, + tableName, + keyColumn, + locationKeyColumn, + layerColumn + } = req.query; - if (!externalDbConnectionId || !tableName) { + if (!externalDbConnectionId || !locaKey || !tableName || !locationKeyColumn) { return res.status(400).json({ success: false, - message: "외부 DB 연결 ID와 테이블명이 필요합니다.", + message: "필수 파라미터가 누락되었습니다.", }); } const connector = await getExternalDbConnector(Number(externalDbConnectionId)); - // 테이블명을 사용하여 모든 컬럼 조회 - let query = `SELECT * FROM ${tableName}`; - - if (locaKey) { - query += ` WHERE LOCAKEY = '${locaKey}'`; - } - - query += ` LIMIT 1000`; + // 동적 쿼리 생성 + const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : ''; + const query = ` + SELECT * FROM ${tableName} + WHERE ${locationKeyColumn} = '${locaKey}' + ${orderByClause} + LIMIT 1000 + `; + + logger.info(`자재 조회 쿼리: ${query}`); const result = await connector.executeQuery(query); @@ -221,31 +355,28 @@ export const getMaterials = async (req: Request, res: Response): Promise => { try { - const { externalDbConnectionId, tableName, locaKeys } = req.query; + const { externalDbConnectionId, locationKeys, tableName } = req.body; - if (!externalDbConnectionId || !tableName || !locaKeys) { + if (!externalDbConnectionId || !locationKeys || !tableName) { return res.status(400).json({ success: false, - message: "외부 DB 연결 ID, 테이블명, Location 키 목록이 필요합니다.", + message: "필수 파라미터가 누락되었습니다.", }); } const connector = await getExternalDbConnector(Number(externalDbConnectionId)); - // locaKeys는 쉼표로 구분된 문자열 - const locaKeyArray = (locaKeys as string).split(","); - const quotedKeys = locaKeyArray.map((key) => `'${key}'`).join(","); + const keysString = locationKeys.map((key: string) => `'${key}'`).join(","); const query = ` SELECT - LOCAKEY, - COUNT(*) as material_count, - MAX(LOLAYER) as max_layer + LOCAKEY as location_key, + COUNT(*) as count FROM ${tableName} - WHERE LOCAKEY IN (${quotedKeys}) + WHERE LOCAKEY IN (${keysString}) GROUP BY LOCAKEY `; @@ -254,7 +385,7 @@ export const getMaterialCounts = async (req: Request, res: Response): Promise => { try { @@ -67,7 +68,7 @@ export const getLayouts = async ( // 레이아웃 상세 조회 (객체 포함) export const getLayoutById = async ( - req: Request, + req: AuthenticatedRequest, res: Response ): Promise => { try { @@ -125,7 +126,7 @@ export const getLayoutById = async ( // 레이아웃 생성 export const createLayout = async ( - req: Request, + req: AuthenticatedRequest, res: Response ): Promise => { const client = await pool.connect(); @@ -138,6 +139,7 @@ export const createLayout = async ( warehouseKey, layoutName, description, + hierarchyConfig, objects, } = req.body; @@ -147,9 +149,9 @@ export const createLayout = async ( const layoutQuery = ` INSERT INTO digital_twin_layout ( company_code, external_db_connection_id, warehouse_key, - layout_name, description, created_by, updated_by + layout_name, description, hierarchy_config, created_by, updated_by ) - VALUES ($1, $2, $3, $4, $5, $6, $6) + VALUES ($1, $2, $3, $4, $5, $6, $7, $7) RETURNING * `; @@ -159,6 +161,7 @@ export const createLayout = async ( warehouseKey, layoutName, description, + hierarchyConfig ? JSON.stringify(hierarchyConfig) : null, userId, ]); @@ -174,9 +177,10 @@ export const createLayout = async ( rotation, color, area_key, loca_key, loc_type, material_count, material_preview_height, - parent_id, display_order, locked + parent_id, display_order, locked, + hierarchy_level, parent_key, external_key ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22) `; for (const obj of objects) { @@ -200,6 +204,9 @@ export const createLayout = async ( obj.parentId || null, obj.displayOrder || 0, obj.locked || false, + obj.hierarchyLevel || 1, + obj.parentKey || null, + obj.externalKey || null, ]); } } @@ -231,7 +238,7 @@ export const createLayout = async ( // 레이아웃 수정 export const updateLayout = async ( - req: Request, + req: AuthenticatedRequest, res: Response ): Promise => { const client = await pool.connect(); @@ -240,7 +247,14 @@ export const updateLayout = async ( const companyCode = req.user?.companyCode; const userId = req.user?.userId; const { id } = req.params; - const { layoutName, description, objects } = req.body; + const { + layoutName, + description, + hierarchyConfig, + externalDbConnectionId, + warehouseKey, + objects, + } = req.body; await client.query("BEGIN"); @@ -249,15 +263,21 @@ export const updateLayout = async ( UPDATE digital_twin_layout SET layout_name = $1, description = $2, - updated_by = $3, + hierarchy_config = $3, + external_db_connection_id = $4, + warehouse_key = $5, + updated_by = $6, updated_at = NOW() - WHERE id = $4 AND company_code = $5 + WHERE id = $7 AND company_code = $8 RETURNING * `; const layoutResult = await client.query(updateLayoutQuery, [ layoutName, description, + hierarchyConfig ? JSON.stringify(hierarchyConfig) : null, + externalDbConnectionId || null, + warehouseKey || null, userId, id, companyCode, @@ -277,7 +297,7 @@ export const updateLayout = async ( [id] ); - // 새 객체 저장 + // 새 객체 저장 (부모-자식 관계 처리) if (objects && objects.length > 0) { const objectQuery = ` INSERT INTO digital_twin_objects ( @@ -287,12 +307,53 @@ export const updateLayout = async ( rotation, color, area_key, loca_key, loc_type, material_count, material_preview_height, - parent_id, display_order, locked + parent_id, display_order, locked, + hierarchy_level, parent_key, external_key ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22) + RETURNING id `; - for (const obj of objects) { + // 임시 ID (음수) → 실제 DB ID 매핑 + const idMapping: { [tempId: number]: number } = {}; + + // 1단계: 부모 객체 먼저 저장 (parentId가 없는 것들) + for (const obj of objects.filter((o) => !o.parentId)) { + const result = 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, + null, // parent_id + obj.displayOrder || 0, + obj.locked || false, + obj.hierarchyLevel || 1, + obj.parentKey || null, + obj.externalKey || null, + ]); + + // 임시 ID와 실제 DB ID 매핑 + if (obj.id) { + idMapping[obj.id] = result.rows[0].id; + } + } + + // 2단계: 자식 객체 저장 (parentId가 있는 것들) + for (const obj of objects.filter((o) => o.parentId)) { + const realParentId = idMapping[obj.parentId!] || null; + await client.query(objectQuery, [ id, obj.type, @@ -310,9 +371,12 @@ export const updateLayout = async ( obj.locType || null, obj.materialCount || 0, obj.materialPreview?.height || null, - obj.parentId || null, + realParentId, // 실제 DB ID 사용 obj.displayOrder || 0, obj.locked || false, + obj.hierarchyLevel || 1, + obj.parentKey || null, + obj.externalKey || null, ]); } } @@ -344,7 +408,7 @@ export const updateLayout = async ( // 레이아웃 삭제 export const deleteLayout = async ( - req: Request, + req: AuthenticatedRequest, res: Response ): Promise => { try { diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts index 5046d8bb..880c54fc 100644 --- a/backend-node/src/controllers/entitySearchController.ts +++ b/backend-node/src/controllers/entitySearchController.ts @@ -1,4 +1,5 @@ -import { Request, Response } from "express"; +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; @@ -6,7 +7,7 @@ import { logger } from "../utils/logger"; * 엔티티 검색 API * GET /api/entity-search/:tableName */ -export async function searchEntity(req: Request, res: Response) { +export async function searchEntity(req: AuthenticatedRequest, res: Response) { try { const { tableName } = req.params; const { @@ -22,7 +23,8 @@ export async function searchEntity(req: Request, res: Response) { logger.warn("엔티티 검색 실패: 테이블명이 없음", { tableName }); return res.status(400).json({ success: false, - message: "테이블명이 지정되지 않았습니다. 컴포넌트 설정에서 sourceTable을 확인해주세요.", + message: + "테이블명이 지정되지 않았습니다. 컴포넌트 설정에서 sourceTable을 확인해주세요.", }); } @@ -111,8 +113,10 @@ export async function searchEntity(req: Request, res: Response) { }, }); } catch (error: any) { - logger.error("엔티티 검색 오류", { error: error.message, stack: error.stack }); + logger.error("엔티티 검색 오류", { + error: error.message, + stack: error.stack, + }); res.status(500).json({ success: false, message: error.message }); } } - diff --git a/backend-node/src/controllers/orderController.ts b/backend-node/src/controllers/orderController.ts index 0b76fd95..e38f2466 100644 --- a/backend-node/src/controllers/orderController.ts +++ b/backend-node/src/controllers/orderController.ts @@ -1,4 +1,5 @@ -import { Request, Response } from "express"; +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; @@ -35,7 +36,7 @@ async function generateOrderNumber(companyCode: string): Promise { * 수주 등록 API * POST /api/orders */ -export async function createOrder(req: Request, res: Response) { +export async function createOrder(req: AuthenticatedRequest, res: Response) { const pool = getPool(); try { @@ -167,7 +168,7 @@ export async function createOrder(req: Request, res: Response) { * 수주 목록 조회 API * GET /api/orders */ -export async function getOrders(req: Request, res: Response) { +export async function getOrders(req: AuthenticatedRequest, res: Response) { const pool = getPool(); try { @@ -235,4 +236,3 @@ export async function getOrders(req: Request, res: Response) { }); } } - diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index c696d5de..f87aa5d6 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -14,8 +14,17 @@ router.get( authenticateToken, async (req: AuthenticatedRequest, res) => { try { - const { leftTable, rightTable, leftColumn, rightColumn, leftValue, dataFilter, enableEntityJoin, displayColumns, deduplication } = - req.query; + const { + leftTable, + rightTable, + leftColumn, + rightColumn, + leftValue, + dataFilter, + enableEntityJoin, + displayColumns, + deduplication, + } = req.query; // 입력값 검증 if (!leftTable || !rightTable || !leftColumn || !rightColumn) { @@ -38,7 +47,9 @@ router.get( } // 🆕 enableEntityJoin 파싱 - const enableEntityJoinFlag = enableEntityJoin === "true" || enableEntityJoin === true; + const enableEntityJoinFlag = + enableEntityJoin === "true" || + (typeof enableEntityJoin === "boolean" && enableEntityJoin); // SQL 인젝션 방지를 위한 검증 const tables = [leftTable as string, rightTable as string]; @@ -68,7 +79,9 @@ router.get( const userCompany = req.user?.companyCode; // displayColumns 파싱 (item_info.item_name 등) - let parsedDisplayColumns: Array<{ name: string; label?: string }> | undefined; + let parsedDisplayColumns: + | Array<{ name: string; label?: string }> + | undefined; if (displayColumns) { try { parsedDisplayColumns = JSON.parse(displayColumns as string); @@ -78,12 +91,14 @@ router.get( } // 🆕 deduplication 파싱 - let parsedDeduplication: { - enabled: boolean; - groupByColumn: string; - keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; - sortColumn?: string; - } | undefined; + let parsedDeduplication: + | { + enabled: boolean; + groupByColumn: string; + keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; + sortColumn?: string; + } + | undefined; if (deduplication) { try { parsedDeduplication = JSON.parse(deduplication as string); @@ -340,30 +355,37 @@ router.get( } const { enableEntityJoin, groupByColumns } = req.query; - const enableEntityJoinFlag = enableEntityJoin === "true" || enableEntityJoin === true; - + const enableEntityJoinFlag = + enableEntityJoin === "true" || + (typeof enableEntityJoin === "boolean" && enableEntityJoin); + // groupByColumns 파싱 (JSON 문자열 또는 쉼표 구분) let groupByColumnsArray: string[] = []; if (groupByColumns) { try { if (typeof groupByColumns === "string") { // JSON 형식이면 파싱, 아니면 쉼표로 분리 - groupByColumnsArray = groupByColumns.startsWith("[") - ? JSON.parse(groupByColumns) - : groupByColumns.split(",").map(c => c.trim()); + groupByColumnsArray = groupByColumns.startsWith("[") + ? JSON.parse(groupByColumns) + : groupByColumns.split(",").map((c) => c.trim()); } } catch (error) { console.warn("groupByColumns 파싱 실패:", error); } } - console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`, { + console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`, { enableEntityJoin: enableEntityJoinFlag, - groupByColumns: groupByColumnsArray + groupByColumns: groupByColumnsArray, }); // 레코드 상세 조회 (Entity Join 옵션 + 그룹핑 옵션 포함) - const result = await dataService.getRecordDetail(tableName, id, enableEntityJoinFlag, groupByColumnsArray); + const result = await dataService.getRecordDetail( + tableName, + id, + enableEntityJoinFlag, + groupByColumnsArray + ); if (!result.success) { return res.status(400).json(result); @@ -396,7 +418,7 @@ router.get( /** * 그룹화된 데이터 UPSERT API * POST /api/data/upsert-grouped - * + * * 요청 본문: * { * tableName: string, @@ -415,7 +437,8 @@ router.post( if (!tableName || !parentKeys || !records || !Array.isArray(records)) { return res.status(400).json({ success: false, - message: "필수 파라미터가 누락되었습니다 (tableName, parentKeys, records).", + message: + "필수 파라미터가 누락되었습니다 (tableName, parentKeys, records).", error: "MISSING_PARAMETERS", }); } @@ -450,17 +473,17 @@ router.post( } console.log(`✅ 그룹화된 데이터 UPSERT 성공: ${tableName}`, { - inserted: result.inserted, - updated: result.updated, - deleted: result.deleted, + inserted: result.data?.inserted || 0, + updated: result.data?.updated || 0, + deleted: result.data?.deleted || 0, }); return res.json({ success: true, message: "데이터가 저장되었습니다.", - inserted: result.inserted, - updated: result.updated, - deleted: result.deleted, + inserted: result.data?.inserted || 0, + updated: result.data?.updated || 0, + deleted: result.data?.deleted || 0, }); } catch (error) { console.error("그룹화된 데이터 UPSERT 오류:", error); @@ -506,16 +529,22 @@ router.post( // company_code와 company_name 자동 추가 (멀티테넌시) const enrichedData = { ...data }; - + // 테이블에 company_code 컬럼이 있는지 확인하고 자동으로 추가 - const hasCompanyCode = await dataService.checkColumnExists(tableName, "company_code"); + const hasCompanyCode = await dataService.checkColumnExists( + tableName, + "company_code" + ); if (hasCompanyCode && req.user?.companyCode) { enrichedData.company_code = req.user.companyCode; console.log(`🏢 company_code 자동 추가: ${req.user.companyCode}`); } - + // 테이블에 company_name 컬럼이 있는지 확인하고 자동으로 추가 - const hasCompanyName = await dataService.checkColumnExists(tableName, "company_name"); + const hasCompanyName = await dataService.checkColumnExists( + tableName, + "company_name" + ); if (hasCompanyName && req.user?.companyName) { enrichedData.company_name = req.user.companyName; console.log(`🏢 company_name 자동 추가: ${req.user.companyName}`); @@ -679,7 +708,10 @@ router.post( console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions }); - const result = await dataService.deleteGroupRecords(tableName, filterConditions); + const result = await dataService.deleteGroupRecords( + tableName, + filterConditions + ); if (!result.success) { return res.status(400).json(result); diff --git a/backend-node/src/routes/digitalTwinRoutes.ts b/backend-node/src/routes/digitalTwinRoutes.ts index 3130b470..904096f7 100644 --- a/backend-node/src/routes/digitalTwinRoutes.ts +++ b/backend-node/src/routes/digitalTwinRoutes.ts @@ -12,6 +12,8 @@ import { // 외부 DB 데이터 조회 import { + getHierarchyData, + getChildrenData, getWarehouses, getAreas, getLocations, @@ -32,6 +34,12 @@ router.put("/layouts/:id", updateLayout); // 레이아웃 수정 router.delete("/layouts/:id", deleteLayout); // 레이아웃 삭제 // ========== 외부 DB 데이터 조회 API ========== + +// 동적 계층 구조 API +router.post("/data/hierarchy", getHierarchyData); // 전체 계층 데이터 조회 +router.post("/data/children", getChildrenData); // 특정 부모의 하위 데이터 조회 + +// 테이블 메타데이터 API router.get("/data/tables/:connectionId", async (req, res) => { // 테이블 목록 조회 try { @@ -56,11 +64,12 @@ router.get("/data/table-preview/:connectionId/:tableName", async (req, res) => { } }); +// 레거시 API (호환성 유지) 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) +router.post("/data/material-counts", getMaterialCounts); // 자재 개수 (여러 Location) - POST로 변경 export default router; diff --git a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx index 082e8661..e7680584 100644 --- a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx +++ b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx @@ -18,32 +18,26 @@ import { Pagination, PaginationInfo } from "@/components/common/Pagination"; import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal"; import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react"; -interface DashboardListClientProps { - initialDashboards: Dashboard[]; - initialPagination: { - total: number; - page: number; - limit: number; - }; -} - /** * 대시보드 목록 클라이언트 컴포넌트 + * - CSR 방식으로 초기 데이터 로드 * - 대시보드 목록 조회 * - 대시보드 생성/수정/삭제/복사 */ -export default function DashboardListClient({ initialDashboards, initialPagination }: DashboardListClientProps) { +export default function DashboardListClient() { const router = useRouter(); const { toast } = useToast(); - const [dashboards, setDashboards] = useState(initialDashboards); - const [loading, setLoading] = useState(false); // 초기 로딩은 서버에서 완료 + + // 상태 관리 + const [dashboards, setDashboards] = useState([]); + const [loading, setLoading] = useState(true); // CSR이므로 초기 로딩 true const [error, setError] = useState(null); const [searchTerm, setSearchTerm] = useState(""); // 페이지네이션 상태 - const [currentPage, setCurrentPage] = useState(initialPagination.page); - const [pageSize, setPageSize] = useState(initialPagination.limit); - const [totalCount, setTotalCount] = useState(initialPagination.total); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [totalCount, setTotalCount] = useState(0); // 모달 상태 const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -73,17 +67,8 @@ export default function DashboardListClient({ initialDashboards, initialPaginati } }; - // 초기 로드 여부 추적 - const [isInitialLoad, setIsInitialLoad] = useState(true); - + // 검색어/페이지 변경 시 fetch (초기 로딩 포함) useEffect(() => { - // 초기 로드는 건너뛰기 (서버에서 이미 데이터를 가져왔음) - if (isInitialLoad) { - setIsInitialLoad(false); - return; - } - - // 이후 검색어/페이지 변경 시에만 fetch loadDashboards(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchTerm, currentPage, pageSize]); @@ -91,7 +76,7 @@ export default function DashboardListClient({ initialDashboards, initialPaginati // 페이지네이션 정보 계산 const paginationInfo: PaginationInfo = { currentPage, - totalPages: Math.ceil(totalCount / pageSize), + totalPages: Math.ceil(totalCount / pageSize) || 1, totalItems: totalCount, itemsPerPage: pageSize, startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1, diff --git a/frontend/app/(main)/admin/dashboard/page.tsx b/frontend/app/(main)/admin/dashboard/page.tsx index 8d78600c..7d09bafc 100644 --- a/frontend/app/(main)/admin/dashboard/page.tsx +++ b/frontend/app/(main)/admin/dashboard/page.tsx @@ -1,73 +1,22 @@ import DashboardListClient from "@/app/(main)/admin/dashboard/DashboardListClient"; -import { cookies } from "next/headers"; /** - * 서버에서 초기 대시보드 목록 fetch + * 대시보드 관리 페이지 + * - 클라이언트 컴포넌트를 렌더링하는 래퍼 + * - 초기 로딩부터 CSR로 처리 */ -async function getInitialDashboards() { - try { - // 서버 사이드 전용: 백엔드 API 직접 호출 - // 도커 네트워크 내부에서는 서비스 이름 사용, 로컬에서는 127.0.0.1 - const backendUrl = process.env.SERVER_API_URL || "http://backend:8080"; - - // 쿠키에서 authToken 추출 - const cookieStore = await cookies(); - const authToken = cookieStore.get("authToken")?.value; - - if (!authToken) { - // 토큰이 없으면 빈 데이터 반환 (클라이언트에서 로드) - return { - dashboards: [], - pagination: { total: 0, page: 1, limit: 10 }, - }; - } - - const response = await fetch(`${backendUrl}/api/dashboards/my?page=1&limit=10`, { - cache: "no-store", // 항상 최신 데이터 - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authToken}`, // Authorization 헤더로 전달 - }, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch dashboards: ${response.status}`); - } - - const data = await response.json(); - return { - dashboards: data.data || [], - pagination: data.pagination || { total: 0, page: 1, limit: 10 }, - }; - } catch (error) { - console.error("Server-side fetch error:", error); - // 에러 발생 시 빈 데이터 반환 (클라이언트에서 재시도 가능) - return { - dashboards: [], - pagination: { total: 0, page: 1, limit: 10 }, - }; - } -} - -/** - * 대시보드 관리 페이지 (서버 컴포넌트) - * - 페이지 헤더 + 초기 데이터를 서버에서 렌더링 - * - 클라이언트 컴포넌트로 초기 데이터 전달 - */ -export default async function DashboardListPage() { - const initialData = await getInitialDashboards(); - +export default function DashboardListPage() { return (
- {/* 페이지 헤더 (서버에서 렌더링) */} + {/* 페이지 헤더 */}

대시보드 관리

대시보드를 생성하고 관리할 수 있습니다

- {/* 나머지 컨텐츠 (클라이언트 컴포넌트 + 서버 데이터) */} - + {/* 클라이언트 컴포넌트 */} +
); diff --git a/frontend/app/test-entity-search/page.tsx b/frontend/app/test-entity-search/page.tsx index af9317a3..802024d9 100644 --- a/frontend/app/test-entity-search/page.tsx +++ b/frontend/app/test-entity-search/page.tsx @@ -1,138 +1,27 @@ "use client"; -import React, { useState } from "react"; -import { EntitySearchInputComponent } from "@/lib/registry/components/entity-search-input"; -import { AutocompleteSearchInputComponent } from "@/lib/registry/components/autocomplete-search-input"; +import React from "react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Label } from "@/components/ui/label"; export default function TestEntitySearchPage() { - const [customerCode, setCustomerCode] = useState(""); - const [customerData, setCustomerData] = useState(null); - - const [itemCode, setItemCode] = useState(""); - const [itemData, setItemData] = useState(null); - return ( -
+

EntitySearchInput 테스트

-

- 엔티티 검색 입력 컴포넌트 동작 테스트 -

+

이 페이지는 빌드 에러로 인해 임시로 비활성화되었습니다.

- {/* 거래처 검색 테스트 - 자동완성 방식 */} - 거래처 검색 (자동완성 드롭다운 방식) ⭐ NEW - - 타이핑하면 바로 드롭다운이 나타나는 방식 - 수주 등록에서 사용 - + 빌드 에러 수정 중 + 순환 참조 문제를 해결한 후 다시 활성화됩니다. - -
- - { - setCustomerCode(code || ""); - setCustomerData(fullData); - }} - /> -
- - {customerData && ( -
-

선택된 거래처 정보:

-
-                {JSON.stringify(customerData, null, 2)}
-              
-
- )} -
-
- - {/* 거래처 검색 테스트 - 모달 방식 */} - - - 거래처 검색 (모달 방식) - - 버튼 클릭 → 모달 열기 → 검색 및 선택 방식 - - - -
- - { - setCustomerCode(code || ""); - setCustomerData(fullData); - }} - /> -
-
-
- - {/* 품목 검색 테스트 */} - - - 품목 검색 (Modal 모드) - - item_info 테이블에서 품목을 검색합니다 - - - -
- - { - setItemCode(code || ""); - setItemData(fullData); - }} - /> -
- - {itemData && ( -
-

선택된 품목 정보:

-
-                {JSON.stringify(itemData, null, 2)}
-              
-
- )} + +

+ 에러 메시지: ReferenceError: Cannot access 'h' before initialization +

); } - diff --git a/frontend/app/test-order-registration/page.tsx b/frontend/app/test-order-registration/page.tsx index 689c5434..fe6a005f 100644 --- a/frontend/app/test-order-registration/page.tsx +++ b/frontend/app/test-order-registration/page.tsx @@ -1,87 +1,27 @@ "use client"; -import React, { useState } from "react"; -import { OrderRegistrationModal } from "@/components/order/OrderRegistrationModal"; -import { Button } from "@/components/ui/button"; +import React from "react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; export default function TestOrderRegistrationPage() { - const [modalOpen, setModalOpen] = useState(false); - - const handleSuccess = () => { - console.log("수주 등록 성공!"); - }; - return ( -
+

수주 등록 테스트

-

- EntitySearchInput + ModalRepeaterTable을 활용한 수주 등록 화면 -

+

이 페이지는 빌드 에러로 인해 임시로 비활성화되었습니다.

- 수주 등록 모달 - - 모달 버튼을 클릭하여 수주 등록 화면을 테스트하세요 - + 빌드 에러 수정 중 + ModalRepeaterTable 순환 참조 문제를 해결한 후 다시 활성화됩니다. - +

+ 에러 메시지: ReferenceError: Cannot access 'h' before initialization +

- - - - 구현된 기능 - - -
- - EntitySearchInput: 거래처 검색 및 선택 (콤보 모드) -
-
- - ModalRepeaterTable: 품목 검색 및 동적 추가 -
-
- - 자동 계산: 수량 × 단가 = 금액 -
-
- - 인라인 편집: 수량, 단가, 납품일, 비고 수정 가능 -
-
- - 중복 방지: 이미 추가된 품목은 선택 불가 -
-
- - 행 삭제: 추가된 품목 개별 삭제 가능 -
-
- - 전체 금액 표시: 모든 품목 금액의 합계 -
-
- - 입력 방식 전환: 거래처 우선 / 견대 방식 / 단가 방식 -
-
-
- - {/* 수주 등록 모달 */} -
); } - diff --git a/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx b/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx index 9f5d21f3..b8eb31ef 100644 --- a/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx @@ -199,14 +199,14 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult onValueChange={(value: "current" | "external") => onChange({ connectionType: value })} >
- -
- -
@@ -216,7 +216,7 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult {/* 외부 DB 선택 */} {dataSource.connectionType === "external" && (
-