From 64cc5c677200d5df913521ebbbdeef0c837a75af Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 27 Jan 2026 10:06:40 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=EA=B0=92=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=ED=95=98=EC=9C=84=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EC=88=98=EC=A7=91=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카테고리 값 삭제 시, 자기 자신과 모든 하위 카테고리 ID를 재귀적으로 수집하는 기능을 추가하였습니다. - 삭제 대상 카테고리 값 수집 완료 후, 하위 카테고리부터 역순으로 삭제하는 로직을 구현하였습니다. - 관련된 로그 메시지를 추가하여 삭제 과정과 결과를 기록하도록 하였습니다. - 화면 관리 기능에서 하위 항목 개수를 계산하는 로직을 개선하여 사용자에게 더 정확한 정보를 제공하도록 하였습니다. --- .../src/services/categoryTreeService.ts | 62 +++++-- .../src/services/tableCategoryValueService.ts | 172 +++++++++++------- .../admin/screenMng/screenMngList/page.tsx | 14 +- frontend/components/screen/ScreenDesigner.tsx | 10 +- .../CategoryValueManagerTree.tsx | 12 +- scripts/remove-logs.js | 60 ++++++ 6 files changed, 248 insertions(+), 82 deletions(-) create mode 100644 scripts/remove-logs.js diff --git a/backend-node/src/services/categoryTreeService.ts b/backend-node/src/services/categoryTreeService.ts index 6cb725c9..da64e869 100644 --- a/backend-node/src/services/categoryTreeService.ts +++ b/backend-node/src/services/categoryTreeService.ts @@ -403,6 +403,33 @@ class CategoryTreeService { } } + /** + * 모든 하위 카테고리 값 ID 재귀 수집 + */ + private async collectAllChildValueIds( + companyCode: string, + valueId: number + ): Promise { + const pool = getPool(); + + // 재귀 CTE를 사용하여 모든 하위 카테고리 수집 + const query = ` + WITH RECURSIVE category_tree AS ( + SELECT value_id FROM category_values_test + WHERE parent_value_id = $1 AND (company_code = $2 OR company_code = '*') + UNION ALL + SELECT cv.value_id + FROM category_values_test cv + INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id + WHERE cv.company_code = $2 OR cv.company_code = '*' + ) + SELECT value_id FROM category_tree + `; + + const result = await pool.query(query, [valueId, companyCode]); + return result.rows.map(row => row.value_id); + } + /** * 카테고리 값 삭제 (하위 항목도 함께 삭제) */ @@ -410,20 +437,33 @@ class CategoryTreeService { const pool = getPool(); try { - const query = ` - DELETE FROM category_values_test - WHERE (company_code = $1 OR company_code = '*') AND value_id = $2 - RETURNING value_id - `; + // 1. 모든 하위 카테고리 ID 수집 + const childValueIds = await this.collectAllChildValueIds(companyCode, valueId); + const allValueIds = [valueId, ...childValueIds]; + + logger.info("삭제 대상 카테고리 값 수집 완료", { + valueId, + childCount: childValueIds.length, + totalCount: allValueIds.length, + }); - const result = await pool.query(query, [companyCode, valueId]); - - if (result.rowCount && result.rowCount > 0) { - logger.info("카테고리 값 삭제 완료", { valueId }); - return true; + // 2. 하위 카테고리부터 역순으로 삭제 (외래키 제약 회피) + const reversedIds = [...allValueIds].reverse(); + + for (const id of reversedIds) { + await pool.query( + `DELETE FROM category_values_test WHERE (company_code = $1 OR company_code = '*') AND value_id = $2`, + [companyCode, id] + ); } - return false; + logger.info("카테고리 값 삭제 완료", { + valueId, + deletedCount: allValueIds.length, + deletedChildCount: childValueIds.length, + }); + + return true; } catch (error: unknown) { const err = error as Error; logger.error("카테고리 값 삭제 실패", { error: err.message, valueId }); diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index bb7ee28a..c4149147 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -619,7 +619,55 @@ class TableCategoryValueService { } /** - * 카테고리 값 삭제 (물리적 삭제) + * 모든 하위 카테고리 값 ID 재귀 수집 + */ + private async collectAllChildValueIds( + valueId: number, + companyCode: string + ): Promise { + const pool = getPool(); + const allChildIds: number[] = []; + + // 재귀 CTE를 사용하여 모든 하위 카테고리 수집 + let query: string; + let params: any[]; + + if (companyCode === "*") { + query = ` + WITH RECURSIVE category_tree AS ( + SELECT value_id FROM table_column_category_values WHERE parent_value_id = $1 + UNION ALL + SELECT cv.value_id + FROM table_column_category_values cv + INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id + ) + SELECT value_id FROM category_tree + `; + params = [valueId]; + } else { + query = ` + WITH RECURSIVE category_tree AS ( + SELECT value_id FROM table_column_category_values + WHERE parent_value_id = $1 AND company_code = $2 + UNION ALL + SELECT cv.value_id + FROM table_column_category_values cv + INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id + WHERE cv.company_code = $2 + ) + SELECT value_id FROM category_tree + `; + params = [valueId, companyCode]; + } + + const result = await pool.query(query, params); + result.rows.forEach(row => allChildIds.push(row.value_id)); + + return allChildIds; + } + + /** + * 카테고리 값 삭제 (하위 카테고리 포함 물리적 삭제) */ async deleteCategoryValue( valueId: number, @@ -629,82 +677,74 @@ class TableCategoryValueService { const pool = getPool(); try { - // 1. 사용 여부 확인 - const usage = await this.checkCategoryValueUsage(valueId, companyCode); + // 1. 자기 자신 + 모든 하위 카테고리 ID 수집 + const childValueIds = await this.collectAllChildValueIds(valueId, companyCode); + const allValueIds = [valueId, ...childValueIds]; + + logger.info("삭제 대상 카테고리 값 수집 완료", { + valueId, + childCount: childValueIds.length, + totalCount: allValueIds.length, + }); - if (usage.isUsed) { - let errorMessage = "이 카테고리 값을 삭제할 수 없습니다.\n"; - errorMessage += `\n현재 ${usage.totalCount}개의 데이터에서 사용 중입니다.`; + // 2. 모든 대상 항목의 사용 여부 확인 + for (const id of allValueIds) { + const usage = await this.checkCategoryValueUsage(id, companyCode); - if (usage.usedInTables.length > 0) { - const menuNames = usage.usedInTables.map((t) => t.menuName).join(", "); - errorMessage += `\n\n다음 메뉴에서 사용 중입니다:\n${menuNames}`; + if (usage.isUsed) { + // 사용 중인 항목 정보 조회 + let labelQuery: string; + let labelParams: any[]; + + if (companyCode === "*") { + labelQuery = `SELECT value_label FROM table_column_category_values WHERE value_id = $1`; + labelParams = [id]; + } else { + labelQuery = `SELECT value_label FROM table_column_category_values WHERE value_id = $1 AND company_code = $2`; + labelParams = [id, companyCode]; + } + + const labelResult = await pool.query(labelQuery, labelParams); + const valueLabel = labelResult.rows[0]?.value_label || `ID:${id}`; + + let errorMessage = `카테고리 "${valueLabel}"을(를) 삭제할 수 없습니다.\n`; + errorMessage += `\n현재 ${usage.totalCount}개의 데이터에서 사용 중입니다.`; + + if (usage.usedInTables.length > 0) { + const menuNames = usage.usedInTables.map((t) => t.menuName).join(", "); + errorMessage += `\n\n다음 메뉴에서 사용 중입니다:\n${menuNames}`; + } + + errorMessage += "\n\n메뉴에서 사용하는 카테고리 항목을 수정한 후 다시 삭제해주세요."; + + throw new Error(errorMessage); } - - errorMessage += "\n\n메뉴에서 사용하는 카테고리 항목을 수정한 후 다시 삭제해주세요."; - - throw new Error(errorMessage); } - // 2. 하위 값 체크 (멀티테넌시 적용) - let checkQuery: string; - let checkParams: any[]; + // 3. 하위 카테고리부터 역순으로 삭제 (외래키 제약 회피) + // 가장 깊은 하위부터 삭제해야 하므로 역순으로 + const reversedIds = [...allValueIds].reverse(); - if (companyCode === "*") { - // 최고 관리자: 모든 하위 값 체크 - checkQuery = ` - SELECT COUNT(*) as count - FROM table_column_category_values - WHERE parent_value_id = $1 - `; - checkParams = [valueId]; - } else { - // 일반 회사: 자신의 하위 값만 체크 - checkQuery = ` - SELECT COUNT(*) as count - FROM table_column_category_values - WHERE parent_value_id = $1 - AND company_code = $2 - `; - checkParams = [valueId, companyCode]; - } - - const checkResult = await pool.query(checkQuery, checkParams); - - if (parseInt(checkResult.rows[0].count) > 0) { - throw new Error("하위 카테고리 값이 있어 삭제할 수 없습니다"); - } - - // 3. 물리적 삭제 (멀티테넌시 적용) - let deleteQuery: string; - let deleteParams: any[]; - - if (companyCode === "*") { - // 최고 관리자: 모든 카테고리 값 삭제 가능 - deleteQuery = ` - DELETE FROM table_column_category_values - WHERE value_id = $1 - `; - deleteParams = [valueId]; - } else { - // 일반 회사: 자신의 카테고리 값만 삭제 가능 - deleteQuery = ` - DELETE FROM table_column_category_values - WHERE value_id = $1 - AND company_code = $2 - `; - deleteParams = [valueId, companyCode]; - } - - const result = await pool.query(deleteQuery, deleteParams); - - if (result.rowCount === 0) { - throw new Error("카테고리 값을 찾을 수 없거나 권한이 없습니다"); + for (const id of reversedIds) { + let deleteQuery: string; + let deleteParams: any[]; + + if (companyCode === "*") { + deleteQuery = `DELETE FROM table_column_category_values WHERE value_id = $1`; + deleteParams = [id]; + } else { + deleteQuery = `DELETE FROM table_column_category_values WHERE value_id = $1 AND company_code = $2`; + deleteParams = [id, companyCode]; + } + + await pool.query(deleteQuery, deleteParams); } logger.info("카테고리 값 삭제 완료", { valueId, companyCode, + deletedCount: allValueIds.length, + deletedChildCount: childValueIds.length, }); } catch (error: any) { logger.error(`카테고리 값 삭제 실패: ${error.message}`); diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index e4b90c34..32ebf967 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -126,7 +126,19 @@ export default function ScreenManagementPage() { if (isDesignMode) { return (
- goToStep("list")} /> + goToStep("list")} + onScreenUpdate={(updatedFields) => { + // 저장 후 화면 정보 즉시 업데이트 (테이블명 등) + if (selectedScreen) { + setSelectedScreen({ + ...selectedScreen, + ...updatedFields, + }); + } + }} + />
); } diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 2e7a5a06..7e72f78f 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -112,6 +112,7 @@ import "@/lib/registry/utils/performanceOptimizer"; interface ScreenDesignerProps { selectedScreen: ScreenDefinition | null; onBackToList: () => void; + onScreenUpdate?: (updatedScreen: Partial) => void; } // 패널 설정 (통합 패널 1개) @@ -127,7 +128,7 @@ const panelConfigs: PanelConfig[] = [ }, ]; -export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) { +export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenUpdate }: ScreenDesignerProps) { // 패널 상태 관리 const { panelStates, togglePanel, openPanel, closePanel } = usePanelState(panelConfigs); @@ -1683,6 +1684,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD console.log("✅ 저장 성공! 메뉴 할당 모달 열기"); toast.success("화면이 저장되었습니다."); + // 저장 성공 후 부모에게 화면 정보 업데이트 알림 (테이블명 즉시 반영) + if (onScreenUpdate && currentMainTableName) { + onScreenUpdate({ tableName: currentMainTableName }); + } + // 저장 성공 후 메뉴 할당 모달 열기 setShowMenuAssignmentModal(true); } catch (error) { @@ -1691,7 +1697,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD } finally { setIsSaving(false); } - }, [selectedScreen, layout, screenResolution, tables]); + }, [selectedScreen, layout, screenResolution, tables, onScreenUpdate]); // 다국어 자동 생성 핸들러 const handleGenerateMultilang = useCallback(async () => { diff --git a/frontend/components/table-category/CategoryValueManagerTree.tsx b/frontend/components/table-category/CategoryValueManagerTree.tsx index f7c991a8..b65c2247 100644 --- a/frontend/components/table-category/CategoryValueManagerTree.tsx +++ b/frontend/components/table-category/CategoryValueManagerTree.tsx @@ -288,6 +288,14 @@ export const CategoryValueManagerTree: React.FC = return count; }, []); + // 하위 항목 개수만 계산 (자기 자신 제외) + const countAllDescendants = useCallback((node: CategoryValue): number => { + if (!node.children || node.children.length === 0) { + return 0; + } + return countAllValues(node.children); + }, [countAllValues]); + // 활성 노드만 필터링 const filterActiveNodes = useCallback((nodes: CategoryValue[]): CategoryValue[] => { return nodes @@ -694,11 +702,11 @@ export const CategoryValueManagerTree: React.FC = 카테고리 삭제 {deletingValue?.valueLabel}을(를) 삭제하시겠습니까? - {deletingValue?.children && deletingValue.children.length > 0 && ( + {deletingValue && countAllDescendants(deletingValue) > 0 && ( <>
- 하위 카테고리 {deletingValue.children.length}개도 모두 함께 삭제됩니다. + 하위 카테고리 {countAllDescendants(deletingValue)}개도 모두 함께 삭제됩니다. )} diff --git a/scripts/remove-logs.js b/scripts/remove-logs.js new file mode 100644 index 00000000..11495f4b --- /dev/null +++ b/scripts/remove-logs.js @@ -0,0 +1,60 @@ +const fs = require('fs'); +const path = require('path'); + +const filePath = path.join(__dirname, '../frontend/lib/utils/buttonActions.ts'); +let content = fs.readFileSync(filePath, 'utf8'); + +// 디버깅 console.log 제거 (전체 줄) +// console.log로 시작하는 줄만 제거 (이모지 포함) +const patterns = [ + // 디버깅 로그 (이모지 포함) + /^\s*console\.log\s*\([^)]*["'`]🔍[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]📦[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]📋[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]🔗[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]🔄[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]🎯[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]✅[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]⏭️[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]📊[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]🏗️[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]📝[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]💾[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]🔐[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]🔑[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]🔒[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]🧹[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]🗑️[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]📂[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]📤[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]📥[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]🔎[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]🆕[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]📌[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]🔥[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]⚡[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]🎉[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]🚀[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]📡[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]🌐[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]👤[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]🚫[^]*?\);\s*$/gm, + /^\s*console\.log\s*\([^)]*["'`]🔧[^]*?\);\s*$/gm, +]; + +let totalRemoved = 0; + +patterns.forEach(pattern => { + const matches = content.match(pattern); + if (matches) { + totalRemoved += matches.length; + content = content.replace(pattern, ''); + } +}); + +// 연속된 빈 줄 제거 (3개 이상의 빈 줄을 2개로) +content = content.replace(/\n\n\n+/g, '\n\n'); + +fs.writeFileSync(filePath, content, 'utf8'); +console.log(`Removed ${totalRemoved} console.log statements`); +