feat: 카테고리 값 삭제 기능 개선 및 하위 카테고리 수집 로직 추가
- 카테고리 값 삭제 시, 자기 자신과 모든 하위 카테고리 ID를 재귀적으로 수집하는 기능을 추가하였습니다. - 삭제 대상 카테고리 값 수집 완료 후, 하위 카테고리부터 역순으로 삭제하는 로직을 구현하였습니다. - 관련된 로그 메시지를 추가하여 삭제 과정과 결과를 기록하도록 하였습니다. - 화면 관리 기능에서 하위 항목 개수를 계산하는 로직을 개선하여 사용자에게 더 정확한 정보를 제공하도록 하였습니다.
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
60
scripts/remove-logs.js
Normal 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`);
|
||||
|
||||
Reference in New Issue
Block a user