From 2487c79a61fba3f3543e707b88df64afe18c87d2 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 19 Dec 2025 13:45:14 +0900 Subject: [PATCH 01/18] =?UTF-8?q?fix:=20=EB=A9=94=EB=89=B4=20=EB=B3=B5?= =?UTF-8?q?=EC=82=AC=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20-=20FK=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0=20=EB=B0=8F=20=EC=84=B1?= =?UTF-8?q?=EB=8A=A5=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - numbering_rules FK 에러 해결 (menu_objid NULL 설정) - category_column_mapping FK 에러 해결 (삭제 후 재복사) - 채번규칙 매핑 보완 로직 추가 (화면에서 참조하는 채번규칙을 이름으로 찾아 매핑) - 기존 채번규칙/카테고리 매핑의 menu_objid 갱신 로직 추가 - N+1 쿼리 최적화 (배치 조회/삽입으로 변경) - 메뉴 삭제: N개 쿼리 → 1개 - 화면 할당/플로우 수집: N개 쿼리 → 1개 - 화면 정의 조회: N개 쿼리 → 1개 - 레이아웃 삽입: N개 쿼리 → 화면당 1개 - 채번규칙/카테고리 매핑 업데이트: CASE WHEN 배치 처리 - 예상 성능 개선: ~10배 --- backend-node/src/services/menuCopyService.ts | 1059 +++++++++++++----- 1 file changed, 775 insertions(+), 284 deletions(-) diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 5c4fde7f..bc80569f 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -247,7 +247,9 @@ export class MenuCopyService { typeof screenId === "number" ? screenId : parseInt(screenId); if (!isNaN(numId)) { referenced.push(numId); - logger.debug(` 📑 탭 컴포넌트에서 화면 참조 발견: ${numId} (탭: ${tab.label || tab.id})`); + logger.debug( + ` 📑 탭 컴포넌트에서 화면 참조 발견: ${numId} (탭: ${tab.label || tab.id})` + ); } } } @@ -257,7 +259,9 @@ export class MenuCopyService { if (props?.componentConfig?.leftScreenId) { const leftScreenId = props.componentConfig.leftScreenId; const numId = - typeof leftScreenId === "number" ? leftScreenId : parseInt(leftScreenId); + typeof leftScreenId === "number" + ? leftScreenId + : parseInt(leftScreenId); if (!isNaN(numId) && numId > 0) { referenced.push(numId); logger.debug(` 📐 분할 패널 좌측 화면 참조 발견: ${numId}`); @@ -267,7 +271,9 @@ export class MenuCopyService { if (props?.componentConfig?.rightScreenId) { const rightScreenId = props.componentConfig.rightScreenId; const numId = - typeof rightScreenId === "number" ? rightScreenId : parseInt(rightScreenId); + typeof rightScreenId === "number" + ? rightScreenId + : parseInt(rightScreenId); if (!isNaN(numId) && numId > 0) { referenced.push(numId); logger.debug(` 📐 분할 패널 우측 화면 참조 발견: ${numId}`); @@ -293,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}개`); @@ -359,37 +363,62 @@ export class MenuCopyService { logger.info(`🔄 플로우 수집 시작: ${screenIds.size}개 화면`); const flowIds = new Set(); - const flowDetails: Array<{ flowId: number; flowName: string; screenId: number }> = []; + const flowDetails: Array<{ + flowId: number; + flowName: string; + 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"; - - 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}"`); - } + for (const layout of layoutsResult.rows) { + const props = layout.properties; + const screenId = layout.screen_id; + + // 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}` + ); } } } @@ -400,7 +429,7 @@ export class MenuCopyService { } else { logger.info(`📭 수집된 플로우 없음 (화면에 플로우 참조가 없음)`); } - + return flowIds; } @@ -462,7 +491,13 @@ export class MenuCopyService { const updated = JSON.parse(JSON.stringify(properties)); // 재귀적으로 객체/배열 탐색 - this.recursiveUpdateReferences(updated, screenIdMap, flowIdMap, "", numberingRuleIdMap); + this.recursiveUpdateReferences( + updated, + screenIdMap, + flowIdMap, + "", + numberingRuleIdMap + ); return updated; } @@ -539,13 +574,24 @@ export class MenuCopyService { } // numberingRuleId 매핑 (문자열) - if (key === "numberingRuleId" && numberingRuleIdMap && typeof value === "string" && value) { + if ( + key === "numberingRuleId" && + numberingRuleIdMap && + typeof value === "string" && + value + ) { const newRuleId = numberingRuleIdMap.get(value); if (newRuleId) { obj[key] = newRuleId; logger.info( ` 🔗 채번규칙 참조 업데이트 (${currentPath}): ${value} → ${newRuleId}` ); + } else { + // 매핑이 없는 채번규칙은 빈 값으로 설정 (다른 회사 채번규칙 참조 방지) + logger.warn( + ` ⚠️ 채번규칙 매핑 없음 (${currentPath}): ${value} → 빈 값으로 설정` + ); + obj[key] = ""; } } @@ -590,11 +636,15 @@ export class MenuCopyService { } const sourceMenu = sourceMenuResult.rows[0]; - const isRootMenu = !sourceMenu.parent_obj_id || sourceMenu.parent_obj_id === 0; + const isRootMenu = + !sourceMenu.parent_obj_id || sourceMenu.parent_obj_id === 0; // 2. 대상 회사에 같은 원본에서 복사된 메뉴 찾기 (source_menu_objid로 정확히 매칭) // 최상위/하위 구분 없이 모든 복사본 검색 - const existingMenuResult = await client.query<{ objid: number; parent_obj_id: number | null }>( + const existingMenuResult = await client.query<{ + objid: number; + parent_obj_id: number | null; + }>( `SELECT objid, parent_obj_id FROM menu_info WHERE source_menu_objid = $1 @@ -608,8 +658,9 @@ export class MenuCopyService { } const existingMenuObjid = existingMenuResult.rows[0].objid; - const existingIsRoot = !existingMenuResult.rows[0].parent_obj_id || - existingMenuResult.rows[0].parent_obj_id === 0; + const existingIsRoot = + !existingMenuResult.rows[0].parent_obj_id || + existingMenuResult.rows[0].parent_obj_id === 0; logger.info( `🔍 기존 복사본 발견: ${sourceMenu.menu_name_kor} (원본: ${sourceMenuObjid}, 복사본: ${existingMenuObjid}, 최상위: ${existingIsRoot})` @@ -649,10 +700,14 @@ export class MenuCopyService { WHERE screen_id = ANY($1) AND company_code = $2`, [screenIds, targetCompanyCode] ); - const sharedScreenIds = new Set(sharedScreensResult.rows.map(r => r.screen_id)); + const sharedScreenIds = new Set( + sharedScreensResult.rows.map((r) => r.screen_id) + ); // 공유되지 않은 화면만 삭제 - const screensToDelete = screenIds.filter(id => !sharedScreenIds.has(id)); + const screensToDelete = screenIds.filter( + (id) => !sharedScreenIds.has(id) + ); if (screensToDelete.length > 0) { // 레이아웃 삭제 @@ -662,8 +717,8 @@ export class MenuCopyService { ); // 화면 정의 삭제 - await client.query( - `DELETE FROM screen_definitions + await client.query( + `DELETE FROM screen_definitions WHERE screen_id = ANY($1) AND company_code = $2`, [screensToDelete, targetCompanyCode] ); @@ -671,7 +726,9 @@ export class MenuCopyService { } if (sharedScreenIds.size > 0) { - logger.info(` ♻️ 공유 화면 유지: ${sharedScreenIds.size}개 (다른 메뉴에서 사용 중)`); + logger.info( + ` ♻️ 공유 화면 유지: ${sharedScreenIds.size}개 (다른 메뉴에서 사용 중)` + ); } } @@ -681,11 +738,43 @@ export class MenuCopyService { ]); logger.info(` ✅ 메뉴 권한 삭제 완료`); - // 5-4. 메뉴 삭제 (역순: 하위 메뉴부터) - // 주의: 채번 규칙과 카테고리 설정은 회사마다 고유하므로 삭제하지 않음 - for (let i = existingMenus.length - 1; i >= 0; i--) { - await client.query(`DELETE FROM menu_info WHERE objid = $1`, [ - existingMenus[i].objid, + // 5-4. 채번 규칙의 menu_objid 참조 해제 (삭제하지 않고 연결만 끊음) + // 채번 규칙은 회사의 핵심 업무 데이터이므로 보존해야 함 + const updatedNumberingRules = await client.query( + `UPDATE numbering_rules + SET menu_objid = NULL + WHERE menu_objid = ANY($1) AND company_code = $2 + RETURNING rule_id`, + [existingMenuIds, targetCompanyCode] + ); + if (updatedNumberingRules.rowCount && updatedNumberingRules.rowCount > 0) { + logger.info( + ` ✅ 채번 규칙 메뉴 연결 해제: ${updatedNumberingRules.rowCount}개 (데이터 보존됨)` + ); + } + + // 5-5. 카테고리 매핑 삭제 (menu_objid가 NOT NULL이므로 NULL 설정 불가) + // 카테고리 매핑은 메뉴와 강하게 연결되어 있으므로 함께 삭제 + const deletedCategoryMappings = await client.query( + `DELETE FROM category_column_mapping + WHERE menu_objid = ANY($1) AND company_code = $2 + RETURNING mapping_id`, + [existingMenuIds, targetCompanyCode] + ); + if ( + deletedCategoryMappings.rowCount && + deletedCategoryMappings.rowCount > 0 + ) { + logger.info( + ` ✅ 카테고리 매핑 삭제 완료: ${deletedCategoryMappings.rowCount}개` + ); + } + + // 5-6. 메뉴 삭제 (배치 삭제 - 하위 메뉴부터 삭제를 위해 역순 정렬된 ID 사용) + // 외래키 제약이 해제되었으므로 배치 삭제 가능 + if (existingMenuIds.length > 0) { + await client.query(`DELETE FROM menu_info WHERE objid = ANY($1)`, [ + existingMenuIds, ]); } logger.info(` ✅ 메뉴 삭제 완료: ${existingMenus.length}개`); @@ -794,10 +883,10 @@ export class MenuCopyService { const ruleResult = await this.copyNumberingRulesWithMap( menuObjids, menuIdMap, // 실제 생성된 메뉴 ID 사용 - targetCompanyCode, - userId, - client - ); + targetCompanyCode, + userId, + client + ); copiedNumberingRules = ruleResult.copiedCount; numberingRuleIdMap = ruleResult.ruleIdMap; } @@ -840,6 +929,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( @@ -948,17 +1051,21 @@ export class MenuCopyService { `SELECT * FROM flow_definition WHERE id = ANY($1)`, [flowIdArray] ); - const flowDefMap = new Map(allFlowDefsResult.rows.map(f => [f.id, f])); + const flowDefMap = new Map(allFlowDefsResult.rows.map((f) => [f.id, f])); // 2) 대상 회사의 기존 플로우 한 번에 조회 (이름+테이블 기준) - const flowNames = allFlowDefsResult.rows.map(f => f.name); - const existingFlowsResult = await client.query<{ id: number; name: string; table_name: string }>( + const flowNames = allFlowDefsResult.rows.map((f) => f.name); + const existingFlowsResult = await client.query<{ + id: number; + name: string; + table_name: string; + }>( `SELECT id, name, table_name FROM flow_definition WHERE company_code = $1 AND name = ANY($2)`, [targetCompanyCode, flowNames] ); const existingFlowMap = new Map( - existingFlowsResult.rows.map(f => [`${f.name}|${f.table_name}`, f.id]) + existingFlowsResult.rows.map((f) => [`${f.name}|${f.table_name}`, f.id]) ); // 3) 복사가 필요한 플로우 ID 목록 @@ -967,16 +1074,18 @@ export class MenuCopyService { for (const originalFlowId of flowIdArray) { const flowDef = flowDefMap.get(originalFlowId); if (!flowDef) { - logger.warn(`⚠️ 플로우를 찾을 수 없음: id=${originalFlowId}`); - continue; - } + logger.warn(`⚠️ 플로우를 찾을 수 없음: id=${originalFlowId}`); + continue; + } const key = `${flowDef.name}|${flowDef.table_name}`; const existingId = existingFlowMap.get(key); if (existingId) { flowIdMap.set(originalFlowId, existingId); - logger.info(` ♻️ 기존 플로우 재사용: ${originalFlowId} → ${existingId} (${flowDef.name})`); + logger.info( + ` ♻️ 기존 플로우 재사용: ${originalFlowId} → ${existingId} (${flowDef.name})` + ); } else { flowsToCopy.push(flowDef); } @@ -985,17 +1094,26 @@ export class MenuCopyService { // 4) 새 플로우 복사 (배치 처리) if (flowsToCopy.length > 0) { // 배치 INSERT로 플로우 생성 - const flowValues = flowsToCopy.map((f, i) => - `($${i * 8 + 1}, $${i * 8 + 2}, $${i * 8 + 3}, $${i * 8 + 4}, $${i * 8 + 5}, $${i * 8 + 6}, $${i * 8 + 7}, $${i * 8 + 8})` - ).join(", "); - - const flowParams = flowsToCopy.flatMap(f => [ - f.name, f.description, f.table_name, f.is_active, - targetCompanyCode, userId, f.db_source_type, f.db_connection_id + const flowValues = flowsToCopy + .map( + (f, i) => + `($${i * 8 + 1}, $${i * 8 + 2}, $${i * 8 + 3}, $${i * 8 + 4}, $${i * 8 + 5}, $${i * 8 + 6}, $${i * 8 + 7}, $${i * 8 + 8})` + ) + .join(", "); + + const flowParams = flowsToCopy.flatMap((f) => [ + f.name, + f.description, + f.table_name, + f.is_active, + targetCompanyCode, + userId, + f.db_source_type, + f.db_connection_id, ]); const newFlowsResult = await client.query<{ id: number }>( - `INSERT INTO flow_definition ( + `INSERT INTO flow_definition ( name, description, table_name, is_active, company_code, created_by, db_source_type, db_connection_id ) VALUES ${flowValues} @@ -1007,11 +1125,13 @@ export class MenuCopyService { flowsToCopy.forEach((flowDef, index) => { const newFlowId = newFlowsResult.rows[index].id; flowIdMap.set(flowDef.id, newFlowId); - logger.info(` ✅ 플로우 신규 복사: ${flowDef.id} → ${newFlowId} (${flowDef.name})`); + logger.info( + ` ✅ 플로우 신규 복사: ${flowDef.id} → ${newFlowId} (${flowDef.name})` + ); }); // 5) 스텝 및 연결 복사 (복사된 플로우만) - const originalFlowIdsToCopy = flowsToCopy.map(f => f.id); + const originalFlowIdsToCopy = flowsToCopy.map((f) => f.id); // 모든 스텝 한 번에 조회 const allStepsResult = await client.query( @@ -1030,7 +1150,7 @@ export class MenuCopyService { // 스텝 복사 (플로우별) const allStepIdMaps = new Map>(); // originalFlowId -> stepIdMap - + for (const originalFlowId of originalFlowIdsToCopy) { const newFlowId = flowIdMap.get(originalFlowId)!; const steps = stepsByFlow.get(originalFlowId) || []; @@ -1038,15 +1158,31 @@ export class MenuCopyService { if (steps.length > 0) { // 배치 INSERT로 스텝 생성 - const stepValues = steps.map((_, i) => - `($${i * 17 + 1}, $${i * 17 + 2}, $${i * 17 + 3}, $${i * 17 + 4}, $${i * 17 + 5}, $${i * 17 + 6}, $${i * 17 + 7}, $${i * 17 + 8}, $${i * 17 + 9}, $${i * 17 + 10}, $${i * 17 + 11}, $${i * 17 + 12}, $${i * 17 + 13}, $${i * 17 + 14}, $${i * 17 + 15}, $${i * 17 + 16}, $${i * 17 + 17})` - ).join(", "); + const stepValues = steps + .map( + (_, i) => + `($${i * 17 + 1}, $${i * 17 + 2}, $${i * 17 + 3}, $${i * 17 + 4}, $${i * 17 + 5}, $${i * 17 + 6}, $${i * 17 + 7}, $${i * 17 + 8}, $${i * 17 + 9}, $${i * 17 + 10}, $${i * 17 + 11}, $${i * 17 + 12}, $${i * 17 + 13}, $${i * 17 + 14}, $${i * 17 + 15}, $${i * 17 + 16}, $${i * 17 + 17})` + ) + .join(", "); - const stepParams = steps.flatMap(s => [ - newFlowId, s.step_name, s.step_order, s.condition_json, - s.color, s.position_x, s.position_y, s.table_name, s.move_type, - s.status_column, s.status_value, s.target_table, s.field_mappings, - s.required_fields, s.integration_type, s.integration_config, s.display_config + const stepParams = steps.flatMap((s) => [ + newFlowId, + s.step_name, + s.step_order, + s.condition_json, + s.color, + s.position_x, + s.position_y, + s.table_name, + s.move_type, + s.status_column, + s.status_value, + s.target_table, + s.field_mappings, + s.required_fields, + s.integration_type, + s.integration_config, + s.display_config, ]); const newStepsResult = await client.query<{ id: number }>( @@ -1064,7 +1200,9 @@ export class MenuCopyService { stepIdMap.set(step.id, newStepsResult.rows[index].id); }); - logger.info(` ↳ 플로우 ${originalFlowId}: 스텝 ${steps.length}개 복사`); + logger.info( + ` ↳ 플로우 ${originalFlowId}: 스텝 ${steps.length}개 복사` + ); } allStepIdMaps.set(originalFlowId, stepIdMap); @@ -1077,14 +1215,19 @@ export class MenuCopyService { ); // 연결 복사 (배치 INSERT) - const connectionsToInsert: { newFlowId: number; newFromStepId: number; newToStepId: number; label: string }[] = []; + const connectionsToInsert: { + newFlowId: number; + newFromStepId: number; + newToStepId: number; + label: string; + }[] = []; for (const conn of allConnectionsResult.rows) { const stepIdMap = allStepIdMaps.get(conn.flow_definition_id); if (!stepIdMap) continue; - const newFromStepId = stepIdMap.get(conn.from_step_id); - const newToStepId = stepIdMap.get(conn.to_step_id); + const newFromStepId = stepIdMap.get(conn.from_step_id); + const newToStepId = stepIdMap.get(conn.to_step_id); const newFlowId = flowIdMap.get(conn.flow_definition_id); if (newFromStepId && newToStepId && newFlowId) { @@ -1092,26 +1235,32 @@ export class MenuCopyService { newFlowId, newFromStepId, newToStepId, - label: conn.label || "" + label: conn.label || "", }); } } if (connectionsToInsert.length > 0) { - const connValues = connectionsToInsert.map((_, i) => - `($${i * 4 + 1}, $${i * 4 + 2}, $${i * 4 + 3}, $${i * 4 + 4})` - ).join(", "); + const connValues = connectionsToInsert + .map( + (_, i) => + `($${i * 4 + 1}, $${i * 4 + 2}, $${i * 4 + 3}, $${i * 4 + 4})` + ) + .join(", "); - const connParams = connectionsToInsert.flatMap(c => [ - c.newFlowId, c.newFromStepId, c.newToStepId, c.label + const connParams = connectionsToInsert.flatMap((c) => [ + c.newFlowId, + c.newFromStepId, + c.newToStepId, + c.label, ]); - await client.query( - `INSERT INTO flow_step_connection ( + await client.query( + `INSERT INTO flow_step_connection ( flow_definition_id, from_step_id, to_step_id, label ) VALUES ${connValues}`, connParams - ); + ); logger.info(` ↳ 연결 ${connectionsToInsert.length}개 복사`); } @@ -1148,6 +1297,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; @@ -1158,35 +1338,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; @@ -1202,14 +1367,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}` ); } } @@ -1230,10 +1396,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( @@ -1347,10 +1512,7 @@ export class MenuCopyService { }); } } catch (error: any) { - logger.error( - `❌ 화면 처리 실패: screen_id=${originalScreenId}`, - error - ); + logger.error(`❌ 화면 처리 실패: screen_id=${originalScreenId}`, error); throw error; } } @@ -1384,36 +1546,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, @@ -1427,8 +1592,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 ); } @@ -1588,7 +1764,7 @@ export class MenuCopyService { const parentMenu = parentMenuResult.rows[0]; // 대상 회사에서 같은 이름 + 같은 원본 회사에서 복사된 메뉴 찾기 - // source_menu_objid가 있는 메뉴(복사된 메뉴)만 대상으로, + // source_menu_objid가 있는 메뉴(복사된 메뉴)만 대상으로, // 해당 source_menu_objid의 원본 메뉴가 같은 회사(sourceCompanyCode)에 속하는지 확인 const sameNameResult = await client.query<{ objid: number }>( `SELECT m.objid FROM menu_info m @@ -1643,7 +1819,10 @@ export class MenuCopyService { try { // 0. 이미 복사된 메뉴가 있는지 확인 (고아 메뉴 재연결용) // 1차: source_menu_objid로 검색 - let existingCopyResult = await client.query<{ objid: number; parent_obj_id: number | null }>( + let existingCopyResult = await client.query<{ + objid: number; + parent_obj_id: number | null; + }>( `SELECT objid, parent_obj_id FROM menu_info WHERE source_menu_objid = $1 AND company_code = $2 LIMIT 1`, @@ -1652,7 +1831,10 @@ export class MenuCopyService { // 2차: source_menu_objid가 없는 기존 복사본 (이름 + 메뉴타입으로 검색) - 호환성 유지 if (existingCopyResult.rows.length === 0 && menu.menu_name_kor) { - existingCopyResult = await client.query<{ objid: number; parent_obj_id: number | null }>( + existingCopyResult = await client.query<{ + objid: number; + parent_obj_id: number | null; + }>( `SELECT objid, parent_obj_id FROM menu_info WHERE menu_name_kor = $1 AND company_code = $2 @@ -1733,7 +1915,9 @@ export class MenuCopyService { // === 신규 메뉴 복사 === // 미리 할당된 ID가 있으면 사용, 없으면 새로 생성 - const newObjId = preAllocatedMenuIdMap?.get(menu.objid) ?? await this.getNextMenuObjid(client); + const newObjId = + preAllocatedMenuIdMap?.get(menu.objid) ?? + (await this.getNextMenuObjid(client)); // source_menu_objid 저장: 모든 복사된 메뉴에 원본 ID 저장 (추적용) const sourceMenuObjid = menu.objid; @@ -1807,15 +1991,15 @@ export class MenuCopyService { // === 최적화: 배치 조회 === // 1. 모든 원본 메뉴의 화면 할당 한 번에 조회 - const menuObjids = menus.map(m => m.objid); - const companyCodes = [...new Set(menus.map(m => m.company_code))]; + const menuObjids = menus.map((m) => m.objid); + const companyCodes = [...new Set(menus.map((m) => m.company_code))]; const allAssignmentsResult = await client.query<{ menu_objid: number; - screen_id: number; - display_order: number; - is_active: string; - }>( + screen_id: number; + display_order: number; + is_active: string; + }>( `SELECT menu_objid, screen_id, display_order, is_active FROM screen_menu_assignments WHERE menu_objid = ANY($1) AND company_code = ANY($2)`, @@ -1837,36 +2021,45 @@ export class MenuCopyService { for (const assignment of allAssignmentsResult.rows) { const newMenuObjid = menuIdMap.get(assignment.menu_objid); - const newScreenId = screenIdMap.get(assignment.screen_id); + const newScreenId = screenIdMap.get(assignment.screen_id); if (!newMenuObjid || !newScreenId) { if (!newScreenId) { - logger.warn(`⚠️ 화면 ID 매핑 없음: screen_id=${assignment.screen_id}`); - } - continue; + logger.warn( + `⚠️ 화면 ID 매핑 없음: screen_id=${assignment.screen_id}` + ); } + continue; + } validAssignments.push({ newScreenId, newMenuObjid, displayOrder: assignment.display_order, - isActive: assignment.is_active + isActive: assignment.is_active, }); } // 3. 배치 INSERT if (validAssignments.length > 0) { - const assignmentValues = validAssignments.map((_, i) => - `($${i * 6 + 1}, $${i * 6 + 2}, $${i * 6 + 3}, $${i * 6 + 4}, $${i * 6 + 5}, $${i * 6 + 6})` - ).join(", "); + const assignmentValues = validAssignments + .map( + (_, i) => + `($${i * 6 + 1}, $${i * 6 + 2}, $${i * 6 + 3}, $${i * 6 + 4}, $${i * 6 + 5}, $${i * 6 + 6})` + ) + .join(", "); - const assignmentParams = validAssignments.flatMap(a => [ - a.newScreenId, a.newMenuObjid, targetCompanyCode, - a.displayOrder, a.isActive, "system" + const assignmentParams = validAssignments.flatMap((a) => [ + a.newScreenId, + a.newMenuObjid, + targetCompanyCode, + a.displayOrder, + a.isActive, + "system", ]); - await client.query( - `INSERT INTO screen_menu_assignments ( + await client.query( + `INSERT INTO screen_menu_assignments ( screen_id, menu_objid, company_code, display_order, is_active, created_by ) VALUES ${assignmentValues}`, assignmentParams @@ -1906,30 +2099,42 @@ export class MenuCopyService { } // 2. 대상 회사에 이미 존재하는 카테고리 한 번에 조회 - const categoryCodes = allCategoriesResult.rows.map(c => c.category_code); + const categoryCodes = allCategoriesResult.rows.map((c) => c.category_code); const existingCategoriesResult = await client.query( `SELECT category_code FROM code_category WHERE category_code = ANY($1) AND company_code = $2`, [categoryCodes, targetCompanyCode] ); - const existingCategoryCodes = new Set(existingCategoriesResult.rows.map(c => c.category_code)); + const existingCategoryCodes = new Set( + existingCategoriesResult.rows.map((c) => c.category_code) + ); // 3. 복사할 카테고리 필터링 const categoriesToCopy = allCategoriesResult.rows.filter( - c => !existingCategoryCodes.has(c.category_code) + (c) => !existingCategoryCodes.has(c.category_code) ); // 4. 배치 INSERT로 카테고리 복사 if (categoriesToCopy.length > 0) { - const categoryValues = categoriesToCopy.map((_, i) => - `($${i * 9 + 1}, $${i * 9 + 2}, $${i * 9 + 3}, $${i * 9 + 4}, $${i * 9 + 5}, $${i * 9 + 6}, NOW(), $${i * 9 + 7}, $${i * 9 + 8}, $${i * 9 + 9})` - ).join(", "); + const categoryValues = categoriesToCopy + .map( + (_, i) => + `($${i * 9 + 1}, $${i * 9 + 2}, $${i * 9 + 3}, $${i * 9 + 4}, $${i * 9 + 5}, $${i * 9 + 6}, NOW(), $${i * 9 + 7}, $${i * 9 + 8}, $${i * 9 + 9})` + ) + .join(", "); - const categoryParams = categoriesToCopy.flatMap(c => { + const categoryParams = categoriesToCopy.flatMap((c) => { const newMenuObjid = menuIdMap.get(c.menu_objid); return [ - c.category_code, c.category_name, c.category_name_eng, c.description, - c.sort_order, c.is_active, userId, targetCompanyCode, newMenuObjid + c.category_code, + c.category_name, + c.category_name_eng, + c.description, + c.sort_order, + c.is_active, + userId, + targetCompanyCode, + newMenuObjid, ]; }); @@ -1963,25 +2168,36 @@ export class MenuCopyService { [Array.from(menuIdMap.values()), targetCompanyCode] ); const existingCodeKeys = new Set( - existingCodesResult.rows.map(c => `${c.code_category}|${c.code_value}`) + existingCodesResult.rows.map((c) => `${c.code_category}|${c.code_value}`) ); // 7. 복사할 코드 필터링 const codesToCopy = allCodesResult.rows.filter( - c => !existingCodeKeys.has(`${c.code_category}|${c.code_value}`) + (c) => !existingCodeKeys.has(`${c.code_category}|${c.code_value}`) ); // 8. 배치 INSERT로 코드 복사 if (codesToCopy.length > 0) { - const codeValues = codesToCopy.map((_, i) => - `($${i * 10 + 1}, $${i * 10 + 2}, $${i * 10 + 3}, $${i * 10 + 4}, $${i * 10 + 5}, $${i * 10 + 6}, $${i * 10 + 7}, NOW(), $${i * 10 + 8}, $${i * 10 + 9}, $${i * 10 + 10})` - ).join(", "); + const codeValues = codesToCopy + .map( + (_, i) => + `($${i * 10 + 1}, $${i * 10 + 2}, $${i * 10 + 3}, $${i * 10 + 4}, $${i * 10 + 5}, $${i * 10 + 6}, $${i * 10 + 7}, NOW(), $${i * 10 + 8}, $${i * 10 + 9}, $${i * 10 + 10})` + ) + .join(", "); - const codeParams = codesToCopy.flatMap(c => { + const codeParams = codesToCopy.flatMap((c) => { const newMenuObjid = menuIdMap.get(c.menu_objid); return [ - c.code_category, c.code_value, c.code_name, c.code_name_eng, c.description, - c.sort_order, c.is_active, userId, targetCompanyCode, newMenuObjid + c.code_category, + c.code_value, + c.code_name, + c.code_name_eng, + c.description, + c.sort_order, + c.is_active, + userId, + targetCompanyCode, + newMenuObjid, ]; }); @@ -1997,10 +2213,107 @@ export class MenuCopyService { logger.info(` ✅ 코드 ${copiedCodes}개 복사`); } - logger.info(`✅ 코드 카테고리 + 코드 복사 완료: 카테고리 ${copiedCategories}개, 코드 ${copiedCodes}개`); + logger.info( + `✅ 코드 카테고리 + 코드 복사 완료: 카테고리 ${copiedCategories}개, 코드 ${copiedCodes}개` + ); 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 참조 업데이트에 사용됨 @@ -2032,30 +2345,43 @@ export class MenuCopyService { } // 2. 대상 회사에 이미 존재하는 채번 규칙 한 번에 조회 - const ruleIds = allRulesResult.rows.map(r => r.rule_id); + const ruleIds = allRulesResult.rows.map((r) => r.rule_id); const existingRulesResult = await client.query( `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)); + const existingRuleIds = new Set( + existingRulesResult.rows.map((r) => r.rule_id) + ); // 3. 복사할 규칙과 스킵할 규칙 분류 const rulesToCopy: any[] = []; const originalToNewRuleMap: Array<{ original: string; new: string }> = []; + // 기존 규칙 중 menu_objid 업데이트가 필요한 규칙들 + const rulesToUpdate: Array<{ ruleId: string; newMenuObjid: number }> = []; + for (const rule of allRulesResult.rows) { if (existingRuleIds.has(rule.rule_id)) { // 기존 규칙은 동일한 ID로 매핑 ruleIdMap.set(rule.rule_id, rule.rule_id); - logger.info(` ♻️ 채번규칙 이미 존재 (스킵): ${rule.rule_id}`); + + // 새 메뉴 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(/^[^_]*_/, '') + 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 }); @@ -2064,16 +2390,29 @@ export class MenuCopyService { // 4. 배치 INSERT로 채번 규칙 복사 if (rulesToCopy.length > 0) { - const ruleValues = rulesToCopy.map((_, i) => - `($${i * 13 + 1}, $${i * 13 + 2}, $${i * 13 + 3}, $${i * 13 + 4}, $${i * 13 + 5}, $${i * 13 + 6}, $${i * 13 + 7}, $${i * 13 + 8}, $${i * 13 + 9}, NOW(), $${i * 13 + 10}, $${i * 13 + 11}, $${i * 13 + 12}, $${i * 13 + 13})` - ).join(", "); + const ruleValues = rulesToCopy + .map( + (_, i) => + `($${i * 13 + 1}, $${i * 13 + 2}, $${i * 13 + 3}, $${i * 13 + 4}, $${i * 13 + 5}, $${i * 13 + 6}, $${i * 13 + 7}, $${i * 13 + 8}, $${i * 13 + 9}, NOW(), $${i * 13 + 10}, $${i * 13 + 11}, $${i * 13 + 12}, $${i * 13 + 13})` + ) + .join(", "); - const ruleParams = rulesToCopy.flatMap(r => { + const ruleParams = rulesToCopy.flatMap((r) => { const newMenuObjid = menuIdMap.get(r.menu_objid); return [ - r.newRuleId, r.rule_name, r.description, r.separator, r.reset_period, - 0, r.table_name, r.column_name, targetCompanyCode, - userId, newMenuObjid, r.scope_type, null + r.newRuleId, + r.rule_name, + r.description, + r.separator, + r.reset_period, + 0, + r.table_name, + r.column_name, + targetCompanyCode, + userId, + newMenuObjid, + r.scope_type, + null, ]; }); @@ -2088,9 +2427,31 @@ export class MenuCopyService { copiedCount = rulesToCopy.length; logger.info(` ✅ 채번 규칙 ${copiedCount}개 복사`); + } - // 5. 모든 원본 파트 한 번에 조회 - const originalRuleIds = rulesToCopy.map(r => r.rule_id); + // 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 WHERE rule_id = ANY($1) ORDER BY rule_id, part_order`, @@ -2100,15 +2461,25 @@ export class MenuCopyService { // 6. 배치 INSERT로 채번 규칙 파트 복사 if (allPartsResult.rows.length > 0) { // 원본 rule_id -> 새 rule_id 매핑 - const ruleMapping = new Map(originalToNewRuleMap.map(m => [m.original, m.new])); + const ruleMapping = new Map( + originalToNewRuleMap.map((m) => [m.original, m.new]) + ); - const partValues = allPartsResult.rows.map((_, i) => - `($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, $${i * 7 + 7}, NOW())` - ).join(", "); + const partValues = allPartsResult.rows + .map( + (_, i) => + `($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, $${i * 7 + 7}, NOW())` + ) + .join(", "); - const partParams = allPartsResult.rows.flatMap(p => [ - ruleMapping.get(p.rule_id), p.part_order, p.part_type, p.generation_method, - p.auto_config, p.manual_config, targetCompanyCode + const partParams = allPartsResult.rows.flatMap((p) => [ + ruleMapping.get(p.rule_id), + p.part_order, + p.part_type, + p.generation_method, + p.auto_config, + p.manual_config, + targetCompanyCode, ]); await client.query( @@ -2123,7 +2494,9 @@ export class MenuCopyService { } } - logger.info(`✅ 채번 규칙 복사 완료: ${copiedCount}개, 매핑: ${ruleIdMap.size}개`); + logger.info( + `✅ 채번 규칙 복사 완료: ${copiedCount}개, 매핑: ${ruleIdMap.size}개` + ); return { copiedCount, ruleIdMap }; } @@ -2162,28 +2535,53 @@ export class MenuCopyService { [targetCompanyCode] ); const existingMappingKeys = new Map( - existingMappingsResult.rows.map(m => [`${m.table_name}|${m.logical_column_name}`, m.mapping_id]) + existingMappingsResult.rows.map((m) => [ + `${m.table_name}|${m.logical_column_name}`, + m.mapping_id, + ]) ); - // 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 }> = []; if (mappingsToCopy.length > 0) { - const mappingValues = mappingsToCopy.map((_, i) => - `($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, NOW(), $${i * 7 + 7})` - ).join(", "); + const mappingValues = mappingsToCopy + .map( + (_, i) => + `($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, NOW(), $${i * 7 + 7})` + ) + .join(", "); - const mappingParams = mappingsToCopy.flatMap(m => { + const mappingParams = mappingsToCopy.flatMap((m) => { const newMenuObjid = menuIdMap.get(m.menu_objid) || 0; mappingInsertInfo.push({ mapping: m, newMenuObjid }); return [ - m.table_name, m.logical_column_name, m.physical_column_name, - newMenuObjid, targetCompanyCode, m.description, userId + m.table_name, + m.logical_column_name, + m.physical_column_name, + newMenuObjid, + targetCompanyCode, + m.description, + userId, ]; }); @@ -2199,13 +2597,39 @@ export class MenuCopyService { // 새로 생성된 매핑 ID를 기존 매핑 맵에 추가 insertResult.rows.forEach((row, index) => { const m = mappingsToCopy[index]; - existingMappingKeys.set(`${m.table_name}|${m.logical_column_name}`, row.mapping_id); + existingMappingKeys.set( + `${m.table_name}|${m.logical_column_name}`, + row.mapping_id + ); }); copiedCount = mappingsToCopy.length; 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 @@ -2226,7 +2650,10 @@ export class MenuCopyService { [targetCompanyCode] ); const existingValueKeys = new Map( - existingValuesResult.rows.map(v => [`${v.table_name}|${v.column_name}|${v.value_code}`, v.value_id]) + existingValuesResult.rows.map((v) => [ + `${v.table_name}|${v.column_name}|${v.value_code}`, + v.value_id, + ]) ); // 6. 값 복사 (부모-자식 관계 유지를 위해 depth 순서로 처리) @@ -2262,17 +2689,34 @@ export class MenuCopyService { const values = valuesByDepth.get(depth)!; if (values.length === 0) continue; - const valueStrings = values.map((_, i) => - `($${i * 15 + 1}, $${i * 15 + 2}, $${i * 15 + 3}, $${i * 15 + 4}, $${i * 15 + 5}, $${i * 15 + 6}, $${i * 15 + 7}, $${i * 15 + 8}, $${i * 15 + 9}, $${i * 15 + 10}, $${i * 15 + 11}, $${i * 15 + 12}, NOW(), $${i * 15 + 13}, $${i * 15 + 14}, $${i * 15 + 15})` - ).join(", "); + const valueStrings = values + .map( + (_, i) => + `($${i * 15 + 1}, $${i * 15 + 2}, $${i * 15 + 3}, $${i * 15 + 4}, $${i * 15 + 5}, $${i * 15 + 6}, $${i * 15 + 7}, $${i * 15 + 8}, $${i * 15 + 9}, $${i * 15 + 10}, $${i * 15 + 11}, $${i * 15 + 12}, NOW(), $${i * 15 + 13}, $${i * 15 + 14}, $${i * 15 + 15})` + ) + .join(", "); - const valueParams = values.flatMap(v => { + const valueParams = values.flatMap((v) => { const newMenuObjid = menuIdMap.get(v.menu_objid); - const newParentId = v.parent_value_id ? valueIdMap.get(v.parent_value_id) || null : null; + const newParentId = v.parent_value_id + ? valueIdMap.get(v.parent_value_id) || null + : null; return [ - v.table_name, v.column_name, v.value_code, v.value_label, v.value_order, - newParentId, v.depth, v.description, v.color, v.icon, - v.is_active, v.is_default, userId, targetCompanyCode, newMenuObjid + v.table_name, + v.column_name, + v.value_code, + v.value_label, + v.value_order, + newParentId, + v.depth, + v.description, + v.color, + v.icon, + v.is_active, + v.is_default, + userId, + targetCompanyCode, + newMenuObjid, ]; }); @@ -2353,25 +2797,35 @@ export class MenuCopyService { [tableNames, targetCompanyCode] ); const existingKeys = new Set( - existingSettingsResult.rows.map(s => `${s.table_name}|${s.column_name}`) + existingSettingsResult.rows.map((s) => `${s.table_name}|${s.column_name}`) ); // 4. 복사할 설정 필터링 const settingsToCopy = sourceSettingsResult.rows.filter( - s => !existingKeys.has(`${s.table_name}|${s.column_name}`) + (s) => !existingKeys.has(`${s.table_name}|${s.column_name}`) ); - logger.info(` 원본 설정: ${sourceSettingsResult.rows.length}개, 복사 대상: ${settingsToCopy.length}개`); + logger.info( + ` 원본 설정: ${sourceSettingsResult.rows.length}개, 복사 대상: ${settingsToCopy.length}개` + ); // 5. 배치 INSERT if (settingsToCopy.length > 0) { - const settingValues = settingsToCopy.map((_, i) => - `($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, NOW(), NOW(), $${i * 7 + 7})` - ).join(", "); + const settingValues = settingsToCopy + .map( + (_, i) => + `($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, NOW(), NOW(), $${i * 7 + 7})` + ) + .join(", "); - const settingParams = settingsToCopy.flatMap(s => [ - s.table_name, s.column_name, s.input_type, s.detail_settings, - s.is_nullable, s.display_order, targetCompanyCode + const settingParams = settingsToCopy.flatMap((s) => [ + s.table_name, + s.column_name, + s.input_type, + s.detail_settings, + s.is_nullable, + s.display_order, + targetCompanyCode, ]); await client.query( @@ -2421,7 +2875,7 @@ export class MenuCopyService { [targetCompanyCode] ); const existingGroupsByCode = new Map( - existingGroupsResult.rows.map(g => [g.relation_code, g.group_id]) + existingGroupsResult.rows.map((g) => [g.relation_code, g.group_id]) ); // group_id 매핑 @@ -2437,7 +2891,9 @@ export class MenuCopyService { } } - logger.info(` 기존: ${groupsResult.rows.length - groupsToCopy.length}개, 신규: ${groupsToCopy.length}개`); + logger.info( + ` 기존: ${groupsResult.rows.length - groupsToCopy.length}개, 신규: ${groupsToCopy.length}개` + ); // 그룹별로 삽입하고 매핑 저장 (RETURNING이 필요해서 배치 불가) for (const group of groupsToCopy) { @@ -2459,12 +2915,22 @@ export class MenuCopyService { ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NOW()) RETURNING group_id`, [ - group.relation_code, group.relation_name, group.description, - group.parent_table_name, group.parent_column_name, newParentMenuObjid, - group.child_table_name, group.child_column_name, newChildMenuObjid, - group.clear_on_parent_change, group.show_group_label, - group.empty_parent_message, group.no_options_message, - targetCompanyCode, "Y", userId + group.relation_code, + group.relation_name, + group.description, + group.parent_table_name, + group.parent_column_name, + newParentMenuObjid, + group.child_table_name, + group.child_column_name, + newChildMenuObjid, + group.clear_on_parent_change, + group.show_group_label, + group.empty_parent_message, + group.no_options_message, + targetCompanyCode, + "Y", + userId, ] ); @@ -2474,7 +2940,7 @@ export class MenuCopyService { } // 모든 매핑 한 번에 조회 (복사할 그룹만) - const groupIdsToCopy = groupsToCopy.map(g => g.group_id); + const groupIdsToCopy = groupsToCopy.map((g) => g.group_id); if (groupIdsToCopy.length > 0) { const allMappingsResult = await client.query( `SELECT * FROM category_value_cascading_mapping @@ -2485,16 +2951,24 @@ export class MenuCopyService { // 배치 INSERT if (allMappingsResult.rows.length > 0) { - const mappingValues = allMappingsResult.rows.map((_, i) => - `($${i * 8 + 1}, $${i * 8 + 2}, $${i * 8 + 3}, $${i * 8 + 4}, $${i * 8 + 5}, $${i * 8 + 6}, $${i * 8 + 7}, $${i * 8 + 8}, NOW())` - ).join(", "); + const mappingValues = allMappingsResult.rows + .map( + (_, i) => + `($${i * 8 + 1}, $${i * 8 + 2}, $${i * 8 + 3}, $${i * 8 + 4}, $${i * 8 + 5}, $${i * 8 + 6}, $${i * 8 + 7}, $${i * 8 + 8}, NOW())` + ) + .join(", "); - const mappingParams = allMappingsResult.rows.flatMap(m => { + const mappingParams = allMappingsResult.rows.flatMap((m) => { const newGroupId = groupIdMap.get(m.group_id); return [ - newGroupId, m.parent_value_code, m.parent_value_label, - m.child_value_code, m.child_value_label, m.display_order, - targetCompanyCode, "Y" + newGroupId, + m.parent_value_code, + m.parent_value_label, + m.child_value_code, + m.child_value_label, + m.display_order, + targetCompanyCode, + "Y", ]; }); @@ -2531,29 +3005,47 @@ export class MenuCopyService { [targetCompanyCode] ); const existingRelationCodes = new Set( - existingRelationsResult.rows.map(r => r.relation_code) + existingRelationsResult.rows.map((r) => r.relation_code) ); // 복사할 관계 필터링 const relationsToCopy = relationsResult.rows.filter( - r => !existingRelationCodes.has(r.relation_code) + (r) => !existingRelationCodes.has(r.relation_code) ); - logger.info(` 기존: ${relationsResult.rows.length - relationsToCopy.length}개, 신규: ${relationsToCopy.length}개`); + logger.info( + ` 기존: ${relationsResult.rows.length - relationsToCopy.length}개, 신규: ${relationsToCopy.length}개` + ); // 배치 INSERT if (relationsToCopy.length > 0) { - const relationValues = relationsToCopy.map((_, i) => - `($${i * 19 + 1}, $${i * 19 + 2}, $${i * 19 + 3}, $${i * 19 + 4}, $${i * 19 + 5}, $${i * 19 + 6}, $${i * 19 + 7}, $${i * 19 + 8}, $${i * 19 + 9}, $${i * 19 + 10}, $${i * 19 + 11}, $${i * 19 + 12}, $${i * 19 + 13}, $${i * 19 + 14}, $${i * 19 + 15}, $${i * 19 + 16}, $${i * 19 + 17}, $${i * 19 + 18}, $${i * 19 + 19}, NOW())` - ).join(", "); + const relationValues = relationsToCopy + .map( + (_, i) => + `($${i * 19 + 1}, $${i * 19 + 2}, $${i * 19 + 3}, $${i * 19 + 4}, $${i * 19 + 5}, $${i * 19 + 6}, $${i * 19 + 7}, $${i * 19 + 8}, $${i * 19 + 9}, $${i * 19 + 10}, $${i * 19 + 11}, $${i * 19 + 12}, $${i * 19 + 13}, $${i * 19 + 14}, $${i * 19 + 15}, $${i * 19 + 16}, $${i * 19 + 17}, $${i * 19 + 18}, $${i * 19 + 19}, NOW())` + ) + .join(", "); - const relationParams = relationsToCopy.flatMap(r => [ - r.relation_code, r.relation_name, r.description, - r.parent_table, r.parent_value_column, r.parent_label_column, - r.child_table, r.child_filter_column, r.child_value_column, r.child_label_column, - r.child_order_column, r.child_order_direction, - r.empty_parent_message, r.no_options_message, r.loading_message, - r.clear_on_parent_change, targetCompanyCode, "Y", userId + const relationParams = relationsToCopy.flatMap((r) => [ + r.relation_code, + r.relation_name, + r.description, + r.parent_table, + r.parent_value_column, + r.parent_label_column, + r.child_table, + r.child_filter_column, + r.child_value_column, + r.child_label_column, + r.child_order_column, + r.child_order_direction, + r.empty_parent_message, + r.no_options_message, + r.loading_message, + r.clear_on_parent_change, + targetCompanyCode, + "Y", + userId, ]); await client.query( @@ -2575,5 +3067,4 @@ export class MenuCopyService { logger.info(`✅ 연쇄관계 복사 완료: ${copiedCount}개`); return copiedCount; } - } From 91d00aa784ab8999638bbaf68d0e1bb03a83450a Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 19 Dec 2025 15:44:38 +0900 Subject: [PATCH 02/18] =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EB=A6=AC=EB=89=B4=EC=96=BC=201.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/app.ts | 3 +- .../src/controllers/entitySearchController.ts | 95 ++ .../controllers/tableManagementController.ts | 7 +- .../src/routes/cascadingAutoFillRoutes.ts | 1 + .../src/routes/cascadingConditionRoutes.ts | 1 + .../src/routes/cascadingHierarchyRoutes.ts | 1 + .../routes/cascadingMutualExclusionRoutes.ts | 1 + backend-node/src/routes/entitySearchRoutes.ts | 11 +- .../src/services/screenManagementService.ts | 14 +- .../src/services/tableManagementService.ts | 58 +- docs/phase0-component-usage-analysis.md | 185 +++ docs/phase0-migration-strategy.md | 393 ++++++ docs/unified-components-implementation.md | 192 +++ docs/노드플로우_개선사항.md | 1 + docs/메일발송_기능_사용_가이드.md | 1 + docs/즉시저장_버튼_액션_구현_계획서.md | 1 + frontend/app/(main)/admin/screenMng/page.tsx | 34 +- .../screen/InteractiveScreenViewerDynamic.tsx | 139 +- frontend/components/screen/ScreenDesigner.tsx | 117 +- .../screen/panels/ComponentsPanel.tsx | 121 +- .../screen/panels/DetailSettingsPanel.tsx | 53 +- .../screen/panels/UnifiedPropertiesPanel.tsx | 89 ++ .../unified/ConditionalConfigPanel.tsx | 359 ++++++ .../components/unified/DynamicConfigPanel.tsx | 372 ++++++ frontend/components/unified/UnifiedBiz.tsx | 349 +++++ .../unified/UnifiedComponentRenderer.tsx | 111 ++ .../unified/UnifiedComponentsDemo.tsx | 1119 +++++++++++++++++ frontend/components/unified/UnifiedDate.tsx | 511 ++++++++ .../components/unified/UnifiedFormContext.tsx | 279 ++++ frontend/components/unified/UnifiedGroup.tsx | 456 +++++++ .../components/unified/UnifiedHierarchy.tsx | 501 ++++++++ frontend/components/unified/UnifiedInput.tsx | 452 +++++++ frontend/components/unified/UnifiedLayout.tsx | 399 ++++++ frontend/components/unified/UnifiedList.tsx | 555 ++++++++ frontend/components/unified/UnifiedMedia.tsx | 575 +++++++++ frontend/components/unified/UnifiedSelect.tsx | 676 ++++++++++ .../config-panels/UnifiedBizConfigPanel.tsx | 267 ++++ .../config-panels/UnifiedDateConfigPanel.tsx | 149 +++ .../config-panels/UnifiedGroupConfigPanel.tsx | 222 ++++ .../UnifiedHierarchyConfigPanel.tsx | 277 ++++ .../config-panels/UnifiedInputConfigPanel.tsx | 161 +++ .../UnifiedLayoutConfigPanel.tsx | 256 ++++ .../config-panels/UnifiedListConfigPanel.tsx | 246 ++++ .../config-panels/UnifiedMediaConfigPanel.tsx | 212 ++++ .../UnifiedSelectConfigPanel.tsx | 405 ++++++ .../components/unified/config-panels/index.ts | 15 + frontend/components/unified/index.ts | 109 ++ .../unified/registerUnifiedComponents.ts | 202 +++ frontend/contexts/ActiveTabContext.tsx | 1 + frontend/hooks/useAutoFill.ts | 1 + frontend/lib/api/screen.ts | 6 +- .../lib/registry/DynamicComponentRenderer.tsx | 213 ++++ frontend/lib/registry/init.ts | 8 + frontend/lib/utils/webTypeMapping.ts | 342 ++++- frontend/types/component.ts | 6 + frontend/types/screen-management.ts | 18 + frontend/types/unified-components.ts | 494 ++++++++ frontend/types/unified-core.ts | 8 +- ..._임베딩_및_데이터_전달_시스템_구현_계획서.md | 1 + 화면_임베딩_시스템_Phase1-4_구현_완료.md | 1 + 화면_임베딩_시스템_충돌_분석_보고서.md | 1 + 61 files changed, 11678 insertions(+), 175 deletions(-) create mode 100644 docs/phase0-component-usage-analysis.md create mode 100644 docs/phase0-migration-strategy.md create mode 100644 docs/unified-components-implementation.md create mode 100644 frontend/components/unified/ConditionalConfigPanel.tsx create mode 100644 frontend/components/unified/DynamicConfigPanel.tsx create mode 100644 frontend/components/unified/UnifiedBiz.tsx create mode 100644 frontend/components/unified/UnifiedComponentRenderer.tsx create mode 100644 frontend/components/unified/UnifiedComponentsDemo.tsx create mode 100644 frontend/components/unified/UnifiedDate.tsx create mode 100644 frontend/components/unified/UnifiedFormContext.tsx create mode 100644 frontend/components/unified/UnifiedGroup.tsx create mode 100644 frontend/components/unified/UnifiedHierarchy.tsx create mode 100644 frontend/components/unified/UnifiedInput.tsx create mode 100644 frontend/components/unified/UnifiedLayout.tsx create mode 100644 frontend/components/unified/UnifiedList.tsx create mode 100644 frontend/components/unified/UnifiedMedia.tsx create mode 100644 frontend/components/unified/UnifiedSelect.tsx create mode 100644 frontend/components/unified/config-panels/UnifiedBizConfigPanel.tsx create mode 100644 frontend/components/unified/config-panels/UnifiedDateConfigPanel.tsx create mode 100644 frontend/components/unified/config-panels/UnifiedGroupConfigPanel.tsx create mode 100644 frontend/components/unified/config-panels/UnifiedHierarchyConfigPanel.tsx create mode 100644 frontend/components/unified/config-panels/UnifiedInputConfigPanel.tsx create mode 100644 frontend/components/unified/config-panels/UnifiedLayoutConfigPanel.tsx create mode 100644 frontend/components/unified/config-panels/UnifiedListConfigPanel.tsx create mode 100644 frontend/components/unified/config-panels/UnifiedMediaConfigPanel.tsx create mode 100644 frontend/components/unified/config-panels/UnifiedSelectConfigPanel.tsx create mode 100644 frontend/components/unified/config-panels/index.ts create mode 100644 frontend/components/unified/index.ts create mode 100644 frontend/components/unified/registerUnifiedComponents.ts create mode 100644 frontend/types/unified-components.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index e928f96c..15736a2c 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -70,7 +70,7 @@ import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리 import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리 import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합 import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리 -import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색 +import entitySearchRoutes, { entityOptionsRouter } from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션 import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달 import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 @@ -249,6 +249,7 @@ app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 app.use("/api/code-merge", codeMergeRoutes); // 코드 병합 app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리 app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색 +app.use("/api/entity", entityOptionsRouter); // 엔티티 옵션 (UnifiedSelect용) app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리 app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리 app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리 diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts index 4d911c57..06de789d 100644 --- a/backend-node/src/controllers/entitySearchController.ts +++ b/backend-node/src/controllers/entitySearchController.ts @@ -3,6 +3,101 @@ import { AuthenticatedRequest } from "../types/auth"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; +/** + * 엔티티 옵션 조회 API (UnifiedSelect용) + * GET /api/entity/:tableName/options + * + * Query Params: + * - value: 값 컬럼 (기본: id) + * - label: 표시 컬럼 (기본: name) + */ +export async function getEntityOptions(req: AuthenticatedRequest, res: Response) { + try { + const { tableName } = req.params; + const { value = "id", label = "name" } = req.query; + + // tableName 유효성 검증 + if (!tableName || tableName === "undefined" || tableName === "null") { + logger.warn("엔티티 옵션 조회 실패: 테이블명이 없음", { tableName }); + return res.status(400).json({ + success: false, + message: "테이블명이 지정되지 않았습니다.", + }); + } + + const companyCode = req.user!.companyCode; + const pool = getPool(); + + // 테이블의 실제 컬럼 목록 조회 + const columnsResult = await pool.query( + `SELECT column_name FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1`, + [tableName] + ); + const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name)); + + // 요청된 컬럼 검증 + const valueColumn = existingColumns.has(value as string) ? value : "id"; + const labelColumn = existingColumns.has(label as string) ? label : "name"; + + // 둘 다 없으면 에러 + if (!existingColumns.has(valueColumn as string)) { + return res.status(400).json({ + success: false, + message: `테이블 "${tableName}"에 값 컬럼 "${value}"이 존재하지 않습니다.`, + }); + } + + // label 컬럼이 없으면 value 컬럼을 label로도 사용 + const effectiveLabelColumn = existingColumns.has(labelColumn as string) ? labelColumn : valueColumn; + + // WHERE 조건 (멀티테넌시) + const whereConditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + if (companyCode !== "*" && existingColumns.has("company_code")) { + whereConditions.push(`company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + + const whereClause = whereConditions.length > 0 + ? `WHERE ${whereConditions.join(" AND ")}` + : ""; + + // 쿼리 실행 (최대 500개) + const query = ` + SELECT ${valueColumn} as value, ${effectiveLabelColumn} as label + FROM ${tableName} + ${whereClause} + ORDER BY ${effectiveLabelColumn} ASC + LIMIT 500 + `; + + const result = await pool.query(query, params); + + logger.info("엔티티 옵션 조회 성공", { + tableName, + valueColumn, + labelColumn: effectiveLabelColumn, + companyCode, + rowCount: result.rowCount, + }); + + res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("엔티티 옵션 조회 오류", { + error: error.message, + stack: error.stack, + }); + res.status(500).json({ success: false, message: error.message }); + } +} + /** * 엔티티 검색 API * GET /api/entity-search/:tableName diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 04fa1add..0343f539 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -97,11 +97,16 @@ export async function getColumnList( } const tableManagementService = new TableManagementService(); + + // 🔥 캐시 버스팅: _t 파라미터가 있으면 캐시 무시 + const bustCache = !!req.query._t; + const result = await tableManagementService.getColumnList( tableName, parseInt(page as string), parseInt(size as string), - companyCode // 🔥 회사 코드 전달 + companyCode, // 🔥 회사 코드 전달 + bustCache // 🔥 캐시 버스팅 옵션 ); logger.info( diff --git a/backend-node/src/routes/cascadingAutoFillRoutes.ts b/backend-node/src/routes/cascadingAutoFillRoutes.ts index 92036080..a5107448 100644 --- a/backend-node/src/routes/cascadingAutoFillRoutes.ts +++ b/backend-node/src/routes/cascadingAutoFillRoutes.ts @@ -54,3 +54,4 @@ export default router; + diff --git a/backend-node/src/routes/cascadingConditionRoutes.ts b/backend-node/src/routes/cascadingConditionRoutes.ts index ed11d3d1..22cd2d2b 100644 --- a/backend-node/src/routes/cascadingConditionRoutes.ts +++ b/backend-node/src/routes/cascadingConditionRoutes.ts @@ -50,3 +50,4 @@ export default router; + diff --git a/backend-node/src/routes/cascadingHierarchyRoutes.ts b/backend-node/src/routes/cascadingHierarchyRoutes.ts index d74929cb..79a1c6e8 100644 --- a/backend-node/src/routes/cascadingHierarchyRoutes.ts +++ b/backend-node/src/routes/cascadingHierarchyRoutes.ts @@ -66,3 +66,4 @@ export default router; + diff --git a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts index ce2fbcac..352a05b5 100644 --- a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts +++ b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts @@ -54,3 +54,4 @@ export default router; + diff --git a/backend-node/src/routes/entitySearchRoutes.ts b/backend-node/src/routes/entitySearchRoutes.ts index 7677279a..f75260e9 100644 --- a/backend-node/src/routes/entitySearchRoutes.ts +++ b/backend-node/src/routes/entitySearchRoutes.ts @@ -1,6 +1,6 @@ import { Router } from "express"; import { authenticateToken } from "../middleware/authMiddleware"; -import { searchEntity } from "../controllers/entitySearchController"; +import { searchEntity, getEntityOptions } from "../controllers/entitySearchController"; const router = Router(); @@ -12,3 +12,12 @@ router.get("/:tableName", authenticateToken, searchEntity); export default router; +// 엔티티 옵션 라우터 (UnifiedSelect용) +export const entityOptionsRouter = Router(); + +/** + * 엔티티 옵션 조회 API + * GET /api/entity/:tableName/options + */ +entityOptionsRouter.get("/:tableName/options", authenticateToken, getEntityOptions); + diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 92a35663..40628f12 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -1658,10 +1658,16 @@ export class ScreenManagementService { ? inputTypeMap.get(`${tableName}.${columnName}`) : null; + // 🆕 Unified 컴포넌트는 덮어쓰지 않음 (새로운 컴포넌트 시스템 보호) + const savedComponentType = properties?.componentType; + const isUnifiedComponent = savedComponentType?.startsWith("unified-"); + const component = { id: layout.component_id, - // 🔥 최신 componentType이 있으면 type 덮어쓰기 - type: latestTypeInfo?.componentType || layout.component_type as any, + // 🔥 최신 componentType이 있으면 type 덮어쓰기 (단, Unified 컴포넌트는 제외) + type: isUnifiedComponent + ? layout.component_type as any // Unified는 저장된 값 유지 + : (latestTypeInfo?.componentType || layout.component_type as any), position: { x: layout.position_x, y: layout.position_y, @@ -1670,8 +1676,8 @@ export class ScreenManagementService { size: { width: layout.width, height: layout.height }, parentId: layout.parent_id, ...properties, - // 🔥 최신 inputType이 있으면 widgetType, componentType 덮어쓰기 - ...(latestTypeInfo && { + // 🔥 최신 inputType이 있으면 widgetType, componentType 덮어쓰기 (단, Unified 컴포넌트는 제외) + ...(!isUnifiedComponent && latestTypeInfo && { widgetType: latestTypeInfo.inputType, inputType: latestTypeInfo.inputType, componentType: latestTypeInfo.componentType, diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index b714b186..70c82538 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -114,7 +114,8 @@ export class TableManagementService { tableName: string, page: number = 1, size: number = 50, - companyCode?: string // 🔥 회사 코드 추가 + companyCode?: string, // 🔥 회사 코드 추가 + bustCache: boolean = false // 🔥 캐시 버스팅 옵션 ): Promise<{ columns: ColumnTypeInfo[]; total: number; @@ -124,7 +125,7 @@ export class TableManagementService { }> { try { logger.info( - `컬럼 정보 조회 시작: ${tableName} (page: ${page}, size: ${size}), company: ${companyCode}` + `컬럼 정보 조회 시작: ${tableName} (page: ${page}, size: ${size}), company: ${companyCode}, bustCache: ${bustCache}` ); // 캐시 키 생성 (companyCode 포함) @@ -132,32 +133,37 @@ export class TableManagementService { CacheKeys.TABLE_COLUMNS(tableName, page, size) + `_${companyCode}`; const countCacheKey = CacheKeys.TABLE_COLUMN_COUNT(tableName); - // 캐시에서 먼저 확인 - const cachedResult = cache.get<{ - columns: ColumnTypeInfo[]; - total: number; - page: number; - size: number; - totalPages: number; - }>(cacheKey); - if (cachedResult) { - logger.info( - `컬럼 정보 캐시에서 조회: ${tableName}, ${cachedResult.columns.length}/${cachedResult.total}개` - ); + // 🔥 캐시 버스팅: bustCache가 true면 캐시 무시 + if (!bustCache) { + // 캐시에서 먼저 확인 + const cachedResult = cache.get<{ + columns: ColumnTypeInfo[]; + total: number; + page: number; + size: number; + totalPages: number; + }>(cacheKey); + if (cachedResult) { + logger.info( + `컬럼 정보 캐시에서 조회: ${tableName}, ${cachedResult.columns.length}/${cachedResult.total}개` + ); - // 디버깅: 캐시된 currency_code 확인 - const cachedCurrency = cachedResult.columns.find( - (col: any) => col.columnName === "currency_code" - ); - if (cachedCurrency) { - console.log(`💾 [캐시] currency_code:`, { - columnName: cachedCurrency.columnName, - inputType: cachedCurrency.inputType, - webType: cachedCurrency.webType, - }); + // 디버깅: 캐시된 currency_code 확인 + const cachedCurrency = cachedResult.columns.find( + (col: any) => col.columnName === "currency_code" + ); + if (cachedCurrency) { + console.log(`💾 [캐시] currency_code:`, { + columnName: cachedCurrency.columnName, + inputType: cachedCurrency.inputType, + webType: cachedCurrency.webType, + }); + } + + return cachedResult; } - - return cachedResult; + } else { + logger.info(`🔥 캐시 버스팅: ${tableName} 캐시 무시`); } // 전체 컬럼 수 조회 (캐시 확인) diff --git a/docs/phase0-component-usage-analysis.md b/docs/phase0-component-usage-analysis.md new file mode 100644 index 00000000..4c74ffd5 --- /dev/null +++ b/docs/phase0-component-usage-analysis.md @@ -0,0 +1,185 @@ +# Phase 0: 컴포넌트 사용 현황 분석 + +## 분석 일시 + +2024-12-19 + +## 분석 대상 + +- 활성화된 화면 정의 (screen_definitions.is_active = 'Y') +- 화면 레이아웃 (screen_layouts) + +--- + +## 1. 컴포넌트별 사용량 순위 + +### 상위 15개 컴포넌트 + +| 순위 | 컴포넌트 | 사용 횟수 | 사용 화면 수 | Unified 매핑 | +| :--: | :-------------------------- | :-------: | :----------: | :------------------------------ | +| 1 | button-primary | 571 | 364 | UnifiedInput (type: button) | +| 2 | text-input | 805 | 166 | **UnifiedInput (type: text)** | +| 3 | table-list | 130 | 130 | UnifiedList (viewMode: table) | +| 4 | table-search-widget | 127 | 127 | UnifiedList (searchable: true) | +| 5 | select-basic | 121 | 76 | **UnifiedSelect** | +| 6 | number-input | 86 | 34 | **UnifiedInput (type: number)** | +| 7 | date-input | 83 | 51 | **UnifiedDate** | +| 8 | file-upload | 41 | 18 | UnifiedMedia (type: file) | +| 9 | tabs-widget | 39 | 39 | UnifiedGroup (type: tabs) | +| 10 | split-panel-layout | 39 | 39 | UnifiedLayout (type: split) | +| 11 | category-manager | 38 | 38 | UnifiedBiz (type: category) | +| 12 | numbering-rule | 31 | 31 | UnifiedBiz (type: numbering) | +| 13 | selected-items-detail-input | 29 | 29 | 복합 컴포넌트 | +| 14 | modal-repeater-table | 25 | 25 | UnifiedList (modal: true) | +| 15 | image-widget | 29 | 29 | UnifiedMedia (type: image) | + +--- + +## 2. Unified 컴포넌트별 통합 대상 분석 + +### UnifiedInput (예상 통합 대상: 891개) + +| 기존 컴포넌트 | 사용 횟수 | 비율 | +| :------------ | :-------: | :---: | +| text-input | 805 | 90.3% | +| number-input | 86 | 9.7% | + +**우선순위: 1위** - 가장 많이 사용되는 컴포넌트 + +### UnifiedSelect (예상 통합 대상: 140개) + +| 기존 컴포넌트 | 사용 횟수 | widgetType | +| :------------------------ | :-------: | :--------- | +| select-basic (category) | 65 | category | +| select-basic (null) | 50 | - | +| autocomplete-search-input | 19 | entity | +| entity-search-input | 20 | entity | +| checkbox-basic | 7 | checkbox | +| radio-basic | 5 | radio | + +**우선순위: 2위** - 다양한 모드 지원 필요 + +### UnifiedDate (예상 통합 대상: 83개) + +| 기존 컴포넌트 | 사용 횟수 | +| :---------------- | :-------: | +| date-input (null) | 58 | +| date-input (date) | 23 | +| date-input (text) | 2 | + +**우선순위: 3위** + +### UnifiedList (예상 통합 대상: 283개) + +| 기존 컴포넌트 | 사용 횟수 | 비고 | +| :-------------------- | :-------: | :---------- | +| table-list | 130 | 기본 테이블 | +| table-search-widget | 127 | 검색 테이블 | +| modal-repeater-table | 25 | 모달 반복 | +| repeater-field-group | 15 | 반복 필드 | +| card-display | 11 | 카드 표시 | +| simple-repeater-table | 1 | 단순 반복 | + +**우선순위: 4위** - 핵심 데이터 표시 컴포넌트 + +### UnifiedMedia (예상 통합 대상: 70개) + +| 기존 컴포넌트 | 사용 횟수 | +| :------------ | :-------: | +| file-upload | 41 | +| image-widget | 29 | + +### UnifiedLayout (예상 통합 대상: 62개) + +| 기존 컴포넌트 | 사용 횟수 | +| :------------------ | :-------: | +| split-panel-layout | 39 | +| screen-split-panel | 21 | +| split-panel-layout2 | 2 | + +### UnifiedGroup (예상 통합 대상: 99개) + +| 기존 컴포넌트 | 사용 횟수 | +| :-------------------- | :-------: | +| tabs-widget | 39 | +| conditional-container | 23 | +| section-paper | 11 | +| section-card | 10 | +| text-display | 13 | +| universal-form-modal | 7 | +| repeat-screen-modal | 5 | + +### UnifiedBiz (예상 통합 대상: 79개) + +| 기존 컴포넌트 | 사용 횟수 | +| :--------------------- | :-------: | +| category-manager | 38 | +| numbering-rule | 31 | +| flow-widget | 8 | +| rack-structure | 2 | +| related-data-buttons | 2 | +| location-swap-selector | 2 | +| tax-invoice-list | 1 | + +--- + +## 3. 구현 우선순위 결정 + +### Phase 1 우선순위 (즉시 효과가 큰 컴포넌트) + +| 순위 | Unified 컴포넌트 | 통합 대상 수 | 영향 화면 수 | 이유 | +| :---: | :---------------- | :----------: | :----------: | :--------------- | +| **1** | **UnifiedInput** | 891개 | 200+ | 가장 많이 사용 | +| **2** | **UnifiedSelect** | 140개 | 100+ | 다양한 모드 필요 | +| **3** | **UnifiedDate** | 83개 | 51 | 비교적 단순 | + +### Phase 2 우선순위 (데이터 표시 컴포넌트) + +| 순위 | Unified 컴포넌트 | 통합 대상 수 | 이유 | +| :---: | :---------------- | :----------: | :--------------- | +| **4** | **UnifiedList** | 283개 | 핵심 데이터 표시 | +| **5** | **UnifiedLayout** | 62개 | 레이아웃 구조 | +| **6** | **UnifiedGroup** | 99개 | 콘텐츠 그룹화 | + +### Phase 3 우선순위 (특수 컴포넌트) + +| 순위 | Unified 컴포넌트 | 통합 대상 수 | 이유 | +| :---: | :------------------- | :----------: | :------------ | +| **7** | **UnifiedMedia** | 70개 | 파일/이미지 | +| **8** | **UnifiedBiz** | 79개 | 비즈니스 특화 | +| **9** | **UnifiedHierarchy** | 0개 | 신규 기능 | + +--- + +## 4. 주요 발견 사항 + +### 4.1 button-primary 분리 검토 + +- 사용량: 571개 (1위) +- 현재 계획: UnifiedInput에 포함 +- **제안**: 별도 `UnifiedButton` 컴포넌트로 분리 검토 + - 버튼은 입력과 성격이 다름 + - 액션 타입, 스타일, 권한 등 복잡한 설정 필요 + +### 4.2 conditional-container 처리 + +- 사용량: 23개 +- 현재 계획: 공통 conditional 속성으로 통합 +- **확인 필요**: 기존 화면에서 어떻게 마이그레이션할지 + +### 4.3 category 관련 컴포넌트 + +- select-basic (category): 65개 +- category-manager: 38개 +- **총 103개**의 카테고리 관련 컴포넌트 +- 카테고리 시스템 통합 중요 + +--- + +## 5. 다음 단계 + +1. [ ] 데이터 마이그레이션 전략 설계 (Phase 0-2) +2. [ ] sys_input_type JSON Schema 설계 (Phase 0-3) +3. [ ] DynamicConfigPanel 프로토타입 (Phase 0-4) +4. [ ] UnifiedInput 구현 시작 (Phase 1-1) + diff --git a/docs/phase0-migration-strategy.md b/docs/phase0-migration-strategy.md new file mode 100644 index 00000000..6ee91643 --- /dev/null +++ b/docs/phase0-migration-strategy.md @@ -0,0 +1,393 @@ +# Phase 0: 데이터 마이그레이션 전략 + +## 1. 현재 데이터 구조 분석 + +### screen_layouts.properties 구조 + +```jsonc +{ + // 기본 정보 + "type": "component", + "componentType": "text-input", // 기존 컴포넌트 타입 + + // 위치/크기 + "position": { "x": 68, "y": 80, "z": 1 }, + "size": { "width": 324, "height": 40 }, + + // 라벨 및 스타일 + "label": "품목코드", + "style": { + "labelColor": "#000000", + "labelDisplay": true, + "labelFontSize": "14px", + "labelFontWeight": "500", + "labelMarginBottom": "8px" + }, + + // 데이터 바인딩 + "tableName": "order_table", + "columnName": "part_code", + + // 필드 속성 + "required": true, + "readonly": false, + + // 컴포넌트별 설정 + "componentConfig": { + "type": "text-input", + "format": "none", + "webType": "text", + "multiline": false, + "placeholder": "텍스트를 입력하세요" + }, + + // 그리드 레이아웃 + "gridColumns": 5, + "gridRowIndex": 0, + "gridColumnStart": 1, + "gridColumnSpan": "third", + + // 기타 + "parentId": null +} +``` + +--- + +## 2. 마이그레이션 전략: 하이브리드 방식 + +### 2.1 비파괴적 전환 (권장) + +기존 필드를 유지하면서 새로운 필드를 추가하는 방식 + +```jsonc +{ + // 기존 필드 유지 (하위 호환성) + "componentType": "text-input", + "componentConfig": { ... }, + + // 신규 필드 추가 + "unifiedType": "UnifiedInput", // 새로운 통합 컴포넌트 타입 + "unifiedConfig": { // 새로운 설정 구조 + "type": "text", + "format": "none", + "placeholder": "텍스트를 입력하세요" + }, + + // 마이그레이션 메타데이터 + "_migration": { + "version": "2.0", + "migratedAt": "2024-12-19T00:00:00Z", + "migratedBy": "system", + "originalType": "text-input" + } +} +``` + +### 2.2 렌더링 로직 수정 + +```typescript +// 렌더러에서 unifiedType 우선 사용 +function renderComponent(props: ComponentProps) { + // 신규 타입이 있으면 Unified 컴포넌트 사용 + if (props.unifiedType) { + return ; + } + + // 없으면 기존 레거시 컴포넌트 사용 + return ; +} +``` + +--- + +## 3. 컴포넌트별 매핑 규칙 + +### 3.1 text-input → UnifiedInput + +```typescript +// AS-IS +{ + "componentType": "text-input", + "componentConfig": { + "type": "text-input", + "format": "none", + "webType": "text", + "multiline": false, + "placeholder": "텍스트를 입력하세요" + } +} + +// TO-BE +{ + "unifiedType": "UnifiedInput", + "unifiedConfig": { + "type": "text", // componentConfig.webType 또는 "text" + "format": "none", // componentConfig.format + "placeholder": "..." // componentConfig.placeholder + } +} +``` + +### 3.2 number-input → UnifiedInput + +```typescript +// AS-IS +{ + "componentType": "number-input", + "componentConfig": { + "type": "number-input", + "webType": "number", + "min": 0, + "max": 100, + "step": 1 + } +} + +// TO-BE +{ + "unifiedType": "UnifiedInput", + "unifiedConfig": { + "type": "number", + "min": 0, + "max": 100, + "step": 1 + } +} +``` + +### 3.3 select-basic → UnifiedSelect + +```typescript +// AS-IS (code 타입) +{ + "componentType": "select-basic", + "codeCategory": "ORDER_STATUS", + "componentConfig": { + "type": "select-basic", + "webType": "code", + "codeCategory": "ORDER_STATUS" + } +} + +// TO-BE +{ + "unifiedType": "UnifiedSelect", + "unifiedConfig": { + "mode": "dropdown", + "source": "code", + "codeGroup": "ORDER_STATUS" + } +} + +// AS-IS (entity 타입) +{ + "componentType": "select-basic", + "componentConfig": { + "type": "select-basic", + "webType": "entity", + "searchable": true, + "valueField": "id", + "displayField": "name" + } +} + +// TO-BE +{ + "unifiedType": "UnifiedSelect", + "unifiedConfig": { + "mode": "dropdown", + "source": "entity", + "searchable": true, + "valueField": "id", + "displayField": "name" + } +} +``` + +### 3.4 date-input → UnifiedDate + +```typescript +// AS-IS +{ + "componentType": "date-input", + "componentConfig": { + "type": "date-input", + "webType": "date", + "format": "YYYY-MM-DD" + } +} + +// TO-BE +{ + "unifiedType": "UnifiedDate", + "unifiedConfig": { + "type": "date", + "format": "YYYY-MM-DD" + } +} +``` + +--- + +## 4. 마이그레이션 스크립트 + +### 4.1 자동 마이그레이션 함수 + +```typescript +// lib/migration/componentMigration.ts + +interface MigrationResult { + success: boolean; + unifiedType: string; + unifiedConfig: Record; +} + +export function migrateToUnified( + componentType: string, + componentConfig: Record +): MigrationResult { + + switch (componentType) { + case 'text-input': + return { + success: true, + unifiedType: 'UnifiedInput', + unifiedConfig: { + type: componentConfig.webType || 'text', + format: componentConfig.format || 'none', + placeholder: componentConfig.placeholder + } + }; + + case 'number-input': + return { + success: true, + unifiedType: 'UnifiedInput', + unifiedConfig: { + type: 'number', + min: componentConfig.min, + max: componentConfig.max, + step: componentConfig.step + } + }; + + case 'select-basic': + return { + success: true, + unifiedType: 'UnifiedSelect', + unifiedConfig: { + mode: 'dropdown', + source: componentConfig.webType || 'static', + codeGroup: componentConfig.codeCategory, + searchable: componentConfig.searchable, + valueField: componentConfig.valueField, + displayField: componentConfig.displayField + } + }; + + case 'date-input': + return { + success: true, + unifiedType: 'UnifiedDate', + unifiedConfig: { + type: componentConfig.webType || 'date', + format: componentConfig.format + } + }; + + default: + return { + success: false, + unifiedType: '', + unifiedConfig: {} + }; + } +} +``` + +### 4.2 DB 마이그레이션 스크립트 + +```sql +-- 마이그레이션 백업 테이블 생성 +CREATE TABLE screen_layouts_backup_v2 AS +SELECT * FROM screen_layouts; + +-- 마이그레이션 실행 (text-input 예시) +UPDATE screen_layouts +SET properties = properties || jsonb_build_object( + 'unifiedType', 'UnifiedInput', + 'unifiedConfig', jsonb_build_object( + 'type', COALESCE(properties->'componentConfig'->>'webType', 'text'), + 'format', COALESCE(properties->'componentConfig'->>'format', 'none'), + 'placeholder', properties->'componentConfig'->>'placeholder' + ), + '_migration', jsonb_build_object( + 'version', '2.0', + 'migratedAt', NOW(), + 'originalType', 'text-input' + ) +) +WHERE properties->>'componentType' = 'text-input'; +``` + +--- + +## 5. 롤백 전략 + +### 5.1 롤백 스크립트 + +```sql +-- 마이그레이션 전 상태로 복원 +UPDATE screen_layouts sl +SET properties = slb.properties +FROM screen_layouts_backup_v2 slb +WHERE sl.layout_id = slb.layout_id; + +-- 또는 신규 필드만 제거 +UPDATE screen_layouts +SET properties = properties - 'unifiedType' - 'unifiedConfig' - '_migration'; +``` + +### 5.2 단계적 롤백 + +```typescript +// 특정 화면만 롤백 +async function rollbackScreen(screenId: number) { + await db.query(` + UPDATE screen_layouts sl + SET properties = properties - 'unifiedType' - 'unifiedConfig' - '_migration' + WHERE screen_id = $1 + `, [screenId]); +} +``` + +--- + +## 6. 마이그레이션 일정 + +| 단계 | 작업 | 대상 | 시점 | +|:---:|:---|:---|:---| +| 1 | 백업 테이블 생성 | 전체 | Phase 1 시작 전 | +| 2 | UnifiedInput 마이그레이션 | text-input, number-input | Phase 1 중 | +| 3 | UnifiedSelect 마이그레이션 | select-basic | Phase 1 중 | +| 4 | UnifiedDate 마이그레이션 | date-input | Phase 1 중 | +| 5 | 검증 및 테스트 | 전체 | Phase 1 완료 후 | +| 6 | 레거시 필드 제거 | 전체 | Phase 5 (추후) | + +--- + +## 7. 주의사항 + +1. **항상 백업 먼저**: 마이그레이션 전 반드시 백업 테이블 생성 +2. **점진적 전환**: 한 번에 모든 컴포넌트를 마이그레이션하지 않음 +3. **하위 호환성**: 기존 필드 유지로 롤백 가능하게 +4. **테스트 필수**: 각 마이그레이션 단계별 화면 테스트 + + diff --git a/docs/unified-components-implementation.md b/docs/unified-components-implementation.md new file mode 100644 index 00000000..663e344e --- /dev/null +++ b/docs/unified-components-implementation.md @@ -0,0 +1,192 @@ +# Unified Components 구현 완료 보고서 + +## 구현 일시 + +2024-12-19 + +## 구현된 컴포넌트 목록 (10개) + +### Phase 1: 핵심 입력 컴포넌트 + +| 컴포넌트 | 파일 | 모드/타입 | 설명 | +| :---------------- | :------------------ | :-------------------------------------------- | :---------------------- | +| **UnifiedInput** | `UnifiedInput.tsx` | text, number, password, slider, color, button | 통합 입력 컴포넌트 | +| **UnifiedSelect** | `UnifiedSelect.tsx` | dropdown, radio, check, tag, toggle, swap | 통합 선택 컴포넌트 | +| **UnifiedDate** | `UnifiedDate.tsx` | date, time, datetime + range | 통합 날짜/시간 컴포넌트 | + +### Phase 2: 레이아웃 및 그룹 컴포넌트 + +| 컴포넌트 | 파일 | 모드/타입 | 설명 | +| :---------------- | :------------------ | :-------------------------------------------------------- | :--------------------- | +| **UnifiedList** | `UnifiedList.tsx` | table, card, kanban, list | 통합 리스트 컴포넌트 | +| **UnifiedLayout** | `UnifiedLayout.tsx` | grid, split, flex, divider, screen-embed | 통합 레이아웃 컴포넌트 | +| **UnifiedGroup** | `UnifiedGroup.tsx` | tabs, accordion, section, card-section, modal, form-modal | 통합 그룹 컴포넌트 | + +### Phase 3: 미디어 및 비즈니스 컴포넌트 + +| 컴포넌트 | 파일 | 모드/타입 | 설명 | +| :------------------- | :--------------------- | :------------------------------------------------------------- | :---------------------- | +| **UnifiedMedia** | `UnifiedMedia.tsx` | file, image, video, audio | 통합 미디어 컴포넌트 | +| **UnifiedBiz** | `UnifiedBiz.tsx` | flow, rack, map, numbering, category, mapping, related-buttons | 통합 비즈니스 컴포넌트 | +| **UnifiedHierarchy** | `UnifiedHierarchy.tsx` | tree, org, bom, cascading | 통합 계층 구조 컴포넌트 | + +--- + +## 공통 인프라 + +### 설정 패널 + +- **DynamicConfigPanel**: JSON Schema 기반 동적 설정 UI 생성 + +### 렌더러 + +- **UnifiedComponentRenderer**: unifiedType에 따른 동적 컴포넌트 렌더링 + +--- + +## 파일 구조 + +``` +frontend/components/unified/ +├── index.ts # 모듈 인덱스 +├── UnifiedComponentRenderer.tsx # 동적 렌더러 +├── DynamicConfigPanel.tsx # JSON Schema 설정 패널 +├── UnifiedInput.tsx # 통합 입력 +├── UnifiedSelect.tsx # 통합 선택 +├── UnifiedDate.tsx # 통합 날짜 +├── UnifiedList.tsx # 통합 리스트 +├── UnifiedLayout.tsx # 통합 레이아웃 +├── UnifiedGroup.tsx # 통합 그룹 +├── UnifiedMedia.tsx # 통합 미디어 +├── UnifiedBiz.tsx # 통합 비즈니스 +└── UnifiedHierarchy.tsx # 통합 계층 + +frontend/types/ +└── unified-components.ts # 타입 정의 + +db/migrations/ +└── unified_component_schema.sql # DB 스키마 (미실행) +``` + +--- + +## 사용 예시 + +### 기본 사용법 + +```tsx +import { + UnifiedInput, + UnifiedSelect, + UnifiedDate, + UnifiedList, + UnifiedComponentRenderer +} from "@/components/unified"; + +// UnifiedInput 사용 + + +// UnifiedSelect 사용 + + +// UnifiedDate 사용 + + +// UnifiedList 사용 + +``` + +### 동적 렌더링 + +```tsx +import { UnifiedComponentRenderer } from "@/components/unified"; + +// unifiedType에 따라 자동으로 적절한 컴포넌트 렌더링 +; +``` + +--- + +## 주의사항 + +### 기존 컴포넌트와의 공존 + +1. **기존 컴포넌트는 그대로 유지**: 모든 레거시 컴포넌트는 정상 동작 +2. **신규 화면에서만 Unified 컴포넌트 사용**: 기존 화면에 영향 없음 +3. **마이그레이션 없음**: 자동 마이그레이션 진행하지 않음 + +### 데이터베이스 마이그레이션 + +`db/migrations/unified_component_schema.sql` 파일은 아직 실행되지 않았습니다. +필요시 수동으로 실행해야 합니다: + +```bash +psql -h localhost -U postgres -d plm_db -f db/migrations/unified_component_schema.sql +``` + +--- + +## 다음 단계 (선택) + +1. **화면 관리 에디터 통합**: Unified 컴포넌트를 화면 에디터의 컴포넌트 팔레트에 추가 +2. **기존 비즈니스 컴포넌트 연동**: UnifiedBiz의 플레이스홀더를 실제 구현으로 교체 +3. **테스트 페이지 작성**: 모든 Unified 컴포넌트 데모 페이지 +4. **문서화**: 각 컴포넌트별 상세 사용 가이드 + +--- + +## 관련 문서 + +- `PLAN_RENEWAL.md`: 리뉴얼 계획서 +- `docs/phase0-component-usage-analysis.md`: 컴포넌트 사용 현황 분석 +- `docs/phase0-migration-strategy.md`: 마이그레이션 전략 (참고용) + diff --git a/docs/노드플로우_개선사항.md b/docs/노드플로우_개선사항.md index c2c44be0..c9349b94 100644 --- a/docs/노드플로우_개선사항.md +++ b/docs/노드플로우_개선사항.md @@ -586,3 +586,4 @@ const result = await executeNodeFlow(flowId, { + diff --git a/docs/메일발송_기능_사용_가이드.md b/docs/메일발송_기능_사용_가이드.md index 4ffb7655..42900211 100644 --- a/docs/메일발송_기능_사용_가이드.md +++ b/docs/메일발송_기능_사용_가이드.md @@ -359,3 +359,4 @@ + diff --git a/docs/즉시저장_버튼_액션_구현_계획서.md b/docs/즉시저장_버튼_액션_구현_계획서.md index 1de42fb2..c392eece 100644 --- a/docs/즉시저장_버튼_액션_구현_계획서.md +++ b/docs/즉시저장_버튼_액션_구현_계획서.md @@ -345,3 +345,4 @@ const getComponentValue = (componentId: string) => { + diff --git a/frontend/app/(main)/admin/screenMng/page.tsx b/frontend/app/(main)/admin/screenMng/page.tsx index 3145d9d3..c1afecaf 100644 --- a/frontend/app/(main)/admin/screenMng/page.tsx +++ b/frontend/app/(main)/admin/screenMng/page.tsx @@ -2,15 +2,16 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; -import { ArrowLeft } from "lucide-react"; +import { ArrowLeft, TestTube2 } from "lucide-react"; import ScreenList from "@/components/screen/ScreenList"; import ScreenDesigner from "@/components/screen/ScreenDesigner"; import TemplateManager from "@/components/screen/TemplateManager"; +import { UnifiedComponentsDemo } from "@/components/unified"; import { ScrollToTop } from "@/components/common/ScrollToTop"; import { ScreenDefinition } from "@/types/screen"; // 단계별 진행을 위한 타입 정의 -type Step = "list" | "design" | "template"; +type Step = "list" | "design" | "template" | "unified-test"; export default function ScreenManagementPage() { const [currentStep, setCurrentStep] = useState("list"); @@ -34,6 +35,10 @@ export default function ScreenManagementPage() { title: "템플릿 관리", description: "화면 템플릿을 관리하고 재사용하세요", }, + "unified-test": { + title: "Unified 컴포넌트 테스트", + description: "10개의 통합 컴포넌트를 테스트합니다", + }, }; // 다음 단계로 이동 @@ -71,13 +76,32 @@ export default function ScreenManagementPage() { ); } + // Unified 컴포넌트 테스트 모드 + if (currentStep === "unified-test") { + return ( +
+ goToStep("list")} /> +
+ ); + } + return (
{/* 페이지 헤더 */} -
-

화면 관리

-

화면을 설계하고 템플릿을 관리합니다

+
+
+

화면 관리

+

화면을 설계하고 템플릿을 관리합니다

+
+
{/* 단계별 내용 */} diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 4763507e..bf62e1bc 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -20,6 +20,84 @@ import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils"; import { useScreenPreview } from "@/contexts/ScreenPreviewContext"; import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; +// 조건부 표시 평가 함수 +function evaluateConditional( + conditional: ComponentData["conditional"], + formData: Record, + allComponents: ComponentData[], +): { visible: boolean; disabled: boolean } { + if (!conditional || !conditional.enabled) { + return { visible: true, disabled: false }; + } + + const { field, operator, value, action } = conditional; + + // 참조 필드의 현재 값 가져오기 + // 필드 ID로 컴포넌트를 찾아 columnName 또는 id로 formData에서 값 조회 + const refComponent = allComponents.find((c) => c.id === field); + const fieldName = (refComponent as any)?.columnName || field; + const fieldValue = formData[fieldName]; + + // 조건 평가 + let conditionMet = false; + switch (operator) { + case "=": + conditionMet = fieldValue === value || String(fieldValue) === String(value); + break; + case "!=": + conditionMet = fieldValue !== value && String(fieldValue) !== String(value); + break; + case ">": + conditionMet = Number(fieldValue) > Number(value); + break; + case "<": + conditionMet = Number(fieldValue) < Number(value); + break; + case "in": + if (Array.isArray(value)) { + conditionMet = value.includes(fieldValue) || value.map(String).includes(String(fieldValue)); + } + break; + case "notIn": + if (Array.isArray(value)) { + conditionMet = !value.includes(fieldValue) && !value.map(String).includes(String(fieldValue)); + } else { + conditionMet = true; + } + break; + case "isEmpty": + conditionMet = + fieldValue === null || + fieldValue === undefined || + fieldValue === "" || + (Array.isArray(fieldValue) && fieldValue.length === 0); + break; + case "isNotEmpty": + conditionMet = + fieldValue !== null && + fieldValue !== undefined && + fieldValue !== "" && + !(Array.isArray(fieldValue) && fieldValue.length === 0); + break; + default: + conditionMet = true; + } + + // 액션에 따른 결과 반환 + switch (action) { + case "show": + return { visible: conditionMet, disabled: false }; + case "hide": + return { visible: !conditionMet, disabled: false }; + case "enable": + return { visible: true, disabled: !conditionMet }; + case "disable": + return { visible: true, disabled: conditionMet }; + default: + return { visible: true, disabled: false }; + } +} + // 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록 import "@/lib/registry/components/ButtonRenderer"; import "@/lib/registry/components/CardRenderer"; @@ -56,7 +134,7 @@ interface InteractiveScreenViewerProps { // 원본 데이터 (수정 모드에서 UPDATE 판단용) originalData?: Record | null; // 탭 관련 정보 (탭 내부의 컴포넌트에서 사용) - parentTabId?: string; // 부모 탭 ID + parentTabId?: string; // 부모 탭 ID parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID } @@ -334,6 +412,14 @@ export const InteractiveScreenViewerDynamic: React.FC { + // 조건부 표시 평가 + const conditionalResult = evaluateConditional(comp.conditional, formData, allComponents); + + // 조건에 따라 숨김 처리 + if (!conditionalResult.visible) { + return null; + } + // 데이터 테이블 컴포넌트 처리 if (isDataTableComponent(comp)) { return ( @@ -431,6 +517,9 @@ export const InteractiveScreenViewerDynamic: React.FC handleFormDataChange(fieldName, e.target.value)} placeholder={`${widgetType} (렌더링 오류)`} - disabled={readonly} + disabled={readonly || isConditionallyDisabled} required={required} className="h-full w-full" /> @@ -486,7 +576,7 @@ export const InteractiveScreenViewerDynamic: React.FC handleFormDataChange(fieldName, e.target.value)} placeholder={placeholder || "입력하세요"} - disabled={readonly} + disabled={readonly || isConditionallyDisabled} required={required} className="h-full w-full" /> @@ -593,7 +683,7 @@ export const InteractiveScreenViewerDynamic: React.FC { // componentConfig에서 quickInsertConfig 가져오기 const quickInsertConfig = (comp as any).componentConfig?.action?.quickInsertConfig; - + if (!quickInsertConfig?.targetTable) { toast.error("대상 테이블이 설정되지 않았습니다."); return; @@ -604,7 +694,7 @@ export const InteractiveScreenViewerDynamic: React.FC = {}; const columnMappings = quickInsertConfig.columnMappings || []; - + for (const mapping of columnMappings) { let value: any; @@ -681,31 +771,31 @@ export const InteractiveScreenViewerDynamic: React.FC 0) { const leftData = splitPanelContext.selectedLeftData; console.log("📍 좌측 패널 자동 매핑 시작:", leftData); - + for (const [key, val] of Object.entries(leftData)) { // 이미 매핑된 컬럼은 스킵 if (insertData[key] !== undefined) { continue; } - + // 대상 테이블에 해당 컬럼이 없으면 스킵 if (!targetTableColumns.includes(key)) { continue; } - + // 시스템 컬럼 제외 - const systemColumns = ['id', 'created_date', 'updated_date', 'writer', 'writer_name']; + const systemColumns = ["id", "created_date", "updated_date", "writer", "writer_name"]; if (systemColumns.includes(key)) { continue; } - + // _label, _name 으로 끝나는 표시용 컬럼 제외 - if (key.endsWith('_label') || key.endsWith('_name')) { + if (key.endsWith("_label") || key.endsWith("_name")) { continue; } - + // 값이 있으면 자동 추가 - if (val !== undefined && val !== null && val !== '') { + if (val !== undefined && val !== null && val !== "") { insertData[key] = val; console.log(`📍 자동 매핑 추가: ${key} = ${val}`); } @@ -724,7 +814,7 @@ export const InteractiveScreenViewerDynamic: React.FC 0) { try { const { default: apiClient } = await import("@/lib/api/client"); - + // 중복 체크를 위한 검색 조건 구성 const searchConditions: Record = {}; for (const col of quickInsertConfig.duplicateCheck.columns) { @@ -736,14 +826,11 @@ export const InteractiveScreenViewerDynamic: React.FC setPopupScreen(null)}> { - const widgetType = col.widgetType || col.widget_type || col.webType || col.web_type; + // widgetType 결정: inputType(entity 등) > webType > widget_type + const inputType = col.inputType || col.input_type; + const widgetType = inputType || col.widgetType || col.widget_type || col.webType || col.web_type; + + // detailSettings 파싱 (문자열이면 JSON 파싱) + let detailSettings = col.detailSettings || col.detail_settings; + if (typeof detailSettings === "string") { + try { + detailSettings = JSON.parse(detailSettings); + } catch (e) { + console.warn("detailSettings 파싱 실패:", e); + detailSettings = {}; + } + } + + // 엔티티 타입 디버깅 + if (inputType === "entity" || widgetType === "entity") { + console.log("🔍 엔티티 컬럼 감지:", { + columnName: col.columnName || col.column_name, + inputType, + widgetType, + detailSettings, + referenceTable: detailSettings?.referenceTable || col.referenceTable || col.reference_table, + }); + } return { tableName: col.tableName || tableName, @@ -950,7 +974,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, dataType: col.dataType || col.data_type || col.dbType, webType: col.webType || col.web_type, - input_type: col.inputType || col.input_type, + input_type: inputType, + inputType: inputType, widgetType, isNullable: col.isNullable || col.is_nullable, required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO", @@ -958,10 +983,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD characterMaximumLength: col.characterMaximumLength || col.character_maximum_length, codeCategory: col.codeCategory || col.code_category, codeValue: col.codeValue || col.code_value, - // 엔티티 타입용 참조 테이블 정보 - referenceTable: col.referenceTable || col.reference_table, - referenceColumn: col.referenceColumn || col.reference_column, - displayColumn: col.displayColumn || col.display_column, + // 엔티티 타입용 참조 테이블 정보 (detailSettings에서 추출) + referenceTable: detailSettings?.referenceTable || col.referenceTable || col.reference_table, + referenceColumn: detailSettings?.referenceColumn || col.referenceColumn || col.reference_column, + displayColumn: detailSettings?.displayColumn || col.displayColumn || col.display_column, + // detailSettings 전체 보존 (Unified 컴포넌트용) + detailSettings, }; }); @@ -2578,28 +2605,40 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const relativeX = e.clientX - containerRect.left; const relativeY = e.clientY - containerRect.top; - // 웹타입을 새로운 컴포넌트 ID로 매핑 - const componentId = getComponentIdFromWebType(column.widgetType); - // console.log(`🔄 폼 컨테이너 드롭: ${column.widgetType} → ${componentId}`); + // 🆕 Unified 컴포넌트 매핑 사용 + const unifiedMapping = createUnifiedConfigFromColumn({ + widgetType: column.widgetType, + columnName: column.columnName, + columnLabel: column.columnLabel, + codeCategory: column.codeCategory, + inputType: column.inputType, + required: column.required, + detailSettings: column.detailSettings, // 엔티티 참조 정보 전달 + // column_labels 직접 필드도 전달 + referenceTable: column.referenceTable, + referenceColumn: column.referenceColumn, + displayColumn: column.displayColumn, + }); // 웹타입별 기본 너비 계산 (10px 단위 고정) const componentWidth = getDefaultWidth(column.widgetType); - console.log("🎯 폼 컨테이너 컴포넌트 생성:", { + console.log("🎯 폼 컨테이너 Unified 컴포넌트 생성:", { widgetType: column.widgetType, + unifiedType: unifiedMapping.componentType, componentWidth, }); newComponent = { id: generateComponentId(), - type: "component", // ✅ 새로운 컴포넌트 시스템 사용 + type: "component", // ✅ Unified 컴포넌트 시스템 사용 label: column.columnLabel || column.columnName, tableName: table.tableName, columnName: column.columnName, required: column.required, readonly: false, parentId: formContainerId, // 폼 컨테이너의 자식으로 설정 - componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입 + componentType: unifiedMapping.componentType, // unified-input, unified-select 등 position: { x: relativeX, y: relativeY, z: 1 } as Position, size: { width: componentWidth, height: getDefaultHeight(column.widgetType) }, // 코드 타입인 경우 코드 카테고리 정보 추가 @@ -2615,43 +2654,47 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD labelMarginBottom: "6px", }, componentConfig: { - type: componentId, // text-input, number-input 등 - webType: column.widgetType, // 원본 웹타입 보존 - inputType: column.inputType, // ✅ input_type 추가 (category 등) - ...getDefaultWebTypeConfig(column.widgetType), - placeholder: column.columnLabel || column.columnName, // placeholder에 라벨명 표시 - // 코드 타입인 경우 코드 카테고리 정보 추가 - ...(column.widgetType === "code" && - column.codeCategory && { - codeCategory: column.codeCategory, - }), + type: unifiedMapping.componentType, // unified-input, unified-select 등 + ...unifiedMapping.componentConfig, // Unified 컴포넌트 기본 설정 }, }; } else { return; // 폼 컨테이너를 찾을 수 없으면 드롭 취소 } } else { - // 일반 캔버스에 드롭한 경우 - 새로운 컴포넌트 시스템 사용 - const componentId = getComponentIdFromWebType(column.widgetType); - // console.log(`🔄 캔버스 드롭: ${column.widgetType} → ${componentId}`); + // 일반 캔버스에 드롭한 경우 - 🆕 Unified 컴포넌트 시스템 사용 + const unifiedMapping = createUnifiedConfigFromColumn({ + widgetType: column.widgetType, + columnName: column.columnName, + columnLabel: column.columnLabel, + codeCategory: column.codeCategory, + inputType: column.inputType, + required: column.required, + detailSettings: column.detailSettings, // 엔티티 참조 정보 전달 + // column_labels 직접 필드도 전달 + referenceTable: column.referenceTable, + referenceColumn: column.referenceColumn, + displayColumn: column.displayColumn, + }); // 웹타입별 기본 너비 계산 (10px 단위 고정) const componentWidth = getDefaultWidth(column.widgetType); - console.log("🎯 캔버스 컴포넌트 생성:", { + console.log("🎯 캔버스 Unified 컴포넌트 생성:", { widgetType: column.widgetType, + unifiedType: unifiedMapping.componentType, componentWidth, }); newComponent = { id: generateComponentId(), - type: "component", // ✅ 새로운 컴포넌트 시스템 사용 + type: "component", // ✅ Unified 컴포넌트 시스템 사용 label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명 tableName: table.tableName, columnName: column.columnName, required: column.required, readonly: false, - componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입 + componentType: unifiedMapping.componentType, // unified-input, unified-select 등 position: { x, y, z: 1 } as Position, size: { width: componentWidth, height: getDefaultHeight(column.widgetType) }, // 코드 타입인 경우 코드 카테고리 정보 추가 @@ -2667,16 +2710,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD labelMarginBottom: "8px", }, componentConfig: { - type: componentId, // text-input, number-input 등 - webType: column.widgetType, // 원본 웹타입 보존 - inputType: column.inputType, // ✅ input_type 추가 (category 등) - ...getDefaultWebTypeConfig(column.widgetType), - placeholder: column.columnLabel || column.columnName, // placeholder에 라벨명 표시 - // 코드 타입인 경우 코드 카테고리 정보 추가 - ...(column.widgetType === "code" && - column.codeCategory && { - codeCategory: column.codeCategory, - }), + type: unifiedMapping.componentType, // unified-input, unified-select 등 + ...unifiedMapping.componentConfig, // Unified 컴포넌트 기본 설정 }, }; } diff --git a/frontend/components/screen/panels/ComponentsPanel.tsx b/frontend/components/screen/panels/ComponentsPanel.tsx index 66d29fef..4ef7afda 100644 --- a/frontend/components/screen/panels/ComponentsPanel.tsx +++ b/frontend/components/screen/panels/ComponentsPanel.tsx @@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ComponentRegistry } from "@/lib/registry/ComponentRegistry"; import { ComponentDefinition, ComponentCategory } from "@/types/component"; -import { Search, Package, Grid, Layers, Palette, Zap, MousePointer, Edit3, BarChart3, Database, Wrench } from "lucide-react"; +import { Search, Package, Grid, Layers, Palette, Zap, MousePointer, Edit3, BarChart3, Database, Wrench, Sparkles } from "lucide-react"; import { TableInfo, ColumnInfo } from "@/types/screen"; import TablesPanel from "./TablesPanel"; @@ -52,6 +52,82 @@ export function ComponentsPanel({ return components; }, []); + // Unified 컴포넌트 정의 (새로운 통합 컴포넌트 시스템) + const unifiedComponents: ComponentDefinition[] = useMemo(() => [ + { + id: "unified-input", + name: "통합 입력", + description: "텍스트, 숫자, 비밀번호, 슬라이더, 컬러 등 다양한 입력 타입 지원", + category: "input" as ComponentCategory, + tags: ["input", "text", "number", "unified"], + defaultSize: { width: 200, height: 40 }, + }, + { + id: "unified-select", + name: "통합 선택", + description: "드롭다운, 라디오, 체크박스, 태그, 토글 등 다양한 선택 타입 지원", + category: "input" as ComponentCategory, + tags: ["select", "dropdown", "radio", "checkbox", "unified"], + defaultSize: { width: 200, height: 40 }, + }, + { + id: "unified-date", + name: "통합 날짜", + description: "날짜, 시간, 날짜시간, 날짜 범위 등 다양한 날짜 타입 지원", + category: "input" as ComponentCategory, + tags: ["date", "time", "datetime", "unified"], + defaultSize: { width: 200, height: 40 }, + }, + { + id: "unified-list", + name: "통합 목록", + description: "테이블, 카드, 칸반, 리스트 등 다양한 데이터 표시 방식 지원", + category: "display" as ComponentCategory, + tags: ["table", "list", "card", "kanban", "unified"], + defaultSize: { width: 600, height: 400 }, + }, + { + id: "unified-layout", + name: "통합 레이아웃", + description: "그리드, 분할 패널, 플렉스 등 다양한 레이아웃 지원", + category: "layout" as ComponentCategory, + tags: ["grid", "split", "flex", "unified"], + defaultSize: { width: 400, height: 300 }, + }, + { + id: "unified-group", + name: "통합 그룹", + description: "탭, 아코디언, 섹션, 카드섹션, 모달 등 다양한 그룹핑 지원", + category: "layout" as ComponentCategory, + tags: ["tabs", "accordion", "section", "modal", "unified"], + defaultSize: { width: 400, height: 300 }, + }, + { + id: "unified-media", + name: "통합 미디어", + description: "이미지, 비디오, 오디오, 파일 업로드 등 미디어 컴포넌트", + category: "display" as ComponentCategory, + tags: ["image", "video", "audio", "file", "unified"], + defaultSize: { width: 300, height: 200 }, + }, + { + id: "unified-biz", + name: "통합 비즈니스", + description: "플로우 다이어그램, 랙 구조, 채번규칙 등 비즈니스 컴포넌트", + category: "utility" as ComponentCategory, + tags: ["flow", "rack", "numbering", "unified"], + defaultSize: { width: 600, height: 400 }, + }, + { + id: "unified-hierarchy", + name: "통합 계층", + description: "트리, 조직도, BOM, 연쇄 선택박스 등 계층 구조 컴포넌트", + category: "data" as ComponentCategory, + tags: ["tree", "org", "bom", "cascading", "unified"], + defaultSize: { width: 400, height: 300 }, + }, + ], []); + // 카테고리별 컴포넌트 그룹화 const componentsByCategory = useMemo(() => { // 숨길 컴포넌트 ID 목록 (기본 입력 컴포넌트들) @@ -66,8 +142,9 @@ export function ComponentsPanel({ data: allComponents.filter((c) => c.category === ComponentCategory.DATA), // 🆕 데이터 카테고리 추가 layout: allComponents.filter((c) => c.category === ComponentCategory.LAYOUT), utility: allComponents.filter((c) => c.category === ComponentCategory.UTILITY), + unified: unifiedComponents, // 🆕 Unified 컴포넌트 카테고리 추가 }; - }, [allComponents]); + }, [allComponents, unifiedComponents]); // 카테고리별 검색 필터링 const getFilteredComponents = (category: keyof typeof componentsByCategory) => { @@ -187,19 +264,28 @@ export function ComponentsPanel({
{/* 카테고리 탭 */} - - + + + {/* 1행: Unified, 테이블, 입력, 데이터 */} + + + Unified + - 테이블 + 테이블 - 입력 + 입력 - 데이터 + 데이터 + {/* 2행: 액션, 표시, 레이아웃, 유틸리티 */} - 액션 + 액션 - 표시 + 표시 - 레이아웃 + 레이아웃 - 유틸리티 + 유틸 + {/* Unified 컴포넌트 탭 */} + +
+

+ Unified 컴포넌트는 속성 기반으로 다양한 기능을 지원하는 새로운 컴포넌트입니다. +

+
+ {getFilteredComponents("unified").length > 0 + ? getFilteredComponents("unified").map(renderComponentCard) + : renderEmptyState()} +
+ {/* 테이블 탭 */} {tables.length > 0 && onTableDragStart ? ( diff --git a/frontend/components/screen/panels/DetailSettingsPanel.tsx b/frontend/components/screen/panels/DetailSettingsPanel.tsx index f3795634..a7fcc5e1 100644 --- a/frontend/components/screen/panels/DetailSettingsPanel.tsx +++ b/frontend/components/screen/panels/DetailSettingsPanel.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useState, useEffect } from "react"; -import { Settings, Database } from "lucide-react"; +import { Settings, Database, Zap } from "lucide-react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; @@ -22,6 +22,8 @@ import { FileComponentConfigPanel } from "./FileComponentConfigPanel"; import { WebTypeConfigPanel } from "./WebTypeConfigPanel"; import { isFileComponent } from "@/lib/utils/componentTypeUtils"; import { BaseInputType, getBaseInputType, getDetailTypes, DetailTypeOption } from "@/types/input-type-mapping"; +import { ConditionalConfigPanel } from "@/components/unified/ConditionalConfigPanel"; +import { ConditionalConfig } from "@/types/unified-components"; // 새로운 컴포넌트 설정 패널들 import import { ButtonConfigPanel as NewButtonConfigPanel } from "../config-panels/ButtonConfigPanel"; @@ -1192,7 +1194,28 @@ export const DetailSettingsPanel: React.FC = ({ }} /> - {/* 🆕 테이블 데이터 자동 입력 섹션 (component 타입용) */} + {/* 조건부 표시 설정 (component 타입용) */} +
+ { + onUpdateProperty(selectedComponent.id, "conditional", newConfig); + }} + availableFields={components + .filter((c) => c.id !== selectedComponent.id && (c.type === "widget" || c.type === "component")) + .map((c) => ({ + id: c.id, + label: (c as any).label || (c as any).columnName || c.id, + type: (c as any).widgetType || (c as any).componentConfig?.webType, + options: (c as any).webTypeConfig?.options || [], + }))} + currentComponentId={selectedComponent.id} + /> +
+ + + + {/* 테이블 데이터 자동 입력 섹션 (component 타입용) */}

@@ -1400,9 +1423,29 @@ export const DetailSettingsPanel: React.FC = ({ {/* 상세 설정 영역 */}
- {console.log("🔍 [DetailSettingsPanel] widget 타입:", selectedComponent?.type, "autoFill:", widget.autoFill)} - {/* 🆕 자동 입력 섹션 */} -
+ {/* 조건부 표시 설정 */} +
+ { + onUpdateProperty(widget.id, "conditional", newConfig); + }} + availableFields={components + .filter((c) => c.id !== widget.id && (c.type === "widget" || c.type === "component")) + .map((c) => ({ + id: c.id, + label: (c as any).label || (c as any).columnName || c.id, + type: (c as any).widgetType || (c as any).componentConfig?.webType, + options: (c as any).webTypeConfig?.options || [], + }))} + currentComponentId={widget.id} + /> +
+ + + + {/* 자동 입력 섹션 */} +

🔥 테이블 데이터 자동 입력 (테스트) diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index ad34df9a..b8ec467a 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -62,6 +62,8 @@ import StyleEditor from "../StyleEditor"; import ResolutionPanel from "./ResolutionPanel"; import { Slider } from "@/components/ui/slider"; import { Grid3X3, Eye, EyeOff, Zap } from "lucide-react"; +import { ConditionalConfigPanel } from "@/components/unified/ConditionalConfigPanel"; +import { ConditionalConfig } from "@/types/unified-components"; interface UnifiedPropertiesPanelProps { selectedComponent?: ComponentData; @@ -313,6 +315,51 @@ export const UnifiedPropertiesPanel: React.FC = ({ selectedComponent.componentConfig?.id || (selectedComponent.type === "component" ? selectedComponent.id : null); // 🆕 독립 컴포넌트 (table-search-widget 등) + // 🆕 Unified 컴포넌트 직접 감지 및 설정 패널 렌더링 + if (componentId?.startsWith("unified-")) { + const unifiedConfigPanels: Record void }>> = { + "unified-input": require("@/components/unified/config-panels/UnifiedInputConfigPanel").UnifiedInputConfigPanel, + "unified-select": require("@/components/unified/config-panels/UnifiedSelectConfigPanel").UnifiedSelectConfigPanel, + "unified-date": require("@/components/unified/config-panels/UnifiedDateConfigPanel").UnifiedDateConfigPanel, + "unified-list": require("@/components/unified/config-panels/UnifiedListConfigPanel").UnifiedListConfigPanel, + "unified-layout": require("@/components/unified/config-panels/UnifiedLayoutConfigPanel").UnifiedLayoutConfigPanel, + "unified-group": require("@/components/unified/config-panels/UnifiedGroupConfigPanel").UnifiedGroupConfigPanel, + "unified-media": require("@/components/unified/config-panels/UnifiedMediaConfigPanel").UnifiedMediaConfigPanel, + "unified-biz": require("@/components/unified/config-panels/UnifiedBizConfigPanel").UnifiedBizConfigPanel, + "unified-hierarchy": require("@/components/unified/config-panels/UnifiedHierarchyConfigPanel").UnifiedHierarchyConfigPanel, + }; + + const UnifiedConfigPanel = unifiedConfigPanels[componentId]; + if (UnifiedConfigPanel) { + const currentConfig = selectedComponent.componentConfig || {}; + const handleUnifiedConfigChange = (newConfig: any) => { + onUpdateProperty(selectedComponent.id, "componentConfig", { ...currentConfig, ...newConfig }); + }; + + const unifiedNames: Record = { + "unified-input": "통합 입력", + "unified-select": "통합 선택", + "unified-date": "통합 날짜", + "unified-list": "통합 목록", + "unified-layout": "통합 레이아웃", + "unified-group": "통합 그룹", + "unified-media": "통합 미디어", + "unified-biz": "통합 비즈니스", + "unified-hierarchy": "통합 계층", + }; + + return ( +
+
+ +

{unifiedNames[componentId] || componentId} 설정

+
+ +
+ ); + } + } + if (componentId) { const definition = ComponentRegistry.getComponent(componentId); @@ -989,6 +1036,18 @@ export const UnifiedPropertiesPanel: React.FC = ({ ); } + // 🆕 3.5. Unified 컴포넌트 - 반드시 다른 체크보다 먼저 처리 + const unifiedComponentType = + (selectedComponent as any).componentType || + selectedComponent.componentConfig?.type || + ""; + if (unifiedComponentType.startsWith("unified-")) { + const configPanel = renderComponentConfigPanel(); + if (configPanel) { + return
{configPanel}
; + } + } + // 4. 새로운 컴포넌트 시스템 (button, card 등) const componentType = selectedComponent.componentConfig?.type || selectedComponent.type; const hasNewConfigPanel = @@ -1468,6 +1527,36 @@ export const UnifiedPropertiesPanel: React.FC = ({ {renderDetailTab()} + {/* 조건부 표시 설정 */} + {selectedComponent && ( + <> + +
+
+ +

조건부 표시

+
+
+ { + handleUpdate("conditional", newConfig); + }} + availableFields={ + allComponents + ?.filter((c) => c.type === "widget" && c.id !== selectedComponent.id) + .map((c) => ({ + id: (c as any).columnName || c.id, + label: (c as any).label || c.id, + type: (c as any).widgetType || "text", + })) || [] + } + /> +
+
+ + )} + {/* 스타일 설정 */} {selectedComponent && ( <> diff --git a/frontend/components/unified/ConditionalConfigPanel.tsx b/frontend/components/unified/ConditionalConfigPanel.tsx new file mode 100644 index 00000000..ba6dee1a --- /dev/null +++ b/frontend/components/unified/ConditionalConfigPanel.tsx @@ -0,0 +1,359 @@ +"use client"; + +/** + * ConditionalConfigPanel + * + * 비개발자도 쉽게 조건부 표시/숨김/활성화/비활성화를 설정할 수 있는 UI + * + * 사용처: + * - 화면관리 > 상세설정 패널 + * - 화면관리 > 속성 패널 + */ + +import React, { useState, useEffect, useMemo } from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Zap, Plus, Trash2, HelpCircle } from "lucide-react"; +import { ConditionalConfig } from "@/types/unified-components"; + +// ===== 타입 정의 ===== + +interface FieldOption { + id: string; + label: string; + type?: string; // text, number, select, checkbox 등 + options?: Array<{ value: string; label: string }>; // select 타입일 경우 옵션들 +} + +interface ConditionalConfigPanelProps { + /** 현재 조건부 설정 */ + config?: ConditionalConfig; + /** 설정 변경 콜백 */ + onChange: (config: ConditionalConfig | undefined) => void; + /** 같은 화면에 있는 다른 필드들 (조건 필드로 선택 가능) */ + availableFields: FieldOption[]; + /** 현재 컴포넌트 ID (자기 자신은 조건 필드에서 제외) */ + currentComponentId?: string; +} + +// 연산자 옵션 +const OPERATORS: Array<{ value: ConditionalConfig["operator"]; label: string; description: string }> = [ + { value: "=", label: "같음", description: "값이 정확히 일치할 때" }, + { value: "!=", label: "다름", description: "값이 일치하지 않을 때" }, + { value: ">", label: "보다 큼", description: "값이 더 클 때 (숫자)" }, + { value: "<", label: "보다 작음", description: "값이 더 작을 때 (숫자)" }, + { value: "in", label: "포함됨", description: "여러 값 중 하나일 때" }, + { value: "notIn", label: "포함 안됨", description: "여러 값 중 아무것도 아닐 때" }, + { value: "isEmpty", label: "비어있음", description: "값이 없을 때" }, + { value: "isNotEmpty", label: "값이 있음", description: "값이 있을 때" }, +]; + +// 동작 옵션 +const ACTIONS: Array<{ value: ConditionalConfig["action"]; label: string; description: string }> = [ + { value: "show", label: "표시", description: "조건 만족 시 이 필드를 표시" }, + { value: "hide", label: "숨김", description: "조건 만족 시 이 필드를 숨김" }, + { value: "enable", label: "활성화", description: "조건 만족 시 이 필드를 활성화" }, + { value: "disable", label: "비활성화", description: "조건 만족 시 이 필드를 비활성화" }, +]; + +// ===== 컴포넌트 ===== + +export function ConditionalConfigPanel({ + config, + onChange, + availableFields, + currentComponentId, +}: ConditionalConfigPanelProps) { + // 로컬 상태 + const [enabled, setEnabled] = useState(config?.enabled ?? false); + const [field, setField] = useState(config?.field ?? ""); + const [operator, setOperator] = useState(config?.operator ?? "="); + const [value, setValue] = useState(String(config?.value ?? "")); + const [action, setAction] = useState(config?.action ?? "show"); + + // 자기 자신을 제외한 필드 목록 + const selectableFields = useMemo(() => { + return availableFields.filter((f) => f.id !== currentComponentId); + }, [availableFields, currentComponentId]); + + // 선택된 필드 정보 + const selectedField = useMemo(() => { + return selectableFields.find((f) => f.id === field); + }, [selectableFields, field]); + + // config prop 변경 시 로컬 상태 동기화 + useEffect(() => { + setEnabled(config?.enabled ?? false); + setField(config?.field ?? ""); + setOperator(config?.operator ?? "="); + setValue(String(config?.value ?? "")); + setAction(config?.action ?? "show"); + }, [config]); + + // 설정 변경 시 부모에게 알림 + const updateConfig = (updates: Partial) => { + const newConfig: ConditionalConfig = { + enabled: updates.enabled ?? enabled, + field: updates.field ?? field, + operator: updates.operator ?? operator, + value: updates.value ?? value, + action: updates.action ?? action, + }; + + // enabled가 false이면 undefined 반환 (설정 제거) + if (!newConfig.enabled) { + onChange(undefined); + } else { + onChange(newConfig); + } + }; + + // 활성화 토글 + const handleEnabledChange = (checked: boolean) => { + setEnabled(checked); + updateConfig({ enabled: checked }); + }; + + // 조건 필드 변경 + const handleFieldChange = (newField: string) => { + setField(newField); + setValue(""); // 필드 변경 시 값 초기화 + updateConfig({ field: newField, value: "" }); + }; + + // 연산자 변경 + const handleOperatorChange = (newOperator: ConditionalConfig["operator"]) => { + setOperator(newOperator); + // 비어있음/값이있음 연산자는 value 필요 없음 + if (newOperator === "isEmpty" || newOperator === "isNotEmpty") { + setValue(""); + updateConfig({ operator: newOperator, value: "" }); + } else { + updateConfig({ operator: newOperator }); + } + }; + + // 값 변경 + const handleValueChange = (newValue: string) => { + setValue(newValue); + + // 타입에 따라 적절한 값으로 변환 + let parsedValue: unknown = newValue; + if (selectedField?.type === "number") { + parsedValue = Number(newValue); + } else if (newValue === "true") { + parsedValue = true; + } else if (newValue === "false") { + parsedValue = false; + } + + updateConfig({ value: parsedValue }); + }; + + // 동작 변경 + const handleActionChange = (newAction: ConditionalConfig["action"]) => { + setAction(newAction); + updateConfig({ action: newAction }); + }; + + // 값 입력 필드 렌더링 (필드 타입에 따라 다르게) + const renderValueInput = () => { + // 비어있음/값이있음은 값 입력 불필요 + if (operator === "isEmpty" || operator === "isNotEmpty") { + return ( +
+ (값 입력 불필요) +
+ ); + } + + // 선택된 필드에 옵션이 있으면 Select로 표시 + if (selectedField?.options && selectedField.options.length > 0) { + return ( + + ); + } + + // 체크박스 타입이면 true/false Select + if (selectedField?.type === "checkbox" || selectedField?.type === "boolean") { + return ( + + ); + } + + // 숫자 타입 + if (selectedField?.type === "number") { + return ( + handleValueChange(e.target.value)} + placeholder="숫자 입력" + className="h-8 text-xs" + /> + ); + } + + // 기본: 텍스트 입력 + return ( + handleValueChange(e.target.value)} + placeholder="값 입력" + className="h-8 text-xs" + /> + ); + }; + + return ( +
+ {/* 헤더 */} +
+
+ + 조건부 표시 + + + +
+ +
+ + {/* 조건 설정 영역 */} + {enabled && ( +
+ {/* 조건 필드 선택 */} +
+ + +

+ 이 필드의 값에 따라 조건이 적용됩니다 +

+
+ + {/* 연산자 선택 */} +
+ + +
+ + {/* 값 입력 */} +
+ + {renderValueInput()} +
+ + {/* 동작 선택 */} +
+ + +

+ 조건이 만족되면 이 필드를 {ACTIONS.find(a => a.value === action)?.label}합니다 +

+
+ + {/* 미리보기 */} + {field && ( +
+

설정 요약:

+

+ "{selectableFields.find(f => f.id === field)?.label || field}" 필드가{" "} + + {operator === "isEmpty" ? "비어있으면" : + operator === "isNotEmpty" ? "값이 있으면" : + `"${value}"${operator === "=" ? "이면" : + operator === "!=" ? "이 아니면" : + operator === ">" ? "보다 크면" : + operator === "<" ? "보다 작으면" : + operator === "in" ? "에 포함되면" : "에 포함되지 않으면"}`} + {" "} + → 이 필드를{" "} + + {action === "show" ? "표시" : + action === "hide" ? "숨김" : + action === "enable" ? "활성화" : "비활성화"} + +

+
+ )} +
+ )} +
+ ); +} + +export default ConditionalConfigPanel; + diff --git a/frontend/components/unified/DynamicConfigPanel.tsx b/frontend/components/unified/DynamicConfigPanel.tsx new file mode 100644 index 00000000..514bda89 --- /dev/null +++ b/frontend/components/unified/DynamicConfigPanel.tsx @@ -0,0 +1,372 @@ +"use client"; + +/** + * DynamicConfigPanel + * + * JSON Schema 기반으로 동적으로 설정 UI를 생성하는 패널 + * 모든 Unified 컴포넌트의 설정을 단일 컴포넌트로 처리 + */ + +import React, { useCallback, useMemo } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Slider } from "@/components/ui/slider"; +import { Textarea } from "@/components/ui/textarea"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { ChevronDown } from "lucide-react"; +import { JSONSchemaProperty, UnifiedConfigSchema } from "@/types/unified-components"; +import { cn } from "@/lib/utils"; + +interface DynamicConfigPanelProps { + schema: UnifiedConfigSchema; + config: Record; + onChange: (key: string, value: unknown) => void; + className?: string; +} + +/** + * 개별 스키마 속성을 렌더링하는 컴포넌트 + */ +function SchemaField({ + name, + property, + value, + onChange, + path = [], +}: { + name: string; + property: JSONSchemaProperty; + value: unknown; + onChange: (key: string, value: unknown) => void; + path?: string[]; +}) { + const fieldPath = [...path, name].join("."); + + // 값 변경 핸들러 + const handleChange = useCallback( + (newValue: unknown) => { + onChange(fieldPath, newValue); + }, + [fieldPath, onChange] + ); + + // 타입에 따른 컴포넌트 렌더링 + const renderField = () => { + // enum이 있으면 Select 렌더링 + if (property.enum && property.enum.length > 0) { + return ( + + ); + } + + // 타입별 렌더링 + switch (property.type) { + case "string": + return ( + handleChange(e.target.value)} + placeholder={property.description} + className="h-8 text-xs" + /> + ); + + case "number": + return ( + handleChange(e.target.value ? Number(e.target.value) : undefined)} + placeholder={property.description} + className="h-8 text-xs" + /> + ); + + case "boolean": + return ( + + ); + + case "array": + // 배열은 간단한 텍스트 입력으로 처리 (쉼표 구분) + return ( +