diff --git a/backend-node/src/routes/roleRoutes.ts b/backend-node/src/routes/roleRoutes.ts index 21c17ecb..0f8a64b0 100644 --- a/backend-node/src/routes/roleRoutes.ts +++ b/backend-node/src/routes/roleRoutes.ts @@ -22,6 +22,15 @@ const router = Router(); // 모든 role 라우트에 인증 미들웨어 적용 router.use(authenticateToken); +/** + * 사용자 권한 그룹 조회 (/:id 보다 먼저 정의해야 함) + */ +// 현재 사용자가 속한 권한 그룹 조회 +router.get("/user/my-groups", getUserRoleGroups); + +// 특정 사용자가 속한 권한 그룹 조회 +router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups); + /** * 권한 그룹 CRUD */ @@ -67,13 +76,4 @@ router.get("/:id/menu-permissions", requireAdmin, getMenuPermissions); // 메뉴 권한 설정 router.put("/:id/menu-permissions", requireAdmin, setMenuPermissions); -/** - * 사용자 권한 그룹 조회 - */ -// 현재 사용자가 속한 권한 그룹 조회 -router.get("/user/my-groups", getUserRoleGroups); - -// 특정 사용자가 속한 권한 그룹 조회 -router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups); - export default router; diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index b66ba165..26c8b779 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -299,18 +299,16 @@ export class MenuCopyService { const screenIds = new Set(); const visited = new Set(); - // 1) 메뉴에 직접 할당된 화면 - for (const menuObjid of menuObjids) { - const assignmentsResult = await client.query<{ screen_id: number }>( - `SELECT DISTINCT screen_id - FROM screen_menu_assignments - WHERE menu_objid = $1 AND company_code = $2`, - [menuObjid, sourceCompanyCode] - ); + // 1) 메뉴에 직접 할당된 화면 - 배치 조회 + const assignmentsResult = await client.query<{ screen_id: number }>( + `SELECT DISTINCT screen_id + FROM screen_menu_assignments + WHERE menu_objid = ANY($1) AND company_code = $2`, + [menuObjids, sourceCompanyCode] + ); - for (const assignment of assignmentsResult.rows) { - screenIds.add(assignment.screen_id); - } + for (const assignment of assignmentsResult.rows) { + screenIds.add(assignment.screen_id); } logger.info(`📌 직접 할당 화면: ${screenIds.size}개`); @@ -371,50 +369,56 @@ export class MenuCopyService { screenId: number; }> = []; - for (const screenId of screenIds) { - const layoutsResult = await client.query( - `SELECT properties FROM screen_layouts WHERE screen_id = $1`, - [screenId] - ); + // 배치 조회: 모든 화면의 레이아웃을 한 번에 조회 + const screenIdArray = Array.from(screenIds); + if (screenIdArray.length === 0) { + return flowIds; + } - for (const layout of layoutsResult.rows) { - const props = layout.properties; + const layoutsResult = await client.query< + ScreenLayout & { screen_id: number } + >( + `SELECT screen_id, properties FROM screen_layouts WHERE screen_id = ANY($1)`, + [screenIdArray] + ); - // webTypeConfig.dataflowConfig.flowConfig.flowId - const flowId = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId; - const flowName = - props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowName || - "Unknown"; + for (const layout of layoutsResult.rows) { + const props = layout.properties; + const screenId = layout.screen_id; - if (flowId && typeof flowId === "number" && flowId > 0) { - if (!flowIds.has(flowId)) { - flowIds.add(flowId); - flowDetails.push({ flowId, flowName, screenId }); - logger.info( - ` 📎 화면 ${screenId}에서 플로우 발견: id=${flowId}, name="${flowName}"` - ); - } + // webTypeConfig.dataflowConfig.flowConfig.flowId + const flowId = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId; + const flowName = + props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowName || "Unknown"; + + if (flowId && typeof flowId === "number" && flowId > 0) { + if (!flowIds.has(flowId)) { + flowIds.add(flowId); + flowDetails.push({ flowId, flowName, screenId }); + logger.info( + ` 📎 화면 ${screenId}에서 플로우 발견: id=${flowId}, name="${flowName}"` + ); } + } - // selectedDiagramId도 확인 (flowId와 동일할 수 있지만 다를 수도 있음) - const selectedDiagramId = - props?.webTypeConfig?.dataflowConfig?.selectedDiagramId; - if ( - selectedDiagramId && - typeof selectedDiagramId === "number" && - selectedDiagramId > 0 - ) { - if (!flowIds.has(selectedDiagramId)) { - flowIds.add(selectedDiagramId); - flowDetails.push({ - flowId: selectedDiagramId, - flowName: "SelectedDiagram", - screenId, - }); - logger.info( - ` 📎 화면 ${screenId}에서 selectedDiagramId 발견: id=${selectedDiagramId}` - ); - } + // selectedDiagramId도 확인 (flowId와 동일할 수 있지만 다를 수도 있음) + const selectedDiagramId = + props?.webTypeConfig?.dataflowConfig?.selectedDiagramId; + if ( + selectedDiagramId && + typeof selectedDiagramId === "number" && + selectedDiagramId > 0 + ) { + if (!flowIds.has(selectedDiagramId)) { + flowIds.add(selectedDiagramId); + flowDetails.push({ + flowId: selectedDiagramId, + flowName: "SelectedDiagram", + screenId, + }); + logger.info( + ` 📎 화면 ${screenId}에서 selectedDiagramId 발견: id=${selectedDiagramId}` + ); } } } @@ -582,6 +586,12 @@ export class MenuCopyService { logger.info( ` 🔗 채번규칙 참조 업데이트 (${currentPath}): ${value} → ${newRuleId}` ); + } else { + // 매핑이 없는 채번규칙은 빈 값으로 설정 (다른 회사 채번규칙 참조 방지) + logger.warn( + ` ⚠️ 채번규칙 매핑 없음 (${currentPath}): ${value} → 빈 값으로 설정` + ); + obj[key] = ""; } } @@ -728,7 +738,7 @@ export class MenuCopyService { ]); logger.info(` ✅ 메뉴 권한 삭제 완료`); - // 5-4. 채번 규칙 처리 (외래키 제약조건 해결) + // 5-4. 채번 규칙 처리 (체크 제약조건 고려) // scope_type = 'menu'인 채번 규칙: 메뉴 전용이므로 삭제 (파트 포함) // check_menu_scope_requires_menu_objid 제약: scope_type='menu'이면 menu_objid NOT NULL 필수 const menuScopedRulesResult = await client.query( @@ -746,47 +756,53 @@ export class MenuCopyService { [menuScopedRuleIds] ); // 채번 규칙 삭제 - await client.query( - `DELETE FROM numbering_rules WHERE rule_id = ANY($1)`, - [menuScopedRuleIds] - ); + await client.query(`DELETE FROM numbering_rules WHERE rule_id = ANY($1)`, [ + menuScopedRuleIds, + ]); logger.info( ` ✅ 메뉴 전용 채번 규칙 삭제: ${menuScopedRuleIds.length}개` ); } // scope_type != 'menu'인 채번 규칙: menu_objid만 NULL로 설정 (규칙 보존) - const tableScopedRulesResult = await client.query( - `UPDATE numbering_rules + const updatedNumberingRules = await client.query( + `UPDATE numbering_rules SET menu_objid = NULL WHERE menu_objid = ANY($1) AND company_code = $2 AND (scope_type IS NULL OR scope_type != 'menu') RETURNING rule_id`, [existingMenuIds, targetCompanyCode] ); - if (tableScopedRulesResult.rows.length > 0) { + if (updatedNumberingRules.rowCount && updatedNumberingRules.rowCount > 0) { logger.info( - ` ✅ 테이블 스코프 채번 규칙 연결 해제: ${tableScopedRulesResult.rows.length}개 (데이터 보존)` + ` ✅ 테이블 스코프 채번 규칙 연결 해제: ${updatedNumberingRules.rowCount}개 (데이터 보존됨)` ); } - // 5-5. 카테고리 컬럼 매핑 삭제 (NOT NULL 제약조건으로 삭제 필요) + // 5-5. 카테고리 매핑 삭제 (menu_objid가 NOT NULL이므로 NULL 설정 불가) + // 카테고리 매핑은 메뉴와 강하게 연결되어 있으므로 함께 삭제 const deletedCategoryMappings = await client.query( - `DELETE FROM category_column_mapping + `DELETE FROM category_column_mapping WHERE menu_objid = ANY($1) AND company_code = $2 RETURNING mapping_id`, [existingMenuIds, targetCompanyCode] ); - if (deletedCategoryMappings.rows.length > 0) { + if ( + deletedCategoryMappings.rowCount && + deletedCategoryMappings.rowCount > 0 + ) { logger.info( - ` ✅ 카테고리 매핑 삭제: ${deletedCategoryMappings.rows.length}개` + ` ✅ 카테고리 매핑 삭제 완료: ${deletedCategoryMappings.rowCount}개` ); } - // 5-6. 메뉴 삭제 (배치) - await client.query(`DELETE FROM menu_info WHERE objid = ANY($1)`, [ - existingMenuIds, - ]); + // 5-6. 메뉴 삭제 (배치 삭제 - 하위 메뉴부터 삭제를 위해 역순 정렬된 ID 사용) + // 외래키 제약이 해제되었으므로 배치 삭제 가능 + if (existingMenuIds.length > 0) { + await client.query(`DELETE FROM menu_info WHERE objid = ANY($1)`, [ + existingMenuIds, + ]); + } logger.info(` ✅ 메뉴 삭제 완료: ${existingMenus.length}개`); logger.info("✅ 기존 복사본 삭제 완료 - 덮어쓰기 준비됨"); @@ -939,6 +955,20 @@ export class MenuCopyService { ); } + // === 4.9단계: 화면에서 참조하는 채번규칙 매핑 보완 === + // 화면 properties에서 참조하는 채번규칙 중 아직 매핑되지 않은 것들을 + // 대상 회사에서 같은 이름의 채번규칙으로 매핑 + if (screenIds.size > 0) { + logger.info("\n🔗 [4.9단계] 화면 채번규칙 참조 매핑 보완"); + await this.supplementNumberingRuleMapping( + Array.from(screenIds), + sourceCompanyCode, + targetCompanyCode, + numberingRuleIdMap, + client + ); + } + // === 5단계: 화면 복사 === logger.info("\n📄 [5단계] 화면 복사"); const screenIdMap = await this.copyScreens( @@ -1293,6 +1323,37 @@ export class MenuCopyService { logger.info(`📄 화면 복사/업데이트 중: ${screenIds.size}개`); + // === 0단계: 원본 화면 정의 배치 조회 === + const screenIdArray = Array.from(screenIds); + const allScreenDefsResult = await client.query( + `SELECT * FROM screen_definitions WHERE screen_id = ANY($1)`, + [screenIdArray] + ); + const screenDefMap = new Map(); + for (const def of allScreenDefsResult.rows) { + screenDefMap.set(def.screen_id, def); + } + + // 대상 회사의 기존 복사본 배치 조회 (source_screen_id 기준) + const existingCopiesResult = await client.query<{ + screen_id: number; + screen_name: string; + source_screen_id: number; + updated_date: Date; + }>( + `SELECT screen_id, screen_name, source_screen_id, updated_date + FROM screen_definitions + WHERE source_screen_id = ANY($1) AND company_code = $2 AND deleted_date IS NULL`, + [screenIdArray, targetCompanyCode] + ); + const existingCopyMap = new Map< + number, + { screen_id: number; screen_name: string; updated_date: Date } + >(); + for (const copy of existingCopiesResult.rows) { + existingCopyMap.set(copy.source_screen_id, copy); + } + // === 1단계: 모든 screen_definitions 처리 (screenIdMap 생성) === const screenDefsToProcess: Array<{ originalScreenId: number; @@ -1303,35 +1364,20 @@ export class MenuCopyService { for (const originalScreenId of screenIds) { try { - // 1) 원본 screen_definitions 조회 - const screenDefResult = await client.query( - `SELECT * FROM screen_definitions WHERE screen_id = $1`, - [originalScreenId] - ); + // 1) 원본 screen_definitions 조회 (캐시에서) + const screenDef = screenDefMap.get(originalScreenId); - if (screenDefResult.rows.length === 0) { + if (!screenDef) { logger.warn(`⚠️ 화면을 찾을 수 없음: screen_id=${originalScreenId}`); continue; } - const screenDef = screenDefResult.rows[0]; - - // 2) 기존 복사본 찾기: source_screen_id로 검색 - let existingCopyResult = await client.query<{ - screen_id: number; - screen_name: string; - updated_date: Date; - }>( - `SELECT screen_id, screen_name, updated_date - FROM screen_definitions - WHERE source_screen_id = $1 AND company_code = $2 AND deleted_date IS NULL - LIMIT 1`, - [originalScreenId, targetCompanyCode] - ); + // 2) 기존 복사본 찾기: 캐시에서 조회 (source_screen_id 기준) + let existingCopy = existingCopyMap.get(originalScreenId); // 2-1) source_screen_id가 없는 기존 복사본 (이름 + 테이블로 검색) - 호환성 유지 - if (existingCopyResult.rows.length === 0 && screenDef.screen_name) { - existingCopyResult = await client.query<{ + if (!existingCopy && screenDef.screen_name) { + const legacyCopyResult = await client.query<{ screen_id: number; screen_name: string; updated_date: Date; @@ -1347,14 +1393,15 @@ export class MenuCopyService { [screenDef.screen_name, screenDef.table_name, targetCompanyCode] ); - if (existingCopyResult.rows.length > 0) { + if (legacyCopyResult.rows.length > 0) { + existingCopy = legacyCopyResult.rows[0]; // 기존 복사본에 source_screen_id 업데이트 (마이그레이션) await client.query( `UPDATE screen_definitions SET source_screen_id = $1 WHERE screen_id = $2`, - [originalScreenId, existingCopyResult.rows[0].screen_id] + [originalScreenId, existingCopy.screen_id] ); logger.info( - ` 📝 기존 화면에 source_screen_id 추가: ${existingCopyResult.rows[0].screen_id} ← ${originalScreenId}` + ` 📝 기존 화면에 source_screen_id 추가: ${existingCopy.screen_id} ← ${originalScreenId}` ); } } @@ -1375,10 +1422,9 @@ export class MenuCopyService { } } - if (existingCopyResult.rows.length > 0) { + if (existingCopy) { // === 기존 복사본이 있는 경우: 업데이트 === - const existingScreen = existingCopyResult.rows[0]; - const existingScreenId = existingScreen.screen_id; + const existingScreenId = existingCopy.screen_id; // 원본 레이아웃 조회 const sourceLayoutsResult = await client.query( @@ -1526,36 +1572,39 @@ export class MenuCopyService { // component_id 매핑 생성 (원본 → 새 ID) const componentIdMap = new Map(); - for (const layout of layoutsResult.rows) { - const newComponentId = `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const timestamp = Date.now(); + layoutsResult.rows.forEach((layout, idx) => { + const newComponentId = `comp_${timestamp}_${idx}_${Math.random().toString(36).substr(2, 5)}`; componentIdMap.set(layout.component_id, newComponentId); - } + }); - // 레이아웃 삽입 - for (const layout of layoutsResult.rows) { - const newComponentId = componentIdMap.get(layout.component_id)!; + // 레이아웃 배치 삽입 준비 + if (layoutsResult.rows.length > 0) { + const layoutValues: string[] = []; + const layoutParams: any[] = []; + let paramIdx = 1; - const newParentId = layout.parent_id - ? componentIdMap.get(layout.parent_id) || layout.parent_id - : null; - const newZoneId = layout.zone_id - ? componentIdMap.get(layout.zone_id) || layout.zone_id - : null; + for (const layout of layoutsResult.rows) { + const newComponentId = componentIdMap.get(layout.component_id)!; - const updatedProperties = this.updateReferencesInProperties( - layout.properties, - screenIdMap, - flowIdMap, - numberingRuleIdMap - ); + const newParentId = layout.parent_id + ? componentIdMap.get(layout.parent_id) || layout.parent_id + : null; + const newZoneId = layout.zone_id + ? componentIdMap.get(layout.zone_id) || layout.zone_id + : null; - await client.query( - `INSERT INTO screen_layouts ( - screen_id, component_type, component_id, parent_id, - position_x, position_y, width, height, properties, - display_order, layout_type, layout_config, zones_config, zone_id - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, - [ + const updatedProperties = this.updateReferencesInProperties( + layout.properties, + screenIdMap, + flowIdMap, + numberingRuleIdMap + ); + + layoutValues.push( + `($${paramIdx}, $${paramIdx + 1}, $${paramIdx + 2}, $${paramIdx + 3}, $${paramIdx + 4}, $${paramIdx + 5}, $${paramIdx + 6}, $${paramIdx + 7}, $${paramIdx + 8}, $${paramIdx + 9}, $${paramIdx + 10}, $${paramIdx + 11}, $${paramIdx + 12}, $${paramIdx + 13})` + ); + layoutParams.push( targetScreenId, layout.component_type, newComponentId, @@ -1569,8 +1618,19 @@ export class MenuCopyService { layout.layout_type, layout.layout_config, layout.zones_config, - newZoneId, - ] + newZoneId + ); + paramIdx += 14; + } + + // 배치 INSERT + await client.query( + `INSERT INTO screen_layouts ( + screen_id, component_type, component_id, parent_id, + position_x, position_y, width, height, properties, + display_order, layout_type, layout_config, zones_config, zone_id + ) VALUES ${layoutValues.join(", ")}`, + layoutParams ); } @@ -2185,6 +2245,101 @@ export class MenuCopyService { return { copiedCategories, copiedCodes }; } + /** + * 화면에서 참조하는 채번규칙 매핑 보완 + * 화면 properties에서 참조하는 채번규칙 중 아직 매핑되지 않은 것들을 + * 대상 회사에서 같은 이름(rule_name)의 채번규칙으로 매핑 + */ + private async supplementNumberingRuleMapping( + screenIds: number[], + sourceCompanyCode: string, + targetCompanyCode: string, + numberingRuleIdMap: Map, + client: PoolClient + ): Promise { + if (screenIds.length === 0) return; + + // 1. 화면 레이아웃에서 모든 채번규칙 ID 추출 + const layoutsResult = await client.query( + `SELECT properties::text as props FROM screen_layouts WHERE screen_id = ANY($1)`, + [screenIds] + ); + + const referencedRuleIds = new Set(); + const ruleIdRegex = /"numberingRuleId"\s*:\s*"([^"]+)"/g; + + for (const row of layoutsResult.rows) { + if (!row.props) continue; + let match; + while ((match = ruleIdRegex.exec(row.props)) !== null) { + const ruleId = match[1]; + // 이미 매핑된 것은 제외 + if (ruleId && !numberingRuleIdMap.has(ruleId)) { + referencedRuleIds.add(ruleId); + } + } + } + + if (referencedRuleIds.size === 0) { + logger.info(` 📭 추가 매핑 필요 없음`); + return; + } + + logger.info(` 🔍 매핑 필요한 채번규칙: ${referencedRuleIds.size}개`); + + // 2. 원본 채번규칙 정보 조회 (rule_name으로 대상 회사에서 찾기 위해) + const sourceRulesResult = await client.query( + `SELECT rule_id, rule_name, table_name FROM numbering_rules + WHERE rule_id = ANY($1)`, + [Array.from(referencedRuleIds)] + ); + + if (sourceRulesResult.rows.length === 0) { + logger.warn( + ` ⚠️ 원본 채번규칙 조회 실패: ${Array.from(referencedRuleIds).join(", ")}` + ); + return; + } + + // 3. 대상 회사에서 같은 이름의 채번규칙 찾기 + const ruleNames = sourceRulesResult.rows.map((r) => r.rule_name); + const targetRulesResult = await client.query( + `SELECT rule_id, rule_name, table_name FROM numbering_rules + WHERE rule_name = ANY($1) AND company_code = $2`, + [ruleNames, targetCompanyCode] + ); + + // rule_name -> target_rule_id 매핑 + const targetRulesByName = new Map(); + for (const r of targetRulesResult.rows) { + // 같은 이름이 여러 개일 수 있으므로 첫 번째만 사용 + if (!targetRulesByName.has(r.rule_name)) { + targetRulesByName.set(r.rule_name, r.rule_id); + } + } + + // 4. 매핑 추가 + let mappedCount = 0; + for (const sourceRule of sourceRulesResult.rows) { + const targetRuleId = targetRulesByName.get(sourceRule.rule_name); + if (targetRuleId) { + numberingRuleIdMap.set(sourceRule.rule_id, targetRuleId); + logger.info( + ` ✅ 채번규칙 매핑 추가: ${sourceRule.rule_id} (${sourceRule.rule_name}) → ${targetRuleId}` + ); + mappedCount++; + } else { + logger.warn( + ` ⚠️ 대상 회사에 같은 이름의 채번규칙 없음: ${sourceRule.rule_name}` + ); + } + } + + logger.info( + ` ✅ 채번규칙 매핑 보완 완료: ${mappedCount}/${referencedRuleIds.size}개` + ); + } + /** * 채번 규칙 복사 (최적화: 배치 조회/삽입) * 화면 복사 전에 호출되어 numberingRuleId 참조 업데이트에 사용됨 @@ -2215,10 +2370,12 @@ export class MenuCopyService { return { copiedCount, ruleIdMap }; } - // 2. 대상 회사에 이미 존재하는 모든 채번 규칙 조회 (원본 ID + 새로 생성될 ID 모두 체크) + // 2. 대상 회사에 이미 존재하는 채번 규칙 한 번에 조회 + const ruleIds = allRulesResult.rows.map((r) => r.rule_id); const existingRulesResult = await client.query( - `SELECT rule_id FROM numbering_rules WHERE company_code = $1`, - [targetCompanyCode] + `SELECT rule_id FROM numbering_rules + WHERE rule_id = ANY($1) AND company_code = $2`, + [ruleIds, targetCompanyCode] ); const existingRuleIds = new Set( existingRulesResult.rows.map((r) => r.rule_id) @@ -2228,21 +2385,29 @@ export class MenuCopyService { const rulesToCopy: any[] = []; const originalToNewRuleMap: Array<{ original: string; new: string }> = []; - for (const rule of allRulesResult.rows) { - // 새 rule_id 생성 - const originalSuffix = rule.rule_id.includes("_") - ? rule.rule_id.replace(/^[^_]*_/, "") - : rule.rule_id; - const newRuleId = `${targetCompanyCode}_${originalSuffix}`; + // 기존 규칙 중 menu_objid 업데이트가 필요한 규칙들 + const rulesToUpdate: Array<{ ruleId: string; newMenuObjid: number }> = []; - // 원본 ID 또는 새로 생성될 ID가 이미 존재하는 경우 스킵 + for (const rule of allRulesResult.rows) { if (existingRuleIds.has(rule.rule_id)) { + // 기존 규칙은 동일한 ID로 매핑 ruleIdMap.set(rule.rule_id, rule.rule_id); - logger.info(` ♻️ 채번규칙 이미 존재 (원본 ID): ${rule.rule_id}`); - } else if (existingRuleIds.has(newRuleId)) { - ruleIdMap.set(rule.rule_id, newRuleId); - logger.info(` ♻️ 채번규칙 이미 존재 (대상 ID): ${newRuleId}`); + + // 새 메뉴 ID로 연결 업데이트 필요 + const newMenuObjid = menuIdMap.get(rule.menu_objid); + if (newMenuObjid) { + rulesToUpdate.push({ ruleId: rule.rule_id, newMenuObjid }); + } + logger.info( + ` ♻️ 채번규칙 이미 존재 (메뉴 연결 갱신): ${rule.rule_id}` + ); } else { + // 새 rule_id 생성 + const originalSuffix = rule.rule_id.includes("_") + ? rule.rule_id.replace(/^[^_]*_/, "") + : rule.rule_id; + const newRuleId = `${targetCompanyCode}_${originalSuffix}`; + ruleIdMap.set(rule.rule_id, newRuleId); originalToNewRuleMap.push({ original: rule.rule_id, new: newRuleId }); rulesToCopy.push({ ...rule, newRuleId }); @@ -2288,8 +2453,30 @@ export class MenuCopyService { copiedCount = rulesToCopy.length; logger.info(` ✅ 채번 규칙 ${copiedCount}개 복사`); + } - // 5. 모든 원본 파트 한 번에 조회 + // 4-1. 기존 채번 규칙의 menu_objid 업데이트 (새 메뉴와 연결) - 배치 처리 + if (rulesToUpdate.length > 0) { + // CASE WHEN을 사용한 배치 업데이트 + const caseWhen = rulesToUpdate + .map((_, i) => `WHEN rule_id = $${i * 2 + 1} THEN $${i * 2 + 2}`) + .join(" "); + const ruleIdsForUpdate = rulesToUpdate.map((r) => r.ruleId); + const params = rulesToUpdate.flatMap((r) => [r.ruleId, r.newMenuObjid]); + + await client.query( + `UPDATE numbering_rules + SET menu_objid = CASE ${caseWhen} END, updated_at = NOW() + WHERE rule_id = ANY($${params.length + 1}) AND company_code = $${params.length + 2}`, + [...params, ruleIdsForUpdate, targetCompanyCode] + ); + logger.info( + ` ✅ 기존 채번 규칙 ${rulesToUpdate.length}개 메뉴 연결 갱신` + ); + } + + // 5. 모든 원본 파트 한 번에 조회 (새로 복사한 규칙만 대상) + if (rulesToCopy.length > 0) { const originalRuleIds = rulesToCopy.map((r) => r.rule_id); const allPartsResult = await client.query( `SELECT * FROM numbering_rule_parts @@ -2380,11 +2567,24 @@ export class MenuCopyService { ]) ); - // 3. 복사할 매핑 필터링 및 배치 INSERT - const mappingsToCopy = allMappingsResult.rows.filter( - (m) => - !existingMappingKeys.has(`${m.table_name}|${m.logical_column_name}`) - ); + // 3. 복사할 매핑 필터링 및 기존 매핑 업데이트 대상 분류 + const mappingsToCopy: any[] = []; + const mappingsToUpdate: Array<{ mappingId: number; newMenuObjid: number }> = + []; + + for (const m of allMappingsResult.rows) { + const key = `${m.table_name}|${m.logical_column_name}`; + if (existingMappingKeys.has(key)) { + // 기존 매핑은 menu_objid만 업데이트 + const existingMappingId = existingMappingKeys.get(key); + const newMenuObjid = menuIdMap.get(m.menu_objid); + if (existingMappingId && newMenuObjid) { + mappingsToUpdate.push({ mappingId: existingMappingId, newMenuObjid }); + } + } else { + mappingsToCopy.push(m); + } + } // 새 매핑 ID -> 원본 매핑 정보 추적 const mappingInsertInfo: Array<{ mapping: any; newMenuObjid: number }> = []; @@ -2433,6 +2633,29 @@ export class MenuCopyService { logger.info(` ✅ 카테고리 매핑 ${copiedCount}개 복사`); } + // 3-1. 기존 카테고리 매핑의 menu_objid 업데이트 (새 메뉴와 연결) - 배치 처리 + if (mappingsToUpdate.length > 0) { + // CASE WHEN을 사용한 배치 업데이트 + const caseWhen = mappingsToUpdate + .map((_, i) => `WHEN mapping_id = $${i * 2 + 1} THEN $${i * 2 + 2}`) + .join(" "); + const mappingIdsForUpdate = mappingsToUpdate.map((m) => m.mappingId); + const params = mappingsToUpdate.flatMap((m) => [ + m.mappingId, + m.newMenuObjid, + ]); + + await client.query( + `UPDATE category_column_mapping + SET menu_objid = CASE ${caseWhen} END + WHERE mapping_id = ANY($${params.length + 1}) AND company_code = $${params.length + 2}`, + [...params, mappingIdsForUpdate, targetCompanyCode] + ); + logger.info( + ` ✅ 기존 카테고리 매핑 ${mappingsToUpdate.length}개 메뉴 연결 갱신` + ); + } + // 4. 모든 원본 카테고리 값 한 번에 조회 const allValuesResult = await client.query( `SELECT * FROM table_column_category_values diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 090985ba..6d254cfe 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -916,7 +916,7 @@ export function CanvasElement({ ) : element.type === "widget" && element.subtype === "weather" ? ( // 날씨 위젯 렌더링
- +
) : element.type === "widget" && element.subtype === "exchange" ? ( // 환율 위젯 렌더링 diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index 03abee6f..6ee36ca1 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -2146,32 +2146,32 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi - + {(hierarchyConfig?.material?.displayColumns || []).map((col) => ( - + {col.label} ))} - {materials.map((material, index) => { - const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER"; - const keyColumn = hierarchyConfig?.material?.keyColumn || "STKKEY"; - const displayColumns = hierarchyConfig?.material?.displayColumns || []; + {materials.map((material, index) => { + const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER"; + const keyColumn = hierarchyConfig?.material?.keyColumn || "STKKEY"; + const displayColumns = hierarchyConfig?.material?.displayColumns || []; const layerNumber = material[layerColumn] || index + 1; - return ( + return ( - {layerNumber}단 + {layerNumber}단 {displayColumns.map((col) => ( - + {material[col.column] || "-"} - ))} + ))} - ); - })} + ); + })}
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx index a702a047..ae98c795 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx @@ -1,7 +1,7 @@ "use client"; -import { useState, useEffect, useMemo } from "react"; -import { Loader2, Search, X, Grid3x3, Package, ParkingCircle, RefreshCw } from "lucide-react"; +import { useState, useEffect, useMemo, useRef } from "react"; +import { Loader2, Search, X, Grid3x3, Package, ParkingCircle, RefreshCw, Maximize, Minimize } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; @@ -12,6 +12,7 @@ import type { PlacedObject, MaterialData } from "@/types/digitalTwin"; import { getLayoutById, getMaterials } from "@/lib/api/digitalTwin"; import { OBJECT_COLORS, DEFAULT_COLOR } from "./constants"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; +import { apiCall } from "@/lib/api/client"; const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), { ssr: false, @@ -26,6 +27,9 @@ interface DigitalTwinViewerProps { layoutId: number; } +// 외부 업체 역할 코드 +const EXTERNAL_VENDOR_ROLE = "LSTHIRA_EXTERNAL_VENDOR"; + export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) { const { toast } = useToast(); const [placedObjects, setPlacedObjects] = useState([]); @@ -43,6 +47,73 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) const [filterType, setFilterType] = useState("all"); const [isRefreshing, setIsRefreshing] = useState(false); + // 외부 업체 모드 + const [isExternalMode, setIsExternalMode] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const [layoutKey, setLayoutKey] = useState(0); // 레이아웃 강제 리렌더링용 + const [lastRefreshedAt, setLastRefreshedAt] = useState(null); // 마지막 갱신 시간 + const canvasContainerRef = useRef(null); + + // 외부 업체 역할 체크 + useEffect(() => { + const checkExternalRole = async () => { + try { + const response = await apiCall("GET", "/roles/user/my-groups"); + console.log("=== 사용자 권한 그룹 조회 ==="); + console.log("API 응답:", response); + console.log("찾는 역할:", EXTERNAL_VENDOR_ROLE); + + if (response.success && response.data) { + console.log("권한 그룹 목록:", response.data); + + // 사용자의 권한 그룹 중 LSTHIRA_EXTERNAL_VENDOR가 있는지 확인 + const hasExternalRole = response.data.some((group: any) => { + console.log("체크 중인 그룹:", group.authCode, group.authName); + return group.authCode === EXTERNAL_VENDOR_ROLE || group.authName === EXTERNAL_VENDOR_ROLE; + }); + + console.log("외부 업체 역할 보유:", hasExternalRole); + setIsExternalMode(hasExternalRole); + } + } catch (error) { + console.error("역할 조회 실패:", error); + } + }; + + checkExternalRole(); + }, []); + + // 전체 화면 토글 (3D 캔버스 영역만) + const toggleFullscreen = () => { + if (!document.fullscreenElement) { + // 3D 캔버스 컨테이너만 풀스크린 + canvasContainerRef.current?.requestFullscreen(); + setIsFullscreen(true); + } else { + document.exitFullscreen(); + setIsFullscreen(false); + } + }; + + // 전체 화면 변경 감지 + useEffect(() => { + const handleFullscreenChange = () => { + const isNowFullscreen = !!document.fullscreenElement; + setIsFullscreen(isNowFullscreen); + + // 전체화면 종료 시 레이아웃 강제 리렌더링 + if (!isNowFullscreen) { + setTimeout(() => { + setLayoutKey((prev) => prev + 1); + window.dispatchEvent(new Event("resize")); + }, 50); + } + }; + + document.addEventListener("fullscreenchange", handleFullscreenChange); + return () => document.removeEventListener("fullscreenchange", handleFullscreenChange); + }, []); + // 레이아웃 데이터 로드 함수 const loadLayout = async () => { try { @@ -144,6 +215,8 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) }), ); } + // 마지막 갱신 시간 기록 + setLastRefreshedAt(new Date()); } else { throw new Error(response.error || "레이아웃 조회 실패"); } @@ -180,6 +253,155 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) // eslint-disable-next-line react-hooks/exhaustive-deps }, [layoutId]); + // 10초 주기 자동 갱신 (중앙 관제 화면 자동 새로고침) + useEffect(() => { + const AUTO_REFRESH_INTERVAL = 10000; // 10초 + + const silentRefresh = async () => { + // 로딩 중이거나 새로고침 중이면 스킵 + if (isLoading || isRefreshing) return; + + try { + // 레이아웃 데이터 조용히 갱신 + const response = await getLayoutById(layoutId); + + if (response.success && response.data) { + const { layout, objects } = response.data; + const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId; + + // hierarchy_config 파싱 + let hierarchyConfigData: any = null; + if (layout.hierarchy_config) { + hierarchyConfigData = + typeof layout.hierarchy_config === "string" + ? JSON.parse(layout.hierarchy_config) + : layout.hierarchy_config; + setHierarchyConfig(hierarchyConfigData); + } + + // 객체 데이터 변환 + const loadedObjects: PlacedObject[] = objects.map((obj: any) => { + const objectType = obj.object_type; + return { + id: obj.id, + type: objectType, + name: obj.object_name, + position: { + x: parseFloat(obj.position_x), + y: parseFloat(obj.position_y), + z: parseFloat(obj.position_z), + }, + size: { + x: parseFloat(obj.size_x), + y: parseFloat(obj.size_y), + z: parseFloat(obj.size_z), + }, + rotation: obj.rotation ? parseFloat(obj.rotation) : 0, + color: getObjectColor(objectType, obj.color), + areaKey: obj.area_key, + locaKey: obj.loca_key, + locType: obj.loc_type, + materialCount: obj.loc_type === "STP" ? undefined : obj.material_count, + materialPreview: + obj.loc_type === "STP" || !obj.material_preview_height + ? undefined + : { height: parseFloat(obj.material_preview_height) }, + parentId: obj.parent_id, + displayOrder: obj.display_order, + locked: obj.locked, + visible: obj.visible !== false, + hierarchyLevel: obj.hierarchy_level, + parentKey: obj.parent_key, + externalKey: obj.external_key, + }; + }); + + // 외부 DB 연결이 있고 자재 설정이 있으면, 각 Location의 실제 자재 개수 조회 + if (dbConnectionId && hierarchyConfigData?.material) { + const locationObjects = loadedObjects.filter( + (obj) => + (obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") && + obj.locaKey, + ); + + // 각 Location에 대해 자재 개수 조회 (병렬 처리) + const materialCountPromises = locationObjects.map(async (obj) => { + try { + const matResponse = await getMaterials(dbConnectionId, { + tableName: hierarchyConfigData.material.tableName, + keyColumn: hierarchyConfigData.material.keyColumn, + locationKeyColumn: hierarchyConfigData.material.locationKeyColumn, + layerColumn: hierarchyConfigData.material.layerColumn, + locaKey: obj.locaKey!, + }); + if (matResponse.success && matResponse.data) { + return { id: obj.id, count: matResponse.data.length }; + } + } catch { + // 자동 갱신 시에는 에러 로그 생략 + } + return { id: obj.id, count: 0 }; + }); + + const materialCounts = await Promise.all(materialCountPromises); + + // materialCount 업데이트 + const updatedObjects = loadedObjects.map((obj) => { + const countData = materialCounts.find((m) => m.id === obj.id); + if (countData && countData.count > 0) { + return { ...obj, materialCount: countData.count }; + } + return obj; + }); + + setPlacedObjects(updatedObjects); + } else { + setPlacedObjects(loadedObjects); + } + + // 선택된 객체가 있으면 자재 목록도 갱신 + if (selectedObject && dbConnectionId && hierarchyConfigData?.material) { + const currentObj = loadedObjects.find((o) => o.id === selectedObject.id); + if ( + currentObj && + (currentObj.type === "location-bed" || + currentObj.type === "location-temp" || + currentObj.type === "location-dest") && + currentObj.locaKey + ) { + const matResponse = await getMaterials(dbConnectionId, { + tableName: hierarchyConfigData.material.tableName, + keyColumn: hierarchyConfigData.material.keyColumn, + locationKeyColumn: hierarchyConfigData.material.locationKeyColumn, + layerColumn: hierarchyConfigData.material.layerColumn, + locaKey: currentObj.locaKey, + }); + if (matResponse.success && matResponse.data) { + const layerColumn = hierarchyConfigData.material.layerColumn || "LOLAYER"; + const sortedMaterials = matResponse.data.sort( + (a: any, b: any) => (b[layerColumn] || 0) - (a[layerColumn] || 0), + ); + setMaterials(sortedMaterials); + } + } + } + + // 마지막 갱신 시간 기록 + setLastRefreshedAt(new Date()); + } + } catch { + // 자동 갱신 실패 시 조용히 무시 (사용자 경험 방해 안 함) + } + }; + + // 10초마다 자동 갱신 + const intervalId = setInterval(silentRefresh, AUTO_REFRESH_INTERVAL); + + // 컴포넌트 언마운트 시 인터벌 정리 + return () => clearInterval(intervalId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [layoutId, isLoading, isRefreshing, selectedObject]); + // Location의 자재 목록 로드 const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => { if (!hierarchyConfig?.material) { @@ -200,7 +422,8 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) }); if (response.success && response.data) { const layerColumn = hierarchyConfig.material.layerColumn || "LOLAYER"; - const sortedMaterials = response.data.sort((a: any, b: any) => (a[layerColumn] || 0) - (b[layerColumn] || 0)); + // 층 내림차순 정렬 (높은 층이 위로) + const sortedMaterials = response.data.sort((a: any, b: any) => (b[layerColumn] || 0) - (a[layerColumn] || 0)); setMaterials(sortedMaterials); } else { setMaterials([]); @@ -334,363 +557,400 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)

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

-

읽기 전용 뷰

+
+

{isExternalMode ? "야드 관제 화면" : "읽기 전용 뷰"}

+ {lastRefreshedAt && ( + + 마지막 갱신: {lastRefreshedAt.toLocaleTimeString("ko-KR")} + + )} +
+
+
+ {/* 전체 화면 버튼 - 외부 업체 모드에서만 표시 */} + {isExternalMode && ( + + )} +
-
{/* 메인 영역 */}
- {/* 좌측: 검색/필터 */} -
-
- {/* 검색 */} -
- -
- - setSearchQuery(e.target.value)} - placeholder="이름, Area, Location 검색..." - className="h-10 pl-9 text-sm" - /> - {searchQuery && ( - - )} + {/* 좌측: 검색/필터 - 외부 모드에서는 숨김 */} + {!isExternalMode && ( +
+
+ {/* 검색 */} +
+ +
+ + setSearchQuery(e.target.value)} + placeholder="이름, Area, Location 검색..." + className="h-10 pl-9 text-sm" + /> + {searchQuery && ( + + )} +
+ + {/* 타입 필터 */} +
+ + +
+ + {/* 필터 초기화 */} + {(searchQuery || filterType !== "all") && ( + + )}
- {/* 타입 필터 */} -
- - -
+ {/* 객체 목록 */} +
+ + {filteredObjects.length === 0 ? ( +
+ {searchQuery ? "검색 결과가 없습니다" : "객체가 없습니다"} +
+ ) : ( + (() => { + // Area 객체가 있는 경우 계층 트리 아코디언 적용 + const areaObjects = filteredObjects.filter((obj) => obj.type === "area"); - {/* 필터 초기화 */} - {(searchQuery || filterType !== "all") && ( - - )} -
+ // Area가 없으면 기존 평면 리스트 유지 + if (areaObjects.length === 0) { + return ( +
+ {filteredObjects.map((obj) => { + let typeLabel = obj.type; + if (obj.type === "location-bed") typeLabel = "베드(BED)"; + else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)"; + else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)"; + else if (obj.type === "location-dest") typeLabel = "지정착지(DES)"; + else if (obj.type === "crane-mobile") typeLabel = "크레인"; + else if (obj.type === "area") typeLabel = "Area"; + else if (obj.type === "rack") typeLabel = "랙"; - {/* 객체 목록 */} -
- - {filteredObjects.length === 0 ? ( -
- {searchQuery ? "검색 결과가 없습니다" : "객체가 없습니다"} -
- ) : ( - (() => { - // Area 객체가 있는 경우 계층 트리 아코디언 적용 - const areaObjects = filteredObjects.filter((obj) => obj.type === "area"); - - // Area가 없으면 기존 평면 리스트 유지 - if (areaObjects.length === 0) { - return ( -
- {filteredObjects.map((obj) => { - let typeLabel = obj.type; - if (obj.type === "location-bed") typeLabel = "베드(BED)"; - else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)"; - else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)"; - else if (obj.type === "location-dest") typeLabel = "지정착지(DES)"; - else if (obj.type === "crane-mobile") typeLabel = "크레인"; - else if (obj.type === "area") typeLabel = "Area"; - else if (obj.type === "rack") typeLabel = "랙"; - - return ( -
handleObjectClick(obj.id)} - className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${ - selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm" - }`} - > -
-
-

{obj.name}

-
- - {typeLabel} + return ( +
handleObjectClick(obj.id)} + className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${ + selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm" + }`} + > +
+
+

{obj.name}

+
+ + {typeLabel} +
+
+ {obj.areaKey && ( +

+ Area: {obj.areaKey} +

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

+ Location: {obj.locaKey} +

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

+ 자재: {obj.materialCount}개 +

+ )} +
-
- {obj.areaKey && ( -

- Area: {obj.areaKey} -

+ ); + })} +
+ ); + } + + // Area가 있는 경우: Area → Location 계층 아코디언 + return ( + + {areaObjects.map((areaObj) => { + const childLocations = filteredObjects.filter( + (obj) => + obj.type !== "area" && + obj.areaKey === areaObj.areaKey && + (obj.parentId === areaObj.id || obj.externalKey === areaObj.externalKey), + ); + + return ( + + +
{ + e.stopPropagation(); + handleObjectClick(areaObj.id); + }} + > +
+ + {areaObj.name} +
+
+ ({childLocations.length}) + +
+
+
+ + {childLocations.length === 0 ? ( +

Location이 없습니다

+ ) : ( +
+ {childLocations.map((locationObj) => ( +
handleObjectClick(locationObj.id)} + className={`cursor-pointer rounded-lg border p-2 transition-all ${ + selectedObject?.id === locationObj.id + ? "border-primary bg-primary/10" + : "hover:border-primary/50" + }`} + > +
+
+ {locationObj.type === "location-stp" ? ( + + ) : ( + + )} + {locationObj.name} +
+ +
+

+ 위치: ({locationObj.position.x.toFixed(1)}, {locationObj.position.z.toFixed(1)}) +

+ {locationObj.locaKey && ( +

+ Location: {locationObj.locaKey} +

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

+ 자재: {locationObj.materialCount}개 +

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

- Location: {obj.locaKey} -

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

- 자재: {obj.materialCount}개 -

- )} -
-
+ + ); })} -
+ ); - } + })() + )} +
+
+ )} - // Area가 있는 경우: Area → Location 계층 아코디언 - return ( - - {areaObjects.map((areaObj) => { - const childLocations = filteredObjects.filter( - (obj) => - obj.type !== "area" && - obj.areaKey === areaObj.areaKey && - (obj.parentId === areaObj.id || obj.externalKey === areaObj.externalKey), - ); - - return ( - - -
{ - e.stopPropagation(); - handleObjectClick(areaObj.id); - }} - > -
- - {areaObj.name} -
-
- ({childLocations.length}) - -
-
-
- - {childLocations.length === 0 ? ( -

Location이 없습니다

- ) : ( -
- {childLocations.map((locationObj) => ( -
handleObjectClick(locationObj.id)} - className={`cursor-pointer rounded-lg border p-2 transition-all ${ - selectedObject?.id === locationObj.id - ? "border-primary bg-primary/10" - : "hover:border-primary/50" - }`} - > -
-
- {locationObj.type === "location-stp" ? ( - - ) : ( - - )} - {locationObj.name} -
- -
-

- 위치: ({locationObj.position.x.toFixed(1)}, {locationObj.position.z.toFixed(1)}) -

- {locationObj.locaKey && ( -

- Location: {locationObj.locaKey} -

- )} - {locationObj.materialCount !== undefined && locationObj.materialCount > 0 && ( -

- 자재: {locationObj.materialCount}개 -

- )} -
- ))} -
- )} -
-
- ); - })} -
- ); - })() + {/* 중앙 + 우측 컨테이너 (전체화면 시 함께 표시) */} +
+ {/* 중앙: 3D 캔버스 */} +
+ {!isLoading && ( + handleObjectClick(placement?.id || null)} + focusOnPlacementId={null} + onCollisionDetected={() => {}} + /> )}
-
- {/* 중앙: 3D 캔버스 */} -
- {!isLoading && ( - handleObjectClick(placement?.id || null)} - focusOnPlacementId={null} - onCollisionDetected={() => {}} - /> - )} -
- - {/* 우측: 정보 패널 */} -
- {selectedObject ? ( -
-
-

상세 정보

-

{selectedObject.name}

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

{selectedObject.type}

+ {/* 우측: 정보 패널 */} +
+ {selectedObject ? ( +
+
+

상세 정보

+

{selectedObject.name}

- {selectedObject.areaKey && ( -
- -

{selectedObject.areaKey}

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

{selectedObject.locaKey}

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

{selectedObject.materialCount}개

-
- )} -
- {/* 자재 목록 (Location인 경우) - 테이블 형태 */} - {(selectedObject.type === "location-bed" || - selectedObject.type === "location-stp" || - selectedObject.type === "location-temp" || - selectedObject.type === "location-dest") && ( -
- {loadingMaterials ? ( -
- + {/* 기본 정보 */} +
+
+ +

{selectedObject.type}

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

{selectedObject.areaKey}

- ) : materials.length === 0 ? ( -
- {externalDbConnectionId ? "자재가 없습니다" : "외부 DB 연결이 설정되지 않았습니다"} + )} + {selectedObject.locaKey && ( +
+ +

{selectedObject.locaKey}

- ) : ( -
- - {/* 테이블 형태로 전체 조회 */} -
- - - - - {(hierarchyConfig?.material?.displayColumns || []).map((colConfig: any) => ( - - ))} - - - - {materials.map((material, index) => { - const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER"; - const displayColumns = hierarchyConfig?.material?.displayColumns || []; - return ( - - - {displayColumns.map((colConfig: any) => ( - - ))} - - ); - })} - -
- {colConfig.label} -
- {material[layerColumn]}단 - - {material[colConfig.column] || "-"} -
-
+ )} + {selectedObject.materialCount !== undefined && selectedObject.materialCount > 0 && ( +
+ +

{selectedObject.materialCount}개

)}
- )} -
- ) : ( -
-

객체를 선택하세요

-
+ + {/* 자재 목록 (Location인 경우) - 테이블 형태 */} + {(selectedObject.type === "location-bed" || + selectedObject.type === "location-stp" || + selectedObject.type === "location-temp" || + selectedObject.type === "location-dest") && ( +
+ {loadingMaterials ? ( +
+ +
+ ) : materials.length === 0 ? ( +
+ {externalDbConnectionId ? "자재가 없습니다" : "외부 DB 연결이 설정되지 않았습니다"} +
+ ) : ( +
+ + {/* 테이블 형태로 전체 조회 */} +
+ + + + + {(hierarchyConfig?.material?.displayColumns || []).map((colConfig: any) => ( + + ))} + + + + {materials.map((material, index) => { + const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER"; + const displayColumns = hierarchyConfig?.material?.displayColumns || []; + return ( + + + {displayColumns.map((colConfig: any) => ( + + ))} + + ); + })} + +
+ {colConfig.label} +
+ {material[layerColumn]}단 + + {material[colConfig.column] || "-"} +
+
+
+ )} +
+ )} +
+ ) : ( +
+

객체를 선택하세요

+
+ )} +
+ + {/* 풀스크린 모드일 때 종료 버튼 */} + {isFullscreen && ( + )}
diff --git a/frontend/components/report/designer/QueryManager.tsx b/frontend/components/report/designer/QueryManager.tsx index e9ccb813..39b0d173 100644 --- a/frontend/components/report/designer/QueryManager.tsx +++ b/frontend/components/report/designer/QueryManager.tsx @@ -274,15 +274,15 @@ export function QueryManager() {
- -
+ > + + +
{/* 쿼리 이름 */}
diff --git a/frontend/components/report/designer/ReportPreviewModal.tsx b/frontend/components/report/designer/ReportPreviewModal.tsx index 2ff70c73..ded27f37 100644 --- a/frontend/components/report/designer/ReportPreviewModal.tsx +++ b/frontend/components/report/designer/ReportPreviewModal.tsx @@ -486,11 +486,11 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) } } return component; - }), - ); + }), + ); return { ...page, components: componentsWithBase64 }; - }), - ); + }), + ); // 쿼리 결과 수집 const queryResults: Record[] }> = {};