; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
This commit is contained in:
leeheejin 2026-01-16 18:15:21 +09:00
commit 0382c94d73
4 changed files with 246 additions and 78 deletions

View File

@ -1418,69 +1418,31 @@ export async function updateMenu(
} }
/** /**
* * ID를
*/ */
export async function deleteMenu( async function collectAllChildMenuIds(parentObjid: number): Promise<number[]> {
req: AuthenticatedRequest, const allIds: number[] = [];
res: Response
): Promise<void> {
try {
const { menuId } = req.params;
logger.info(`메뉴 삭제 요청: menuId = ${menuId}`, { user: req.user });
// 사용자의 company_code 확인 // 직접 자식 메뉴들 조회
if (!req.user?.companyCode) { const children = await query<any>(
res.status(400).json({ `SELECT objid FROM menu_info WHERE parent_obj_id = $1`,
success: false, [parentObjid]
message: "사용자의 회사 코드를 찾을 수 없습니다.",
error: "Missing company_code",
});
return;
}
const userCompanyCode = req.user.companyCode;
const userType = req.user.userType;
// 삭제하려는 메뉴 조회
const currentMenu = await queryOne<any>(
`SELECT objid, company_code FROM menu_info WHERE objid = $1`,
[Number(menuId)]
); );
if (!currentMenu) { for (const child of children) {
res.status(404).json({ allIds.push(child.objid);
success: false, // 자식의 자식들도 재귀적으로 수집
message: `메뉴를 찾을 수 없습니다: ${menuId}`, const grandChildren = await collectAllChildMenuIds(child.objid);
error: "Menu not found", allIds.push(...grandChildren);
});
return;
} }
// 공통 메뉴(company_code = '*')는 최고 관리자만 삭제 가능 return allIds;
if (currentMenu.company_code === "*") { }
if (userCompanyCode !== "*" || userType !== "SUPER_ADMIN") {
res.status(403).json({
success: false,
message: "공통 메뉴는 최고 관리자만 삭제할 수 있습니다.",
error: "Unauthorized to delete common menu",
});
return;
}
} else if (userCompanyCode !== "*") {
// 회사 관리자는 자기 회사 메뉴만 삭제 가능
if (currentMenu.company_code !== userCompanyCode) {
res.status(403).json({
success: false,
message: "해당 회사의 메뉴를 삭제할 권한이 없습니다.",
error: "Unauthorized to delete menu for this company",
});
return;
}
}
// 외래키 제약 조건이 있는 관련 테이블 데이터 먼저 정리
const menuObjid = Number(menuId);
/**
*
*/
async function cleanupMenuRelatedData(menuObjid: number): Promise<void> {
// 1. category_column_mapping에서 menu_objid를 NULL로 설정 // 1. category_column_mapping에서 menu_objid를 NULL로 설정
await query( await query(
`UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`, `UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`,
@ -1517,28 +1479,118 @@ export async function deleteMenu(
[menuObjid] [menuObjid]
); );
logger.info("메뉴 관련 데이터 정리 완료", { menuObjid }); // 7. screen_groups에서 menu_objid를 NULL로 설정
await query(
// Raw Query를 사용한 메뉴 삭제 `UPDATE screen_groups SET menu_objid = NULL WHERE menu_objid = $1`,
const [deletedMenu] = await query<any>(
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
[menuObjid] [menuObjid]
); );
}
logger.info("메뉴 삭제 성공", { deletedMenu }); /**
*
*/
export async function deleteMenu(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { menuId } = req.params;
logger.info(`메뉴 삭제 요청: menuId = ${menuId}`, { user: req.user });
// 사용자의 company_code 확인
if (!req.user?.companyCode) {
res.status(400).json({
success: false,
message: "사용자의 회사 코드를 찾을 수 없습니다.",
error: "Missing company_code",
});
return;
}
const userCompanyCode = req.user.companyCode;
const userType = req.user.userType;
// 삭제하려는 메뉴 조회
const currentMenu = await queryOne<any>(
`SELECT objid, company_code, menu_name_kor FROM menu_info WHERE objid = $1`,
[Number(menuId)]
);
if (!currentMenu) {
res.status(404).json({
success: false,
message: `메뉴를 찾을 수 없습니다: ${menuId}`,
error: "Menu not found",
});
return;
}
// 공통 메뉴(company_code = '*')는 최고 관리자만 삭제 가능
if (currentMenu.company_code === "*") {
if (userCompanyCode !== "*" || userType !== "SUPER_ADMIN") {
res.status(403).json({
success: false,
message: "공통 메뉴는 최고 관리자만 삭제할 수 있습니다.",
error: "Unauthorized to delete common menu",
});
return;
}
} else if (userCompanyCode !== "*") {
// 회사 관리자는 자기 회사 메뉴만 삭제 가능
if (currentMenu.company_code !== userCompanyCode) {
res.status(403).json({
success: false,
message: "해당 회사의 메뉴를 삭제할 권한이 없습니다.",
error: "Unauthorized to delete menu for this company",
});
return;
}
}
const menuObjid = Number(menuId);
// 하위 메뉴들 재귀적으로 수집
const childMenuIds = await collectAllChildMenuIds(menuObjid);
const allMenuIdsToDelete = [menuObjid, ...childMenuIds];
logger.info(`메뉴 삭제 대상: 본인(${menuObjid}) + 하위 메뉴 ${childMenuIds.length}`, {
menuName: currentMenu.menu_name_kor,
totalCount: allMenuIdsToDelete.length,
childMenuIds,
});
// 모든 삭제 대상 메뉴에 대해 관련 데이터 정리
for (const objid of allMenuIdsToDelete) {
await cleanupMenuRelatedData(objid);
}
logger.info("메뉴 관련 데이터 정리 완료", {
menuObjid,
totalCleaned: allMenuIdsToDelete.length
});
// 하위 메뉴부터 역순으로 삭제 (외래키 제약 회피)
// 가장 깊은 하위부터 삭제해야 하므로 역순으로
const reversedIds = [...allMenuIdsToDelete].reverse();
for (const objid of reversedIds) {
await query(`DELETE FROM menu_info WHERE objid = $1`, [objid]);
}
logger.info("메뉴 삭제 성공", {
deletedMenuObjid: menuObjid,
deletedMenuName: currentMenu.menu_name_kor,
totalDeleted: allMenuIdsToDelete.length,
});
const response: ApiResponse<any> = { const response: ApiResponse<any> = {
success: true, success: true,
message: "메뉴가 성공적으로 삭제되었습니다.", message: `메뉴가 성공적으로 삭제되었습니다. (하위 메뉴 ${childMenuIds.length}개 포함)`,
data: { data: {
objid: deletedMenu.objid.toString(), objid: menuObjid.toString(),
menuNameKor: deletedMenu.menu_name_kor, menuNameKor: currentMenu.menu_name_kor,
menuNameEng: deletedMenu.menu_name_eng, deletedCount: allMenuIdsToDelete.length,
menuUrl: deletedMenu.menu_url, deletedChildCount: childMenuIds.length,
menuDesc: deletedMenu.menu_desc,
status: deletedMenu.status,
writer: deletedMenu.writer,
regdate: new Date(deletedMenu.regdate).toISOString(),
}, },
}; };
@ -1623,18 +1675,49 @@ export async function deleteMenusBatch(
} }
} }
// 모든 삭제 대상 메뉴 ID 수집 (하위 메뉴 포함)
const allMenuIdsToDelete = new Set<number>();
for (const menuId of menuIds) {
const objid = Number(menuId);
allMenuIdsToDelete.add(objid);
// 하위 메뉴들 재귀적으로 수집
const childMenuIds = await collectAllChildMenuIds(objid);
childMenuIds.forEach(id => allMenuIdsToDelete.add(Number(id)));
}
const allIdsArray = Array.from(allMenuIdsToDelete);
logger.info(`메뉴 일괄 삭제 대상: 선택 ${menuIds.length}개 + 하위 메뉴 포함 총 ${allIdsArray.length}`, {
selectedMenuIds: menuIds,
totalWithChildren: allIdsArray.length,
});
// 모든 삭제 대상 메뉴에 대해 관련 데이터 정리
for (const objid of allIdsArray) {
await cleanupMenuRelatedData(objid);
}
logger.info("메뉴 관련 데이터 정리 완료", {
totalCleaned: allIdsArray.length
});
// Raw Query를 사용한 메뉴 일괄 삭제 // Raw Query를 사용한 메뉴 일괄 삭제
let deletedCount = 0; let deletedCount = 0;
let failedCount = 0; let failedCount = 0;
const deletedMenus: any[] = []; const deletedMenus: any[] = [];
const failedMenuIds: string[] = []; const failedMenuIds: string[] = [];
// 하위 메뉴부터 삭제하기 위해 역순으로 정렬
const reversedIds = [...allIdsArray].reverse();
// 각 메뉴 ID에 대해 삭제 시도 // 각 메뉴 ID에 대해 삭제 시도
for (const menuId of menuIds) { for (const menuObjid of reversedIds) {
try { try {
const result = await query<any>( const result = await query<any>(
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`, `DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
[Number(menuId)] [menuObjid]
); );
if (result.length > 0) { if (result.length > 0) {
@ -1645,20 +1728,20 @@ export async function deleteMenusBatch(
}); });
} else { } else {
failedCount++; failedCount++;
failedMenuIds.push(menuId); failedMenuIds.push(String(menuObjid));
} }
} catch (error) { } catch (error) {
logger.error(`메뉴 삭제 실패 (ID: ${menuId}):`, error); logger.error(`메뉴 삭제 실패 (ID: ${menuObjid}):`, error);
failedCount++; failedCount++;
failedMenuIds.push(menuId); failedMenuIds.push(String(menuObjid));
} }
} }
logger.info("메뉴 일괄 삭제 완료", { logger.info("메뉴 일괄 삭제 완료", {
total: menuIds.length, requested: menuIds.length,
totalWithChildren: allIdsArray.length,
deletedCount, deletedCount,
failedCount, failedCount,
deletedMenus,
failedMenuIds, failedMenuIds,
}); });

View File

@ -2090,7 +2090,7 @@ export class MenuCopyService {
menu.menu_url, menu.menu_url,
menu.menu_desc, menu.menu_desc,
userId, userId,
menu.status, 'active', // 복제된 메뉴는 항상 활성화 상태
menu.system_name, menu.system_name,
targetCompanyCode, // 새 회사 코드 targetCompanyCode, // 새 회사 코드
menu.lang_key, menu.lang_key,

View File

@ -142,7 +142,7 @@ export async function syncScreenGroupsToMenu(
const newObjid = Date.now(); const newObjid = Date.now();
const createRootQuery = ` const createRootQuery = `
INSERT INTO menu_info (objid, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_type, company_code, writer, regdate, status) INSERT INTO menu_info (objid, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_type, company_code, writer, regdate, status)
VALUES ($1, 0, '사용자', 'User', 1, 1, $2, $3, NOW(), 'Y') VALUES ($1, 0, '사용자', 'User', 1, 1, $2, $3, NOW(), 'active')
RETURNING objid RETURNING objid
`; `;
const createRootResult = await client.query(createRootQuery, [newObjid, companyCode, userId]); const createRootResult = await client.query(createRootQuery, [newObjid, companyCode, userId]);
@ -159,12 +159,36 @@ export async function syncScreenGroupsToMenu(
groupIdToName.set(g.id, g.group_name?.trim().toLowerCase() || ''); groupIdToName.set(g.id, g.group_name?.trim().toLowerCase() || '');
}); });
// 5. 각 screen_group 처리 // 5. 최상위 회사 폴더 ID 찾기 (level 0, parent_group_id IS NULL)
// 이 폴더는 메뉴로 생성하지 않고, 하위 폴더들을 사용자 루트 바로 아래에 배치
const topLevelCompanyFolderIds = new Set<number>();
for (const group of screenGroupsResult.rows) {
if (group.group_level === 0 && group.parent_group_id === null) {
topLevelCompanyFolderIds.add(group.id);
// 최상위 폴더 → 사용자 루트에 매핑 (하위 폴더의 부모로 사용)
groupToMenuMap.set(group.id, userMenuRootObjid!);
logger.info("최상위 회사 폴더 스킵", { groupId: group.id, groupName: group.group_name });
}
}
// 6. 각 screen_group 처리
for (const group of screenGroupsResult.rows) { for (const group of screenGroupsResult.rows) {
const groupId = group.id; const groupId = group.id;
const groupName = group.group_name?.trim(); const groupName = group.group_name?.trim();
const groupNameLower = groupName?.toLowerCase() || ''; const groupNameLower = groupName?.toLowerCase() || '';
// 최상위 회사 폴더는 메뉴로 생성하지 않고 스킵
if (topLevelCompanyFolderIds.has(groupId)) {
result.skipped++;
result.details.push({
action: 'skipped',
sourceName: groupName,
sourceId: groupId,
reason: '최상위 회사 폴더 (메뉴 생성 스킵)',
});
continue;
}
// 이미 연결된 경우 - 실제로 메뉴가 존재하는지 확인 // 이미 연결된 경우 - 실제로 메뉴가 존재하는지 확인
if (group.menu_objid) { if (group.menu_objid) {
const menuExists = existingMenuObjids.has(Number(group.menu_objid)); const menuExists = existingMenuObjids.has(Number(group.menu_objid));
@ -237,11 +261,17 @@ export async function syncScreenGroupsToMenu(
const newObjid = Date.now() + groupId; // 고유 ID 보장 const newObjid = Date.now() + groupId; // 고유 ID 보장
// 부모 메뉴 objid 결정 // 부모 메뉴 objid 결정
// 우선순위: groupToMenuMap > parent_menu_objid (존재 확인 필수)
let parentMenuObjid = userMenuRootObjid; let parentMenuObjid = userMenuRootObjid;
if (group.parent_group_id && group.parent_menu_objid) { if (group.parent_group_id && groupToMenuMap.has(group.parent_group_id)) {
parentMenuObjid = Number(group.parent_menu_objid); // 현재 트랜잭션에서 생성된 부모 메뉴 사용
} else if (group.parent_group_id && groupToMenuMap.has(group.parent_group_id)) {
parentMenuObjid = groupToMenuMap.get(group.parent_group_id)!; parentMenuObjid = groupToMenuMap.get(group.parent_group_id)!;
} else if (group.parent_group_id && group.parent_menu_objid) {
// 기존 parent_menu_objid가 실제로 존재하는지 확인
const parentMenuExists = existingMenuObjids.has(Number(group.parent_menu_objid));
if (parentMenuExists) {
parentMenuObjid = Number(group.parent_menu_objid);
}
} }
// 같은 부모 아래에서 가장 높은 seq 조회 후 +1 // 같은 부모 아래에서 가장 높은 seq 조회 후 +1
@ -261,7 +291,7 @@ export async function syncScreenGroupsToMenu(
INSERT INTO menu_info ( INSERT INTO menu_info (
objid, parent_obj_id, menu_name_kor, menu_name_eng, objid, parent_obj_id, menu_name_kor, menu_name_eng,
seq, menu_type, company_code, writer, regdate, status, screen_group_id, menu_desc seq, menu_type, company_code, writer, regdate, status, screen_group_id, menu_desc
) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'Y', $8, $9) ) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9)
RETURNING objid RETURNING objid
`; `;
await client.query(insertMenuQuery, [ await client.query(insertMenuQuery, [

View File

@ -172,6 +172,7 @@ export function ScreenGroupTreeView({
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null); const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
const [syncDirection, setSyncDirection] = useState<"screen-to-menu" | "menu-to-screen" | "all" | null>(null); const [syncDirection, setSyncDirection] = useState<"screen-to-menu" | "menu-to-screen" | "all" | null>(null);
const [syncProgress, setSyncProgress] = useState<{ message: string; detail?: string } | null>(null);
// 회사 선택 (최고 관리자용) // 회사 선택 (최고 관리자용)
const { user } = useAuth(); const { user } = useAuth();
@ -328,14 +329,31 @@ export function ScreenGroupTreeView({
setIsSyncing(true); setIsSyncing(true);
setSyncDirection(direction); setSyncDirection(direction);
setSyncProgress({
message: direction === "screen-to-menu"
? "화면관리 → 메뉴 동기화 중..."
: "메뉴 → 화면관리 동기화 중...",
detail: "데이터를 분석하고 있습니다..."
});
try { try {
setSyncProgress({
message: direction === "screen-to-menu"
? "화면관리 → 메뉴 동기화 중..."
: "메뉴 → 화면관리 동기화 중...",
detail: "동기화 작업을 수행하고 있습니다..."
});
const response = direction === "screen-to-menu" const response = direction === "screen-to-menu"
? await syncScreenGroupsToMenu(targetCompanyCode) ? await syncScreenGroupsToMenu(targetCompanyCode)
: await syncMenuToScreenGroups(targetCompanyCode); : await syncMenuToScreenGroups(targetCompanyCode);
if (response.success) { if (response.success) {
const data = response.data; const data = response.data;
setSyncProgress({
message: "동기화 완료!",
detail: `생성 ${data?.created || 0}개, 연결 ${data?.linked || 0}개, 스킵 ${data?.skipped || 0}`
});
toast.success( toast.success(
`동기화 완료: 생성 ${data?.created || 0}개, 연결 ${data?.linked || 0}개, 스킵 ${data?.skipped || 0}` `동기화 완료: 생성 ${data?.created || 0}개, 연결 ${data?.linked || 0}개, 스킵 ${data?.skipped || 0}`
); );
@ -347,13 +365,17 @@ export function ScreenGroupTreeView({
setSyncStatus(statusResponse.data); setSyncStatus(statusResponse.data);
} }
} else { } else {
setSyncProgress(null);
toast.error(`동기화 실패: ${response.error || "알 수 없는 오류"}`); toast.error(`동기화 실패: ${response.error || "알 수 없는 오류"}`);
} }
} catch (error: any) { } catch (error: any) {
setSyncProgress(null);
toast.error(`동기화 실패: ${error.message}`); toast.error(`동기화 실패: ${error.message}`);
} finally { } finally {
setIsSyncing(false); setIsSyncing(false);
setSyncDirection(null); setSyncDirection(null);
// 3초 후 진행 메시지 초기화
setTimeout(() => setSyncProgress(null), 3000);
} }
}; };
@ -366,27 +388,42 @@ export function ScreenGroupTreeView({
setIsSyncing(true); setIsSyncing(true);
setSyncDirection("all"); setSyncDirection("all");
setSyncProgress({
message: "전체 회사 동기화 중...",
detail: "모든 회사의 데이터를 분석하고 있습니다..."
});
try { try {
setSyncProgress({
message: "전체 회사 동기화 중...",
detail: "양방향 동기화 작업을 수행하고 있습니다..."
});
const response = await syncAllCompanies(); const response = await syncAllCompanies();
if (response.success && response.data) { if (response.success && response.data) {
const data = response.data; const data = response.data;
setSyncProgress({
message: "전체 동기화 완료!",
detail: `${data.totalCompanies}개 회사, 생성 ${data.totalCreated}개, 연결 ${data.totalLinked}`
});
toast.success( toast.success(
`전체 동기화 완료: ${data.totalCompanies}개 회사, 생성 ${data.totalCreated}개, 연결 ${data.totalLinked}` `전체 동기화 완료: ${data.totalCompanies}개 회사, 생성 ${data.totalCreated}개, 연결 ${data.totalLinked}`
); );
// 그룹 데이터 새로고침 // 그룹 데이터 새로고침
await loadGroupsData(); await loadGroupsData();
// 동기화 다이얼로그 닫기
setIsSyncDialogOpen(false);
} else { } else {
setSyncProgress(null);
toast.error(`전체 동기화 실패: ${response.error || "알 수 없는 오류"}`); toast.error(`전체 동기화 실패: ${response.error || "알 수 없는 오류"}`);
} }
} catch (error: any) { } catch (error: any) {
setSyncProgress(null);
toast.error(`전체 동기화 실패: ${error.message}`); toast.error(`전체 동기화 실패: ${error.message}`);
} finally { } finally {
setIsSyncing(false); setIsSyncing(false);
setSyncDirection(null); setSyncDirection(null);
// 3초 후 진행 메시지 초기화
setTimeout(() => setSyncProgress(null), 3000);
} }
}; };
@ -979,6 +1016,7 @@ export function ScreenGroupTreeView({
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>
{isSuperAdmin && (
<Button <Button
onClick={handleOpenSyncDialog} onClick={handleOpenSyncDialog}
variant="ghost" variant="ghost"
@ -988,6 +1026,7 @@ export function ScreenGroupTreeView({
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
</Button> </Button>
)}
</div> </div>
{/* 트리 목록 */} {/* 트리 목록 */}
@ -1816,7 +1855,23 @@ export function ScreenGroupTreeView({
{/* 메뉴-화면그룹 동기화 다이얼로그 */} {/* 메뉴-화면그룹 동기화 다이얼로그 */}
<Dialog open={isSyncDialogOpen} onOpenChange={setIsSyncDialogOpen}> <Dialog open={isSyncDialogOpen} onOpenChange={setIsSyncDialogOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]"> <DialogContent className="max-w-[95vw] sm:max-w-[500px] overflow-hidden">
{/* 동기화 진행 중 오버레이 (삭제와 동일한 스타일) */}
{isSyncing && (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center rounded-lg bg-background/90 backdrop-blur-sm">
<Loader2 className="h-10 w-10 animate-spin text-primary" />
<p className="mt-4 text-sm font-medium">{syncProgress?.message || "동기화 중..."}</p>
{syncProgress?.detail && (
<p className="mt-1 text-xs text-muted-foreground">{syncProgress.detail}</p>
)}
<div className="mt-3 h-2 w-48 overflow-hidden rounded-full bg-secondary">
<div
className="h-full bg-primary animate-pulse"
style={{ width: "100%" }}
/>
</div>
</div>
)}
<DialogHeader> <DialogHeader>
<DialogTitle className="text-base sm:text-lg">- </DialogTitle> <DialogTitle className="text-base sm:text-lg">- </DialogTitle>
<DialogDescription className="text-xs sm:text-sm"> <DialogDescription className="text-xs sm:text-sm">