feat: 카테고리 값 삭제 기능 개선 및 하위 카테고리 수집 로직 추가

- 카테고리 값 삭제 시, 자기 자신과 모든 하위 카테고리 ID를 재귀적으로 수집하는 기능을 추가하였습니다.
- 삭제 대상 카테고리 값 수집 완료 후, 하위 카테고리부터 역순으로 삭제하는 로직을 구현하였습니다.
- 관련된 로그 메시지를 추가하여 삭제 과정과 결과를 기록하도록 하였습니다.
- 화면 관리 기능에서 하위 항목 개수를 계산하는 로직을 개선하여 사용자에게 더 정확한 정보를 제공하도록 하였습니다.
This commit is contained in:
kjs
2026-01-27 10:06:40 +09:00
parent 589f5b9222
commit 64cc5c6772
6 changed files with 248 additions and 82 deletions

View File

@@ -403,6 +403,33 @@ class CategoryTreeService {
}
}
/**
* 모든 하위 카테고리 값 ID 재귀 수집
*/
private async collectAllChildValueIds(
companyCode: string,
valueId: number
): Promise<number[]> {
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 });

View File

@@ -619,7 +619,55 @@ class TableCategoryValueService {
}
/**
* 카테고리 값 삭제 (물리적 삭제)
* 모든 하위 카테고리 값 ID 재귀 수집
*/
private async collectAllChildValueIds(
valueId: number,
companyCode: string
): Promise<number[]> {
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}`);

View File

@@ -126,7 +126,19 @@ export default function ScreenManagementPage() {
if (isDesignMode) {
return (
<div className="fixed inset-0 z-50 bg-background">
<ScreenDesigner selectedScreen={selectedScreen} onBackToList={() => goToStep("list")} />
<ScreenDesigner
selectedScreen={selectedScreen}
onBackToList={() => goToStep("list")}
onScreenUpdate={(updatedFields) => {
// 저장 후 화면 정보 즉시 업데이트 (테이블명 등)
if (selectedScreen) {
setSelectedScreen({
...selectedScreen,
...updatedFields,
});
}
}}
/>
</div>
);
}

View File

@@ -112,6 +112,7 @@ import "@/lib/registry/utils/performanceOptimizer";
interface ScreenDesignerProps {
selectedScreen: ScreenDefinition | null;
onBackToList: () => void;
onScreenUpdate?: (updatedScreen: Partial<ScreenDefinition>) => 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 () => {

View File

@@ -288,6 +288,14 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
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<CategoryValueManagerTreeProps> =
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
<strong>{deletingValue?.valueLabel}</strong>() ?
{deletingValue?.children && deletingValue.children.length > 0 && (
{deletingValue && countAllDescendants(deletingValue) > 0 && (
<>
<br />
<span className="text-destructive">
{deletingValue.children.length} .
{countAllDescendants(deletingValue)} .
</span>
</>
)}

60
scripts/remove-logs.js Normal file
View File

@@ -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`);