feat: Enhance screen group deletion functionality with optional numbering rules deletion

- Added a new query parameter `deleteNumberingRules` to the `deleteScreenGroup` function, allowing users to specify if numbering rules should be deleted when a root screen group is removed.
- Updated the `deleteScreenGroup` controller to handle the deletion of numbering rules conditionally based on the new parameter.
- Enhanced the frontend `ScreenGroupTreeView` component to include a checkbox for users to confirm the deletion of numbering rules when deleting a root group, improving user control and clarity during deletion operations.
- Implemented appropriate warnings and messages to inform users about the implications of deleting numbering rules, ensuring better user experience and data integrity awareness.
This commit is contained in:
kjs 2026-03-04 18:42:44 +09:00
parent 93d9df3e5a
commit f97edad1ea
7 changed files with 158 additions and 58 deletions

View File

@ -308,6 +308,7 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response
const client = await pool.connect(); const client = await pool.connect();
try { try {
const { id } = req.params; const { id } = req.params;
const deleteNumberingRules = req.query.deleteNumberingRules === "true";
const companyCode = req.user?.companyCode || "*"; const companyCode = req.user?.companyCode || "*";
await client.query('BEGIN'); await client.query('BEGIN');
@ -380,31 +381,29 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response
}); });
} }
// 2-4. 해당 회사의 채번 규칙 삭제 (최상위 그룹 삭제 시) // 2-4. 해당 회사의 채번 규칙 삭제 (최상위 그룹 삭제 + 사용자가 명시적으로 요청한 경우에만)
// 삭제되는 그룹이 최상위인지 확인 if (deleteNumberingRules) {
const isRootGroup = await client.query( const isRootGroup = await client.query(
`SELECT 1 FROM screen_groups WHERE id = $1 AND parent_group_id IS NULL`, `SELECT 1 FROM screen_groups WHERE id = $1 AND parent_group_id IS NULL`,
[id] [id]
);
if (isRootGroup.rows.length > 0) {
// 최상위 그룹 삭제 시 해당 회사의 채번 규칙도 삭제
// 먼저 파트 삭제
await client.query(
`DELETE FROM numbering_rule_parts
WHERE rule_id IN (SELECT rule_id FROM numbering_rules WHERE company_code = $1)`,
[targetCompanyCode]
); );
// 규칙 삭제
const deletedRules = await client.query( if (isRootGroup.rows.length > 0) {
`DELETE FROM numbering_rules WHERE company_code = $1 RETURNING rule_id`, await client.query(
[targetCompanyCode] `DELETE FROM numbering_rule_parts
); WHERE rule_id IN (SELECT rule_id FROM numbering_rules WHERE company_code = $1)`,
if (deletedRules.rowCount && deletedRules.rowCount > 0) { [targetCompanyCode]
logger.info("그룹 삭제 시 채번 규칙 삭제", { );
companyCode: targetCompanyCode, const deletedRules = await client.query(
deletedCount: deletedRules.rowCount `DELETE FROM numbering_rules WHERE company_code = $1 RETURNING rule_id`,
}); [targetCompanyCode]
);
if (deletedRules.rowCount && deletedRules.rowCount > 0) {
logger.warn("최상위 그룹 삭제 시 채번 규칙 삭제 (사용자 명시 요청)", {
companyCode: targetCompanyCode,
deletedCount: deletedRules.rowCount
});
}
} }
} }
} }

View File

@ -513,6 +513,15 @@ export class TableManagementService {
detailSettingsStr = JSON.stringify(settings.detailSettings); detailSettingsStr = JSON.stringify(settings.detailSettings);
} }
// 입력타입에 해당하지 않는 설정값은 NULL로 강제 초기화
const inputType = settings.inputType;
const referenceTable = inputType === "entity" ? (settings.referenceTable || null) : null;
const referenceColumn = inputType === "entity" ? (settings.referenceColumn || null) : null;
const displayColumn = inputType === "entity" ? (settings.displayColumn || null) : null;
const codeCategory = inputType === "code" ? (settings.codeCategory || null) : null;
const codeValue = inputType === "code" ? (settings.codeValue || null) : null;
const categoryRef = inputType === "category" ? (settings.categoryRef || null) : null;
await query( await query(
`INSERT INTO table_type_columns ( `INSERT INTO table_type_columns (
table_name, column_name, column_label, input_type, detail_settings, table_name, column_name, column_label, input_type, detail_settings,
@ -525,11 +534,11 @@ export class TableManagementService {
column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label), column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label),
input_type = COALESCE(EXCLUDED.input_type, table_type_columns.input_type), input_type = COALESCE(EXCLUDED.input_type, table_type_columns.input_type),
detail_settings = COALESCE(EXCLUDED.detail_settings, table_type_columns.detail_settings), detail_settings = COALESCE(EXCLUDED.detail_settings, table_type_columns.detail_settings),
code_category = COALESCE(EXCLUDED.code_category, table_type_columns.code_category), code_category = EXCLUDED.code_category,
code_value = COALESCE(EXCLUDED.code_value, table_type_columns.code_value), code_value = EXCLUDED.code_value,
reference_table = COALESCE(EXCLUDED.reference_table, table_type_columns.reference_table), reference_table = EXCLUDED.reference_table,
reference_column = COALESCE(EXCLUDED.reference_column, table_type_columns.reference_column), reference_column = EXCLUDED.reference_column,
display_column = COALESCE(EXCLUDED.display_column, table_type_columns.display_column), display_column = EXCLUDED.display_column,
display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order), display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order),
is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible), is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible),
category_ref = EXCLUDED.category_ref, category_ref = EXCLUDED.category_ref,
@ -538,17 +547,17 @@ export class TableManagementService {
tableName, tableName,
columnName, columnName,
settings.columnLabel, settings.columnLabel,
settings.inputType, inputType,
detailSettingsStr, detailSettingsStr,
settings.codeCategory, codeCategory,
settings.codeValue, codeValue,
settings.referenceTable, referenceTable,
settings.referenceColumn, referenceColumn,
settings.displayColumn, displayColumn,
settings.displayOrder || 0, settings.displayOrder || 0,
settings.isVisible !== undefined ? settings.isVisible : true, settings.isVisible !== undefined ? settings.isVisible : true,
companyCode, companyCode,
settings.categoryRef || null, categoryRef,
] ]
); );
@ -849,16 +858,26 @@ export class TableManagementService {
...detailSettings, ...detailSettings,
}; };
// table_type_columns 테이블에서 업데이트 (company_code 추가) // 입력타입 변경 시 이전 타입의 설정값 초기화
const clearEntity = finalInputType !== "entity";
const clearCode = finalInputType !== "code";
const clearCategory = finalInputType !== "category";
await query( await query(
`INSERT INTO table_type_columns ( `INSERT INTO table_type_columns (
table_name, column_name, input_type, detail_settings, table_name, column_name, input_type, detail_settings,
is_nullable, display_order, company_code, created_date, updated_date is_nullable, display_order, company_code, created_date, updated_date
) VALUES ($1, $2, $3, $4, 'Y', 0, $5, now(), now()) ) VALUES ($1, $2, $3, $4, 'Y', 0, $5, now(), now())
ON CONFLICT (table_name, column_name, company_code) ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET DO UPDATE SET
input_type = EXCLUDED.input_type, input_type = EXCLUDED.input_type,
detail_settings = EXCLUDED.detail_settings, detail_settings = EXCLUDED.detail_settings,
reference_table = CASE WHEN $6 THEN NULL ELSE table_type_columns.reference_table END,
reference_column = CASE WHEN $6 THEN NULL ELSE table_type_columns.reference_column END,
display_column = CASE WHEN $6 THEN NULL ELSE table_type_columns.display_column END,
code_category = CASE WHEN $7 THEN NULL ELSE table_type_columns.code_category END,
code_value = CASE WHEN $7 THEN NULL ELSE table_type_columns.code_value END,
category_ref = CASE WHEN $8 THEN NULL ELSE table_type_columns.category_ref END,
updated_date = now()`, updated_date = now()`,
[ [
tableName, tableName,
@ -866,6 +885,9 @@ export class TableManagementService {
finalInputType, finalInputType,
JSON.stringify(finalDetailSettings), JSON.stringify(finalDetailSettings),
companyCode, companyCode,
clearEntity,
clearCode,
clearCategory,
] ]
); );

View File

@ -453,18 +453,39 @@ export default function TableManagementPage() {
[loadColumnTypes, loadConstraints, pageSize, tables], [loadColumnTypes, loadConstraints, pageSize, tables],
); );
// 입력 타입 변경 // 입력 타입 변경 - 이전 타입의 설정값 초기화 포함
const handleInputTypeChange = useCallback( const handleInputTypeChange = useCallback(
(columnName: string, newInputType: string) => { (columnName: string, newInputType: string) => {
setColumns((prev) => setColumns((prev) =>
prev.map((col) => { prev.map((col) => {
if (col.columnName === columnName) { if (col.columnName === columnName) {
const inputTypeOption = memoizedInputTypeOptions.find((option) => option.value === newInputType); const inputTypeOption = memoizedInputTypeOptions.find((option) => option.value === newInputType);
return { const updated: typeof col = {
...col, ...col,
inputType: newInputType, inputType: newInputType,
detailSettings: inputTypeOption?.description || col.detailSettings, detailSettings: inputTypeOption?.description || col.detailSettings,
}; };
// 엔티티가 아닌 타입으로 변경 시 참조 설정 초기화
if (newInputType !== "entity") {
updated.referenceTable = undefined;
updated.referenceColumn = undefined;
updated.displayColumn = undefined;
}
// 코드가 아닌 타입으로 변경 시 코드 설정 초기화
if (newInputType !== "code") {
updated.codeCategory = undefined;
updated.codeValue = undefined;
updated.hierarchyRole = undefined;
}
// 카테고리가 아닌 타입으로 변경 시 카테고리 참조 초기화
if (newInputType !== "category") {
updated.categoryRef = undefined;
}
return updated;
} }
return col; return col;
}), }),

View File

@ -135,6 +135,7 @@ export function ScreenGroupTreeView({
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deletingGroup, setDeletingGroup] = useState<ScreenGroup | null>(null); const [deletingGroup, setDeletingGroup] = useState<ScreenGroup | null>(null);
const [deleteScreensWithGroup, setDeleteScreensWithGroup] = useState(false); // 화면도 함께 삭제 체크박스 const [deleteScreensWithGroup, setDeleteScreensWithGroup] = useState(false); // 화면도 함께 삭제 체크박스
const [deleteNumberingRules, setDeleteNumberingRules] = useState(false); // 채번 규칙도 함께 삭제 체크박스
const [isDeleting, setIsDeleting] = useState(false); // 삭제 진행 중 상태 const [isDeleting, setIsDeleting] = useState(false); // 삭제 진행 중 상태
const [deleteProgress, setDeleteProgress] = useState({ current: 0, total: 0, message: "" }); // 삭제 진행 상태 const [deleteProgress, setDeleteProgress] = useState({ current: 0, total: 0, message: "" }); // 삭제 진행 상태
@ -439,7 +440,8 @@ export function ScreenGroupTreeView({
const handleDeleteGroup = (group: ScreenGroup, e?: React.MouseEvent) => { const handleDeleteGroup = (group: ScreenGroup, e?: React.MouseEvent) => {
e?.stopPropagation(); e?.stopPropagation();
setDeletingGroup(group); setDeletingGroup(group);
setDeleteScreensWithGroup(false); // 기본값: 화면 삭제 안함 setDeleteScreensWithGroup(false);
setDeleteNumberingRules(false);
setIsDeleteDialogOpen(true); setIsDeleteDialogOpen(true);
}; };
@ -572,11 +574,17 @@ export function ScreenGroupTreeView({
// 최종적으로 대상 그룹 삭제 // 최종적으로 대상 그룹 삭제
currentStep++; currentStep++;
setDeleteProgress({ current: currentStep, total: totalSteps, message: "그룹 삭제 완료 중..." }); setDeleteProgress({ current: currentStep, total: totalSteps, message: "그룹 삭제 완료 중..." });
const response = await deleteScreenGroup(deletingGroup.id); const isRootGroup = !deletingGroup.parent_group_id;
const response = await deleteScreenGroup(deletingGroup.id, {
deleteNumberingRules: isRootGroup && deleteNumberingRules,
});
if (response.success) { if (response.success) {
const messages = [];
if (deleteScreensWithGroup) messages.push(`화면 ${totalScreensToDelete}`);
if (isRootGroup && deleteNumberingRules) messages.push("채번 규칙");
toast.success( toast.success(
deleteScreensWithGroup messages.length > 0
? `그룹과 화면 ${totalScreensToDelete}개가 삭제되었습니다` ? `그룹과 ${messages.join(", ")}이(가) 삭제되었습니다`
: "그룹이 삭제되었습니다" : "그룹이 삭제되었습니다"
); );
await loadGroupsData(); await loadGroupsData();
@ -593,6 +601,7 @@ export function ScreenGroupTreeView({
setIsDeleteDialogOpen(false); setIsDeleteDialogOpen(false);
setDeletingGroup(null); setDeletingGroup(null);
setDeleteScreensWithGroup(false); setDeleteScreensWithGroup(false);
setDeleteNumberingRules(false);
} }
}; };
@ -1479,7 +1488,7 @@ export function ScreenGroupTreeView({
</p> </p>
<p className="mt-2 text-destructive/80"> <p className="mt-2 text-destructive/80">
{deleteScreensWithGroup {deleteScreensWithGroup
? "⚠️ 그룹에 속한 모든 화면, 플로우, 관련 데이터가 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다." ? "그룹에 속한 모든 화면, 플로우, 관련 데이터가 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다."
: "그룹에 속한 화면들은 미분류로 이동됩니다." : "그룹에 속한 화면들은 미분류로 이동됩니다."
} }
</p> </p>
@ -1520,6 +1529,43 @@ export function ScreenGroupTreeView({
</label> </label>
</div> </div>
)} )}
{/* 최상위 그룹일 때 채번 삭제 경고 */}
{deletingGroup && !deletingGroup.parent_group_id && (
<div className="space-y-3">
<div className="rounded-md border-2 border-destructive bg-destructive/5 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-6 w-6 shrink-0 text-destructive" />
<div className="space-y-2">
<p className="text-sm font-bold text-destructive">
-
</p>
<p className="text-xs text-destructive/90 leading-relaxed">
.
<span className="font-bold underline"> </span>.
, .
</p>
</div>
</div>
</div>
<div className="flex items-center space-x-2 rounded-md border border-destructive/30 bg-destructive/5 p-3">
<input
type="checkbox"
id="deleteNumberingRules"
checked={deleteNumberingRules}
onChange={(e) => setDeleteNumberingRules(e.target.checked)}
className="h-4 w-4 rounded border-destructive text-destructive focus:ring-destructive"
/>
<label
htmlFor="deleteNumberingRules"
className="cursor-pointer text-sm font-semibold text-destructive"
>
()
</label>
</div>
</div>
)}
{/* 로딩 오버레이 */} {/* 로딩 오버레이 */}
{isDeleting && ( {isDeleting && (
@ -1551,7 +1597,7 @@ export function ScreenGroupTreeView({
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={(e) => { onClick={(e) => {
e.preventDefault(); // 자동 닫힘 방지 e.preventDefault();
confirmDeleteGroup(); confirmDeleteGroup();
}} }}
disabled={isDeleting} disabled={isDeleting}

View File

@ -156,9 +156,15 @@ export async function updateScreenGroup(id: number, data: Partial<ScreenGroup>):
} }
} }
export async function deleteScreenGroup(id: number): Promise<ApiResponse<void>> { export async function deleteScreenGroup(id: number, options?: { deleteNumberingRules?: boolean }): Promise<ApiResponse<void>> {
try { try {
const response = await apiClient.delete(`/screen-groups/groups/${id}`); const params = new URLSearchParams();
if (options?.deleteNumberingRules) {
params.set("deleteNumberingRules", "true");
}
const queryString = params.toString();
const url = `/screen-groups/groups/${id}${queryString ? `?${queryString}` : ""}`;
const response = await apiClient.delete(url);
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
return { success: false, error: error.message }; return { success: false, error: error.message };

View File

@ -649,11 +649,12 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
title: config.rightPanel?.addButtonLabel || "추가", title: config.rightPanel?.addButtonLabel || "추가",
modalSize: "lg", modalSize: "lg",
editData: initialData, editData: initialData,
isCreateMode: true, // 생성 모드 isCreateMode: true,
onSave: () => { onSave: () => {
if (selectedLeftItem) { if (selectedLeftItem) {
loadRightData(selectedLeftItem); loadRightData(selectedLeftItem);
} }
loadLeftData();
}, },
}, },
}); });
@ -664,6 +665,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
config.dataTransferFields, config.dataTransferFields,
selectedLeftItem, selectedLeftItem,
loadRightData, loadRightData,
loadLeftData,
]); ]);
// 기본키 컬럼명 가져오기 (우측 패널) // 기본키 컬럼명 가져오기 (우측 패널)
@ -722,19 +724,20 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
screenId: modalScreenId, screenId: modalScreenId,
title: "수정", title: "수정",
modalSize: "lg", modalSize: "lg",
editData: editData, // 병합된 데이터 전달 editData: editData,
isCreateMode: false, // 수정 모드 isCreateMode: false,
onSave: () => { onSave: () => {
if (selectedLeftItem) { if (selectedLeftItem) {
loadRightData(selectedLeftItem); loadRightData(selectedLeftItem);
} }
loadLeftData();
}, },
}, },
}); });
window.dispatchEvent(event); window.dispatchEvent(event);
console.log("[SplitPanelLayout2] 우측 수정 모달 열기:", editData); console.log("[SplitPanelLayout2] 우측 수정 모달 열기:", editData);
}, },
[config.rightPanel?.editModalScreenId, config.rightPanel?.addModalScreenId, config.rightPanel?.mainTableForEdit, selectedLeftItem, loadRightData], [config.rightPanel?.editModalScreenId, config.rightPanel?.addModalScreenId, config.rightPanel?.mainTableForEdit, selectedLeftItem, loadRightData, loadLeftData],
); );
// 좌측 패널 수정 버튼 클릭 // 좌측 패널 수정 버튼 클릭
@ -835,10 +838,11 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
// 데이터 새로고침 // 데이터 새로고침
if (deleteTargetPanel === "left") { if (deleteTargetPanel === "left") {
loadLeftData(); loadLeftData();
setSelectedLeftItem(null); // 좌측 선택 초기화 setSelectedLeftItem(null);
setRightData([]); // 우측 데이터도 초기화 setRightData([]);
} else if (selectedLeftItem) { } else if (selectedLeftItem) {
loadRightData(selectedLeftItem); loadRightData(selectedLeftItem);
loadLeftData();
} }
} catch (error: any) { } catch (error: any) {
console.error("[SplitPanelLayout2] 삭제 실패:", error); console.error("[SplitPanelLayout2] 삭제 실패:", error);
@ -903,6 +907,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
if (selectedLeftItem) { if (selectedLeftItem) {
loadRightData(selectedLeftItem); loadRightData(selectedLeftItem);
} }
loadLeftData();
}, },
}, },
}); });
@ -917,7 +922,6 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
const selectedId = Array.from(selectedRightItems)[0]; const selectedId = Array.from(selectedRightItems)[0];
const item = rightData.find((d) => d[pkColumn] === selectedId); const item = rightData.find((d) => d[pkColumn] === selectedId);
if (item) { if (item) {
// 액션 버튼에 모달 화면이 설정되어 있으면 해당 화면 사용
const modalScreenId = btn.modalScreenId || config.rightPanel?.editModalScreenId || config.rightPanel?.addModalScreenId; const modalScreenId = btn.modalScreenId || config.rightPanel?.editModalScreenId || config.rightPanel?.addModalScreenId;
if (!modalScreenId) { if (!modalScreenId) {
@ -936,6 +940,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
if (selectedLeftItem) { if (selectedLeftItem) {
loadRightData(selectedLeftItem); loadRightData(selectedLeftItem);
} }
loadLeftData();
}, },
}, },
}); });
@ -966,6 +971,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
selectedLeftItem, selectedLeftItem,
config.dataTransferFields, config.dataTransferFields,
loadRightData, loadRightData,
loadLeftData,
selectedRightItems, selectedRightItems,
getPrimaryKeyColumn, getPrimaryKeyColumn,
rightData, rightData,

View File

@ -2449,7 +2449,7 @@ export function TableSectionRenderer({
multiSelect={multiSelect} multiSelect={multiSelect}
filterCondition={conditionalFilterCondition} filterCondition={conditionalFilterCondition}
modalTitle={`${effectiveOptions.find((o) => o.value === modalCondition)?.label || modalCondition} - ${modalTitle}`} modalTitle={`${effectiveOptions.find((o) => o.value === modalCondition)?.label || modalCondition} - ${modalTitle}`}
alreadySelected={conditionalTableData[modalCondition] || []} alreadySelected={Object.values(conditionalTableData).flat()}
uniqueField={tableConfig.saveConfig?.uniqueField} uniqueField={tableConfig.saveConfig?.uniqueField}
onSelect={handleConditionalAddItems} onSelect={handleConditionalAddItems}
columnLabels={columnLabels} columnLabels={columnLabels}