From e9c64f65c8d21ab65b7ea3ac0ad84dc4f6f706b6 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 17 Nov 2025 13:36:46 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=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=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 소프트 삭제(is_active=false)에서 하드 삭제(DELETE)로 변경 - 하위 카테고리 체크 시 is_active 조건 제거하여 정확성 향상 - 불필요한 updated_at, updated_by 파라미터 제거 --- .../src/services/tableCategoryValueService.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index bffb0d05..588d9a9c 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -445,7 +445,7 @@ class TableCategoryValueService { } /** - * 카테고리 값 삭제 (비활성화) + * 카테고리 값 삭제 (물리적 삭제) */ async deleteCategoryValue( valueId: number, @@ -465,7 +465,6 @@ class TableCategoryValueService { SELECT COUNT(*) as count FROM table_column_category_values WHERE parent_value_id = $1 - AND is_active = true `; checkParams = [valueId]; } else { @@ -475,7 +474,6 @@ class TableCategoryValueService { FROM table_column_category_values WHERE parent_value_id = $1 AND company_code = $2 - AND is_active = true `; checkParams = [valueId, companyCode]; } @@ -486,27 +484,25 @@ class TableCategoryValueService { throw new Error("하위 카테고리 값이 있어 삭제할 수 없습니다"); } - // 비활성화 (멀티테넌시 적용) + // 물리적 삭제 (멀티테넌시 적용) let deleteQuery: string; let deleteParams: any[]; if (companyCode === "*") { // 최고 관리자: 모든 카테고리 값 삭제 가능 deleteQuery = ` - UPDATE table_column_category_values - SET is_active = false, updated_at = NOW(), updated_by = $2 + DELETE FROM table_column_category_values WHERE value_id = $1 `; - deleteParams = [valueId, userId]; + deleteParams = [valueId]; } else { // 일반 회사: 자신의 카테고리 값만 삭제 가능 deleteQuery = ` - UPDATE table_column_category_values - SET is_active = false, updated_at = NOW(), updated_by = $3 + DELETE FROM table_column_category_values WHERE value_id = $1 AND company_code = $2 `; - deleteParams = [valueId, companyCode, userId]; + deleteParams = [valueId, companyCode]; } const result = await pool.query(deleteQuery, deleteParams); @@ -515,7 +511,7 @@ class TableCategoryValueService { throw new Error("카테고리 값을 찾을 수 없거나 권한이 없습니다"); } - logger.info("카테고리 값 삭제(비활성화) 완료", { + logger.info("카테고리 값 삭제 완료", { valueId, companyCode, }); From 12000ca059d7cc4f42a6a9c551578d7c0e099869 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 17 Nov 2025 14:22:50 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=82=AD=EC=A0=9C=20=EC=8B=9C=20=EC=8B=A4=EC=A0=9C?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=82=AC=EC=9A=A9=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=20=ED=99=95=EC=9D=B8=20=EB=B0=8F=20=EC=B0=A8=EB=8B=A8?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카테고리 값/컬럼이 실제 데이터에서 사용 중이면 삭제 차단 - 사용 중인 데이터 개수 및 메뉴 목록 표시 - 물리적 삭제 방식으로 변경 - 상세 에러 메시지 팝업 추가 --- .../tableCategoryValueController.ts | 10 ++ .../src/services/tableCategoryValueService.ts | 143 +++++++++++++++++- .../table-category/CategoryValueManager.tsx | 9 +- frontend/lib/api/tableCategoryValue.ts | 5 +- .../lib/registry/DynamicComponentRenderer.tsx | 52 ++----- 5 files changed, 178 insertions(+), 41 deletions(-) diff --git a/backend-node/src/controllers/tableCategoryValueController.ts b/backend-node/src/controllers/tableCategoryValueController.ts index 8bb2b0db..c25b4127 100644 --- a/backend-node/src/controllers/tableCategoryValueController.ts +++ b/backend-node/src/controllers/tableCategoryValueController.ts @@ -187,6 +187,16 @@ export const deleteCategoryValue = async (req: AuthenticatedRequest, res: Respon }); } catch (error: any) { logger.error(`카테고리 값 삭제 실패: ${error.message}`); + + // 사용 중인 경우 상세 에러 메시지 반환 (400) + if (error.message.includes("삭제할 수 없습니다")) { + return res.status(400).json({ + success: false, + message: error.message, + }); + } + + // 기타 에러 (500) return res.status(500).json({ success: false, message: error.message || "카테고리 값 삭제 중 오류가 발생했습니다", diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index 588d9a9c..2a379ae0 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -444,6 +444,128 @@ class TableCategoryValueService { } } + /** + * 카테고리 값 사용 여부 확인 + * 실제 데이터 테이블에서 해당 카테고리 값이 사용되고 있는지 확인 + */ + async checkCategoryValueUsage( + valueId: number, + companyCode: string + ): Promise<{ isUsed: boolean; usedInTables: any[]; totalCount: number }> { + const pool = getPool(); + + try { + logger.info("카테고리 값 사용 여부 확인", { valueId, companyCode }); + + // 1. 카테고리 값 정보 조회 + let valueQuery: string; + let valueParams: any[]; + + if (companyCode === "*") { + valueQuery = ` + SELECT table_name, column_name, value_code + FROM table_column_category_values + WHERE value_id = $1 + `; + valueParams = [valueId]; + } else { + valueQuery = ` + SELECT table_name, column_name, value_code + FROM table_column_category_values + WHERE value_id = $1 + AND company_code = $2 + `; + valueParams = [valueId, companyCode]; + } + + const valueResult = await pool.query(valueQuery, valueParams); + + if (valueResult.rowCount === 0) { + throw new Error("카테고리 값을 찾을 수 없습니다"); + } + + const { table_name, column_name, value_code } = valueResult.rows[0]; + + // 2. 실제 데이터 테이블에서 사용 여부 확인 + // 테이블이 존재하는지 먼저 확인 + const tableExistsQuery = ` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = $1 + ) as exists + `; + + const tableExistsResult = await pool.query(tableExistsQuery, [table_name]); + + if (!tableExistsResult.rows[0].exists) { + logger.info("테이블이 존재하지 않음", { table_name }); + return { isUsed: false, usedInTables: [], totalCount: 0 }; + } + + // 3. 해당 테이블에서 value_code를 사용하는 데이터 개수 확인 + let dataCountQuery: string; + let dataCountParams: any[]; + + if (companyCode === "*") { + dataCountQuery = ` + SELECT COUNT(*) as count + FROM ${table_name} + WHERE ${column_name} = $1 + `; + dataCountParams = [value_code]; + } else { + dataCountQuery = ` + SELECT COUNT(*) as count + FROM ${table_name} + WHERE ${column_name} = $1 + AND company_code = $2 + `; + dataCountParams = [value_code, companyCode]; + } + + const dataCountResult = await pool.query(dataCountQuery, dataCountParams); + const totalCount = parseInt(dataCountResult.rows[0].count); + const isUsed = totalCount > 0; + + // 4. 사용 중인 메뉴 목록 조회 (해당 테이블을 사용하는 화면/메뉴) + const menuQuery = ` + SELECT DISTINCT + mi.objid as menu_objid, + mi.menu_name_kor as menu_name, + mi.menu_url + FROM menu_info mi + INNER JOIN screen_menu_assignments sma ON sma.menu_objid = mi.objid + INNER JOIN screen_definitions sd ON sd.screen_id = sma.screen_id + WHERE sd.table_name = $1 + AND mi.company_code = $2 + ORDER BY mi.menu_name_kor + `; + + const menuResult = await pool.query(menuQuery, [table_name, companyCode]); + + const usedInTables = menuResult.rows.map((row) => ({ + menuObjid: row.menu_objid, + menuName: row.menu_name, + menuUrl: row.menu_url, + tableName: table_name, + columnName: column_name, + })); + + logger.info("카테고리 값 사용 여부 확인 완료", { + valueId, + isUsed, + totalCount, + usedInMenusCount: usedInTables.length, + }); + + return { isUsed, usedInTables, totalCount }; + } catch (error: any) { + logger.error(`카테고리 값 사용 여부 확인 실패: ${error.message}`); + throw error; + } + } + /** * 카테고리 값 삭제 (물리적 삭제) */ @@ -455,7 +577,24 @@ class TableCategoryValueService { const pool = getPool(); try { - // 하위 값 체크 (멀티테넌시 적용) + // 1. 사용 여부 확인 + const usage = await this.checkCategoryValueUsage(valueId, companyCode); + + if (usage.isUsed) { + let errorMessage = "이 카테고리 값을 삭제할 수 없습니다.\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); + } + + // 2. 하위 값 체크 (멀티테넌시 적용) let checkQuery: string; let checkParams: any[]; @@ -484,7 +623,7 @@ class TableCategoryValueService { throw new Error("하위 카테고리 값이 있어 삭제할 수 없습니다"); } - // 물리적 삭제 (멀티테넌시 적용) + // 3. 물리적 삭제 (멀티테넌시 적용) let deleteQuery: string; let deleteParams: any[]; diff --git a/frontend/components/table-category/CategoryValueManager.tsx b/frontend/components/table-category/CategoryValueManager.tsx index fe9ac7be..98d23bae 100644 --- a/frontend/components/table-category/CategoryValueManager.tsx +++ b/frontend/components/table-category/CategoryValueManager.tsx @@ -184,11 +184,18 @@ export const CategoryValueManager: React.FC = ({ title: "성공", description: "카테고리 값이 삭제되었습니다", }); + } else { + // 백엔드에서 반환한 상세 에러 메시지 표시 + toast({ + title: "삭제 불가", + description: response.error || response.message || "카테고리 값 삭제에 실패했습니다", + variant: "destructive", + }); } } catch (error) { toast({ title: "오류", - description: "카테고리 값 삭제에 실패했습니다", + description: "카테고리 값 삭제 중 오류가 발생했습니다", variant: "destructive", }); } diff --git a/frontend/lib/api/tableCategoryValue.ts b/frontend/lib/api/tableCategoryValue.ts index bcaf4582..9316beb0 100644 --- a/frontend/lib/api/tableCategoryValue.ts +++ b/frontend/lib/api/tableCategoryValue.ts @@ -109,7 +109,10 @@ export async function deleteCategoryValue(valueId: number) { return response.data; } catch (error: any) { console.error("카테고리 값 삭제 실패:", error); - return { success: false, error: error.message }; + + // 백엔드에서 반환한 에러 메시지 전달 + const errorMessage = error.response?.data?.message || error.message; + return { success: false, error: errorMessage, message: errorMessage }; } } diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index e016b61d..144b1f1a 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -29,14 +29,7 @@ export interface ComponentRenderer { // 테이블 선택된 행 정보 (다중 선택 액션용) selectedRows?: any[]; selectedRowsData?: any[]; - onSelectedRowsChange?: ( - selectedRows: any[], - selectedRowsData: any[], - sortBy?: string, - sortOrder?: "asc" | "desc", - columnOrder?: string[], - tableDisplayData?: any[], - ) => void; + onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[]) => void; // 테이블 정렬 정보 (엑셀 다운로드용) sortBy?: string; sortOrder?: "asc" | "desc"; @@ -105,8 +98,6 @@ export interface DynamicComponentRendererProps { screenId?: number; tableName?: string; menuId?: number; // 🆕 메뉴 ID (카테고리 관리 등에 필요) - // 🆕 조건부 컨테이너 높이 변화 콜백 - onHeightChange?: (componentId: string, newHeight: number) => void; menuObjid?: number; // 🆕 메뉴 OBJID (메뉴 스코프 - 카테고리/채번) selectedScreen?: any; // 🆕 화면 정보 전체 (menuId 등 추출용) userId?: string; // 🆕 현재 사용자 ID @@ -117,14 +108,7 @@ export interface DynamicComponentRendererProps { // 테이블 선택된 행 정보 (다중 선택 액션용) selectedRows?: any[]; selectedRowsData?: any[]; - onSelectedRowsChange?: ( - selectedRows: any[], - selectedRowsData: any[], - sortBy?: string, - sortOrder?: "asc" | "desc", - columnOrder?: string[], - tableDisplayData?: any[], - ) => void; + onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[]) => void; // 테이블 정렬 정보 (엑셀 다운로드용) sortBy?: string; sortOrder?: "asc" | "desc"; @@ -164,14 +148,14 @@ export const DynamicComponentRenderer: React.FC = const webType = (component as any).componentConfig?.webType; const tableName = (component as any).tableName; const columnName = (component as any).columnName; - + // 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만 if ((inputType === "category" || webType === "category") && tableName && columnName) { try { const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent"); const fieldName = columnName || component.id; const currentValue = props.formData?.[fieldName] || ""; - + const handleChange = (value: any) => { if (props.onFormDataChange) { props.onFormDataChange(fieldName, value); @@ -270,7 +254,6 @@ export const DynamicComponentRenderer: React.FC = onConfigChange, isPreview, autoGeneration, - onHeightChange, // 🆕 높이 변화 콜백 ...restProps } = props; @@ -307,12 +290,12 @@ export const DynamicComponentRenderer: React.FC = // 숨김 값 추출 const hiddenValue = component.hidden || component.componentConfig?.hidden; - // 🆕 조건부 컨테이너용 높이 변화 핸들러 - const handleHeightChange = props.onHeightChange - ? (newHeight: number) => { - props.onHeightChange!(component.id, newHeight); - } - : undefined; + // size.width와 size.height를 style.width와 style.height로 변환 + const finalStyle: React.CSSProperties = { + ...component.style, + width: component.size?.width ? `${component.size.width}px` : component.style?.width, + height: component.size?.height ? `${component.size.height}px` : component.style?.height, + }; const rendererProps = { component, @@ -322,7 +305,7 @@ export const DynamicComponentRenderer: React.FC = onDragEnd, size: component.size || newComponent.defaultSize, position: component.position, - style: component.style, // 컴포넌트 스타일 전달 + style: finalStyle, // size를 포함한 최종 style config: component.componentConfig, componentConfig: component.componentConfig, value: currentValue, // formData에서 추출한 현재 값 전달 @@ -362,9 +345,6 @@ export const DynamicComponentRenderer: React.FC = tableDisplayData, // 🆕 화면 표시 데이터 // 플로우 선택된 데이터 정보 전달 flowSelectedData, - // 🆕 조건부 컨테이너 높이 변화 콜백 - onHeightChange: handleHeightChange, - componentId: component.id, flowSelectedStepId, onFlowSelectedDataChange, // 설정 변경 핸들러 전달 @@ -390,9 +370,7 @@ export const DynamicComponentRenderer: React.FC = return rendererInstance.render(); } else { // 함수형 컴포넌트 - // config 내부 속성도 펼쳐서 전달 (tableName, displayField 등) - const configProps = component.componentConfig?.config || component.componentConfig || {}; - return ; + return ; } } } catch (error) { @@ -414,10 +392,10 @@ export const DynamicComponentRenderer: React.FC = // 폴백 렌더링 - 기본 플레이스홀더 return ( -
+
-
{component.label || component.id}
-
미구현 컴포넌트: {componentType}
+
{component.label || component.id}
+
미구현 컴포넌트: {componentType}
);